From dcacb4873570d44d10c8a081a1c4163d33e61683 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 24 Sep 2026 16:57:49 -0700 Subject: [PATCH 01/31] fix(dart): release RustBuffers instead of leaking them (#4072) Co-authored-by: Claude Opus 5.5 --- dart/moq_ffi/dart_test.yaml | 3 + dart/moq_ffi/lib/src/moq.dart | 195 +++++++---------------- dart/moq_ffi/lib/src/uniffi_runtime.dart | 26 ++- dart/moq_ffi/test/leak_test.dart | 91 +++++++++++ flake.nix | 9 +- quest/m1/README.md | 1 - quest/m1/dart-leak.md | 49 ------ quest/m1/dart-publish.md | 3 +- 8 files changed, 180 insertions(+), 197 deletions(-) create mode 100644 dart/moq_ffi/dart_test.yaml create mode 100644 dart/moq_ffi/test/leak_test.dart delete mode 100644 quest/m1/dart-leak.md diff --git a/dart/moq_ffi/dart_test.yaml b/dart/moq_ffi/dart_test.yaml new file mode 100644 index 0000000000..d11c7b216b --- /dev/null +++ b/dart/moq_ffi/dart_test.yaml @@ -0,0 +1,3 @@ +# Suites share one process, so a concurrent suite would add its allocations to +# the resident memory that leak_test.dart measures. +concurrency: 1 diff --git a/dart/moq_ffi/lib/src/moq.dart b/dart/moq_ffi/lib/src/moq.dart index 92e68c91cb..512cc11793 100644 --- a/dart/moq_ffi/lib/src/moq.dart +++ b/dart/moq_ffi/lib/src/moq.dart @@ -8116,14 +8116,9 @@ class FfiConverterOptionalBool { if (value == null) { return toRustBuffer(Uint8List.fromList([0])); } - final length = FfiConverterOptionalBool.allocationSize(value); - final Pointer frameData = calloc(length); - final buf = frameData.asTypedList(length); + final buf = Uint8List(FfiConverterOptionalBool.allocationSize(value)); FfiConverterOptionalBool.write(value, buf); - final bytes = calloc(); - bytes.ref.len = length; - bytes.ref.data = frameData; - return RustBuffer.fromBytes(bytes.ref); + return toRustBuffer(buf); } static int write(bool? value, Uint8List buf) { @@ -8166,14 +8161,9 @@ class FfiConverterOptionalDouble64 { if (value == null) { return toRustBuffer(Uint8List.fromList([0])); } - final length = FfiConverterOptionalDouble64.allocationSize(value); - final Pointer frameData = calloc(length); - final buf = frameData.asTypedList(length); + final buf = Uint8List(FfiConverterOptionalDouble64.allocationSize(value)); FfiConverterOptionalDouble64.write(value, buf); - final bytes = calloc(); - bytes.ref.len = length; - bytes.ref.data = frameData; - return RustBuffer.fromBytes(bytes.ref); + return toRustBuffer(buf); } static int write(double? value, Uint8List buf) { @@ -8216,14 +8206,11 @@ class FfiConverterOptionalMoqAnnounceUpdate { if (value == null) { return toRustBuffer(Uint8List.fromList([0])); } - final length = FfiConverterOptionalMoqAnnounceUpdate.allocationSize(value); - final Pointer frameData = calloc(length); - final buf = frameData.asTypedList(length); + final buf = Uint8List( + FfiConverterOptionalMoqAnnounceUpdate.allocationSize(value), + ); FfiConverterOptionalMoqAnnounceUpdate.write(value, buf); - final bytes = calloc(); - bytes.ref.len = length; - bytes.ref.data = frameData; - return RustBuffer.fromBytes(bytes.ref); + return toRustBuffer(buf); } static int write(MoqAnnounceUpdate? value, Uint8List buf) { @@ -8266,14 +8253,9 @@ class FfiConverterOptionalMoqCatalog { if (value == null) { return toRustBuffer(Uint8List.fromList([0])); } - final length = FfiConverterOptionalMoqCatalog.allocationSize(value); - final Pointer frameData = calloc(length); - final buf = frameData.asTypedList(length); + final buf = Uint8List(FfiConverterOptionalMoqCatalog.allocationSize(value)); FfiConverterOptionalMoqCatalog.write(value, buf); - final bytes = calloc(); - bytes.ref.len = length; - bytes.ref.data = frameData; - return RustBuffer.fromBytes(bytes.ref); + return toRustBuffer(buf); } static int write(MoqCatalog? value, Uint8List buf) { @@ -8316,14 +8298,11 @@ class FfiConverterOptionalMoqDatagram { if (value == null) { return toRustBuffer(Uint8List.fromList([0])); } - final length = FfiConverterOptionalMoqDatagram.allocationSize(value); - final Pointer frameData = calloc(length); - final buf = frameData.asTypedList(length); + final buf = Uint8List( + FfiConverterOptionalMoqDatagram.allocationSize(value), + ); FfiConverterOptionalMoqDatagram.write(value, buf); - final bytes = calloc(); - bytes.ref.len = length; - bytes.ref.data = frameData; - return RustBuffer.fromBytes(bytes.ref); + return toRustBuffer(buf); } static int write(MoqDatagram? value, Uint8List buf) { @@ -8366,14 +8345,11 @@ class FfiConverterOptionalMoqDimensions { if (value == null) { return toRustBuffer(Uint8List.fromList([0])); } - final length = FfiConverterOptionalMoqDimensions.allocationSize(value); - final Pointer frameData = calloc(length); - final buf = frameData.asTypedList(length); + final buf = Uint8List( + FfiConverterOptionalMoqDimensions.allocationSize(value), + ); FfiConverterOptionalMoqDimensions.write(value, buf); - final bytes = calloc(); - bytes.ref.len = length; - bytes.ref.data = frameData; - return RustBuffer.fromBytes(bytes.ref); + return toRustBuffer(buf); } static int write(MoqDimensions? value, Uint8List buf) { @@ -8421,16 +8397,11 @@ class FfiConverterOptionalMoqFetchGroupOptions { if (value == null) { return toRustBuffer(Uint8List.fromList([0])); } - final length = FfiConverterOptionalMoqFetchGroupOptions.allocationSize( - value, + final buf = Uint8List( + FfiConverterOptionalMoqFetchGroupOptions.allocationSize(value), ); - final Pointer frameData = calloc(length); - final buf = frameData.asTypedList(length); FfiConverterOptionalMoqFetchGroupOptions.write(value, buf); - final bytes = calloc(); - bytes.ref.len = length; - bytes.ref.data = frameData; - return RustBuffer.fromBytes(bytes.ref); + return toRustBuffer(buf); } static int write(MoqFetchGroupOptions? value, Uint8List buf) { @@ -8473,14 +8444,9 @@ class FfiConverterOptionalMoqFrame { if (value == null) { return toRustBuffer(Uint8List.fromList([0])); } - final length = FfiConverterOptionalMoqFrame.allocationSize(value); - final Pointer frameData = calloc(length); - final buf = frameData.asTypedList(length); + final buf = Uint8List(FfiConverterOptionalMoqFrame.allocationSize(value)); FfiConverterOptionalMoqFrame.write(value, buf); - final bytes = calloc(); - bytes.ref.len = length; - bytes.ref.data = frameData; - return RustBuffer.fromBytes(bytes.ref); + return toRustBuffer(buf); } static int write(MoqFrame? value, Uint8List buf) { @@ -8523,14 +8489,11 @@ class FfiConverterOptionalMoqGroupConsumer { if (value == null) { return toRustBuffer(Uint8List.fromList([0])); } - final length = FfiConverterOptionalMoqGroupConsumer.allocationSize(value); - final Pointer frameData = calloc(length); - final buf = frameData.asTypedList(length); + final buf = Uint8List( + FfiConverterOptionalMoqGroupConsumer.allocationSize(value), + ); FfiConverterOptionalMoqGroupConsumer.write(value, buf); - final bytes = calloc(); - bytes.ref.len = length; - bytes.ref.data = frameData; - return RustBuffer.fromBytes(bytes.ref); + return toRustBuffer(buf); } static int write(MoqGroupConsumer? value, Uint8List buf) { @@ -8573,14 +8536,11 @@ class FfiConverterOptionalMoqMediaFrame { if (value == null) { return toRustBuffer(Uint8List.fromList([0])); } - final length = FfiConverterOptionalMoqMediaFrame.allocationSize(value); - final Pointer frameData = calloc(length); - final buf = frameData.asTypedList(length); + final buf = Uint8List( + FfiConverterOptionalMoqMediaFrame.allocationSize(value), + ); FfiConverterOptionalMoqMediaFrame.write(value, buf); - final bytes = calloc(); - bytes.ref.len = length; - bytes.ref.data = frameData; - return RustBuffer.fromBytes(bytes.ref); + return toRustBuffer(buf); } static int write(MoqMediaFrame? value, Uint8List buf) { @@ -8623,14 +8583,11 @@ class FfiConverterOptionalMoqOriginProducer { if (value == null) { return toRustBuffer(Uint8List.fromList([0])); } - final length = FfiConverterOptionalMoqOriginProducer.allocationSize(value); - final Pointer frameData = calloc(length); - final buf = frameData.asTypedList(length); + final buf = Uint8List( + FfiConverterOptionalMoqOriginProducer.allocationSize(value), + ); FfiConverterOptionalMoqOriginProducer.write(value, buf); - final bytes = calloc(); - bytes.ref.len = length; - bytes.ref.data = frameData; - return RustBuffer.fromBytes(bytes.ref); + return toRustBuffer(buf); } static int write(MoqOriginProducer? value, Uint8List buf) { @@ -8673,14 +8630,9 @@ class FfiConverterOptionalMoqRequest { if (value == null) { return toRustBuffer(Uint8List.fromList([0])); } - final length = FfiConverterOptionalMoqRequest.allocationSize(value); - final Pointer frameData = calloc(length); - final buf = frameData.asTypedList(length); + final buf = Uint8List(FfiConverterOptionalMoqRequest.allocationSize(value)); FfiConverterOptionalMoqRequest.write(value, buf); - final bytes = calloc(); - bytes.ref.len = length; - bytes.ref.data = frameData; - return RustBuffer.fromBytes(bytes.ref); + return toRustBuffer(buf); } static int write(MoqRequest? value, Uint8List buf) { @@ -8723,14 +8675,11 @@ class FfiConverterOptionalMoqSubscription { if (value == null) { return toRustBuffer(Uint8List.fromList([0])); } - final length = FfiConverterOptionalMoqSubscription.allocationSize(value); - final Pointer frameData = calloc(length); - final buf = frameData.asTypedList(length); + final buf = Uint8List( + FfiConverterOptionalMoqSubscription.allocationSize(value), + ); FfiConverterOptionalMoqSubscription.write(value, buf); - final bytes = calloc(); - bytes.ref.len = length; - bytes.ref.data = frameData; - return RustBuffer.fromBytes(bytes.ref); + return toRustBuffer(buf); } static int write(MoqSubscription? value, Uint8List buf) { @@ -8773,14 +8722,11 @@ class FfiConverterOptionalMoqTrackInfo { if (value == null) { return toRustBuffer(Uint8List.fromList([0])); } - final length = FfiConverterOptionalMoqTrackInfo.allocationSize(value); - final Pointer frameData = calloc(length); - final buf = frameData.asTypedList(length); + final buf = Uint8List( + FfiConverterOptionalMoqTrackInfo.allocationSize(value), + ); FfiConverterOptionalMoqTrackInfo.write(value, buf); - final bytes = calloc(); - bytes.ref.len = length; - bytes.ref.data = frameData; - return RustBuffer.fromBytes(bytes.ref); + return toRustBuffer(buf); } static int write(MoqTrackInfo? value, Uint8List buf) { @@ -8823,14 +8769,11 @@ class FfiConverterOptionalMoqVideoHint { if (value == null) { return toRustBuffer(Uint8List.fromList([0])); } - final length = FfiConverterOptionalMoqVideoHint.allocationSize(value); - final Pointer frameData = calloc(length); - final buf = frameData.asTypedList(length); + final buf = Uint8List( + FfiConverterOptionalMoqVideoHint.allocationSize(value), + ); FfiConverterOptionalMoqVideoHint.write(value, buf); - final bytes = calloc(); - bytes.ref.len = length; - bytes.ref.data = frameData; - return RustBuffer.fromBytes(bytes.ref); + return toRustBuffer(buf); } static int write(MoqVideoHint? value, Uint8List buf) { @@ -8873,14 +8816,11 @@ class FfiConverterOptionalSequenceString { if (value == null) { return toRustBuffer(Uint8List.fromList([0])); } - final length = FfiConverterOptionalSequenceString.allocationSize(value); - final Pointer frameData = calloc(length); - final buf = frameData.asTypedList(length); + final buf = Uint8List( + FfiConverterOptionalSequenceString.allocationSize(value), + ); FfiConverterOptionalSequenceString.write(value, buf); - final bytes = calloc(); - bytes.ref.len = length; - bytes.ref.data = frameData; - return RustBuffer.fromBytes(bytes.ref); + return toRustBuffer(buf); } static int write(List? value, Uint8List buf) { @@ -8923,14 +8863,9 @@ class FfiConverterOptionalString { if (value == null) { return toRustBuffer(Uint8List.fromList([0])); } - final length = FfiConverterOptionalString.allocationSize(value); - final Pointer frameData = calloc(length); - final buf = frameData.asTypedList(length); + final buf = Uint8List(FfiConverterOptionalString.allocationSize(value)); FfiConverterOptionalString.write(value, buf); - final bytes = calloc(); - bytes.ref.len = length; - bytes.ref.data = frameData; - return RustBuffer.fromBytes(bytes.ref); + return toRustBuffer(buf); } static int write(String? value, Uint8List buf) { @@ -8973,14 +8908,9 @@ class FfiConverterOptionalUInt64 { if (value == null) { return toRustBuffer(Uint8List.fromList([0])); } - final length = FfiConverterOptionalUInt64.allocationSize(value); - final Pointer frameData = calloc(length); - final buf = frameData.asTypedList(length); + final buf = Uint8List(FfiConverterOptionalUInt64.allocationSize(value)); FfiConverterOptionalUInt64.write(value, buf); - final bytes = calloc(); - bytes.ref.len = length; - bytes.ref.data = frameData; - return RustBuffer.fromBytes(bytes.ref); + return toRustBuffer(buf); } static int write(int? value, Uint8List buf) { @@ -9023,14 +8953,9 @@ class FfiConverterOptionalUint8List { if (value == null) { return toRustBuffer(Uint8List.fromList([0])); } - final length = FfiConverterOptionalUint8List.allocationSize(value); - final Pointer frameData = calloc(length); - final buf = frameData.asTypedList(length); + final buf = Uint8List(FfiConverterOptionalUint8List.allocationSize(value)); FfiConverterOptionalUint8List.write(value, buf); - final bytes = calloc(); - bytes.ref.len = length; - bytes.ref.data = frameData; - return RustBuffer.fromBytes(bytes.ref); + return toRustBuffer(buf); } static int write(Uint8List? value, Uint8List buf) { @@ -9232,7 +9157,7 @@ class FfiConverterUint8List { static LiftRetVal read(Uint8List buf) { final length = buf.buffer.asByteData(buf.offsetInBytes).getInt32(0); - final bytes = Uint8List.view(buf.buffer, buf.offsetInBytes + 4, length); + final bytes = buf.sublist(4, 4 + length); return LiftRetVal(bytes, length + 4); } diff --git a/dart/moq_ffi/lib/src/uniffi_runtime.dart b/dart/moq_ffi/lib/src/uniffi_runtime.dart index ef3dc04b16..ab87a64ed3 100644 --- a/dart/moq_ffi/lib/src/uniffi_runtime.dart +++ b/dart/moq_ffi/lib/src/uniffi_runtime.dart @@ -71,11 +71,11 @@ void checkCallStatus( if (status.ref.code == CALL_SUCCESS) { return; } else if (status.ref.code == CALL_ERROR) { - throw errorHandler.lift(status.ref.errorBuf); + throw liftAndFree(status.ref.errorBuf, errorHandler.lift); } else if (status.ref.code == CALL_UNEXPECTED_ERROR) { if (status.ref.errorBuf.len > 0) { throw UniffiInternalError.panicked( - FfiConverterString.lift(status.ref.errorBuf), + liftAndFree(status.ref.errorBuf, FfiConverterString.lift), ); } else { throw UniffiInternalError.panicked("Rust panic"); @@ -101,6 +101,16 @@ T rustCall( } } +T liftAndFree(F raw, T Function(F) lifter) { + try { + return lifter(raw); + } finally { + if (raw is RustBuffer) { + raw.free(); + } + } +} + T rustCallWithLifter( F Function(Pointer) ffiCall, T Function(F) lifter, [ @@ -110,7 +120,7 @@ T rustCallWithLifter( try { final rawResult = ffiCall(status); checkCallStatus(errorHandler ?? NullRustCallStatusErrorHandler(), status); - return lifter(rawResult); + return liftAndFree(rawResult, lifter); } finally { calloc.free(status); } @@ -119,7 +129,6 @@ T rustCallWithLifter( class NullRustCallStatusErrorHandler extends UniffiRustCallStatusErrorHandler { @override Exception lift(RustBuffer errorBuf) { - errorBuf.free(); return UniffiInternalError.panicked("Unexpected CALL_ERROR"); } } @@ -175,7 +184,12 @@ RustBuffer toRustBuffer(Uint8List data) { final bytes = calloc(); bytes.ref.len = length; bytes.ref.data = frameData; - return RustBuffer.fromBytes(bytes.ref); + try { + return RustBuffer.fromBytes(bytes.ref); + } finally { + calloc.free(frameData); + calloc.free(bytes); + } } ForeignBytes lowerForeignBytes(Uint8List data) { @@ -306,7 +320,7 @@ Future uniffiRustCallAsync( try { final result = completeFunc(rustFuture, status); checkCallStatus(errorHandler ?? NullRustCallStatusErrorHandler(), status); - return liftFunc(result); + return liftAndFree(result, liftFunc); } finally { calloc.free(status); } diff --git a/dart/moq_ffi/test/leak_test.dart b/dart/moq_ffi/test/leak_test.dart new file mode 100644 index 0000000000..fe8ce19431 --- /dev/null +++ b/dart/moq_ffi/test/leak_test.dart @@ -0,0 +1,91 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:moq_ffi/moq_ffi.dart'; +import 'package:test/test.dart'; + +// Each call moves `size` bytes across the FFI boundary, so a leak of that +// buffer grows resident memory by `iterations * size`. Growth under a quarter of +// that leaves room for allocator and Dart heap noise without hiding a leak. +const size = 64 * 1024; +const iterations = 2000; +const leaked = size * iterations; + +void main() { + test('a returned String is released', () { + final track = MoqBroadcastProducer().publishTrack( + name: 'x' * size, + info: null, + ); + // Warm up so one-time allocations do not count as growth. + for (var i = 0; i < 100; i++) { + track.name(); + } + + final before = ProcessInfo.currentRss; + for (var i = 0; i < iterations; i++) { + track.name(); + } + final growth = ProcessInfo.currentRss - before; + + expect(growth, lessThan(leaked ~/ 4)); + }); + + test('a non-null optional argument is released', () async { + // A local broadcast has no origin to resolve against, so each call lowers + // the optional String and then throws, also covering the error buffer. + final consumer = MoqBroadcastProducer().consume(); + final reference = 'x' * size; + Future call() => expectLater( + consumer.resolve(reference: reference), + throwsA(isA()), + ); + + for (var i = 0; i < 100; i++) { + await call(); + } + + final before = ProcessInfo.currentRss; + for (var i = 0; i < iterations; i++) { + await call(); + } + final growth = ProcessInfo.currentRss - before; + + expect(growth, lessThan(leaked ~/ 4)); + }); + + test('an async return is released', () async { + final payload = Uint8List(size); + + // Handles are released deterministically so the only growth left is what + // the bindings leak, not frames a live track still holds. + Future roundTrip() async { + final broadcast = MoqBroadcastProducer(); + final track = broadcast.publishTrack(name: 'frames', info: null); + final consumer = track.consume(subscription: null); + final producer = track.appendGroup(); + producer.writeFrame(frame: MoqFrame(payload: payload)); + producer.finish(); + final group = await consumer.nextGroup(); + final frame = await group!.readFrame(); + expect(frame!.payload.length, size); + group.dispose(); + producer.dispose(); + consumer.dispose(); + track.dispose(); + broadcast.dispose(); + } + + for (var i = 0; i < 100; i++) { + await roundTrip(); + } + + final before = ProcessInfo.currentRss; + for (var i = 0; i < iterations; i++) { + await roundTrip(); + } + final growth = ProcessInfo.currentRss - before; + + expect(growth, lessThan(leaked ~/ 4)); + }); +} diff --git a/flake.nix b/flake.nix index 9cee6d70e4..028422fd1b 100644 --- a/flake.nix +++ b/flake.nix @@ -304,17 +304,18 @@ ]; # uniffi-bindgen-dart renders rs/moq-ffi into dart/moq_ffi. The fork - # carries the uniffi 0.32 port and library-mode CLI while those changes - # remain open upstream. + # carries the uniffi 0.32 port, library-mode CLI, and RustBuffer leak + # fixes while those changes remain open upstream. Its tags add a + # `-kixelated.N` pre-release so they never collide with upstream's. uniffi-bindgen-dart = pkgs.rustPlatform.buildRustPackage rec { pname = "uniffi-bindgen-dart"; - version = "0.3.0+v0.32.0"; + version = "0.3.1-kixelated.4+v0.32.0"; src = pkgs.fetchFromGitHub { owner = "kixelated"; repo = "uniffi-dart"; rev = "v${version}"; - hash = "sha256-jvVEZVZLorj+GPUXL6Y4riCLsbJcWWbQgIIUoK/ZSEo="; + hash = "sha256-BCIooajAp0Wqt7LeanFSdmS/GT0uYo+d8Qv2jGWCJD8="; }; # The upstream repository ignores Cargo.lock so cargo installs test diff --git a/quest/m1/README.md b/quest/m1/README.md index 76a1c964b3..077be24662 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -130,7 +130,6 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Dart on iOS](/quest/m1/dart-ios.md) - prove the shipped iOS native asset actually loads on a device, which no CI can - [libmoq shutdown](/quest/m1/libmoq-shutdown.md) - OBS exits cleanly with the plugin loaded: a C ABI `moq_shutdown` stops the libmoq thread before the module is unloaded - [Kotlin JVM exit](/quest/m1/kt-jvm-exit.md) - a Kotlin/JVM program exits cleanly whatever the moq-ffi runtime thread is doing, like Python does since #3766 -- [Dart leaks](/quest/m1/dart-leak.md) - the generated Dart bindings leak native memory on every call - [Dart publish](/quest/m1/dart-publish.md) - the packages are built and dry-run clean but exist nowhere consumers can install from - [Dart codec parity](/quest/m1/dart-codecs.md) - Dart is the one binding that cannot originate media - [libmoq fetch](/quest/m1/libmoq-fetch.md) - libmoq gains an additive cached-group fetch entry point diff --git a/quest/m1/dart-leak.md b/quest/m1/dart-leak.md deleted file mode 100644 index 9feb833b86..0000000000 --- a/quest/m1/dart-leak.md +++ /dev/null @@ -1,49 +0,0 @@ -# [M] Dart binding memory leaks - -## Goal - -Dart bindings stop leaking native memory on every call. Two independent leaks -in the generated runtime mean `announcement.path()` in a loop grows without -bound. - -## Plan - -- `toRustBuffer` allocates the scratch buffer and the `ForeignBytes` that Rust - copies from, and frees neither, so every string or struct **argument** leaks. - Passing the `RustBuffer` itself is fine, since uniffi's callee takes - ownership of that one. -- `rustCallWithLifter` frees the `RustCallStatus` but not the returned - `RustBuffer`, so every returned `String`, struct, and list leaks. - `RustBuffer.free()` appears exactly once in the whole binding, on the panic - path. - -The fix belongs upstream, not here: `dart/moq_ffi/lib/src/uniffi_runtime.dart` -is generated, and `dart/scripts/check.sh` diffs it against a fresh -`generate.sh` run, so an in-tree patch fails the staleness check by design. - -That upstream fix is merged as -[kixelated/uniffi-dart#1](https://github.com/kixelated/uniffi-dart/pull/1), on -the `uniffi-0.32` branch rather than `main`, which carries a different -(`0.31.2`) line. What remains here: tag it, repin `flake.nix` and -`nix/uniffi-dart-Cargo.lock`, and run `just dart generate`. - -Freeing the returned buffer alone would have been a use-after-free, which is -probably why nothing freed it. `BytesCodeType.read` returned a `Uint8List.view` -over the buffer, so a lifted byte array aliased Rust memory and escaped into -the caller. The byte read has to copy first; only then is the free sound. - -Expect red CI on that fork: `futures_test.dart: sleep` and the payjoin -downstream job both fail on an unmodified `uniffi-0.32`, confirmed by a -baseline run. Neither is caused by the fix, and `bytes_types`, the fixture -that covers it, passes. - -Cover it with a test that would have caught it: a loop over an accessor -returning a `String`, asserting resident memory does not grow. - -This gates the first pub.dev upload, which is manual anyway (the package names -have to be claimed and trusted publishing configured), so there is a natural -place to hold it. - -## Required - -- A `kixelated/uniffi-dart` tag on `uniffi-0.32` containing the merged fix diff --git a/quest/m1/dart-publish.md b/quest/m1/dart-publish.md index cd6ab88532..f51a9d4f83 100644 --- a/quest/m1/dart-publish.md +++ b/quest/m1/dart-publish.md @@ -39,12 +39,11 @@ hand-edited before tagging. Verify the published `moq_ffi` resolves its native asset from a clean machine with no monorepo checkout, since that download path is the one CI never exercises. -Both blockers below are about not publishing a claim we cannot support: the +The blocker below is about not publishing a claim we cannot support: the first release is the one that reaches strangers, and pub.dev packages generally cannot be unpublished or deleted. A version may be retracted within seven days, but retraction does not erase it. ## Required -- [Dart binding memory leaks](/quest/m1/dart-leak.md) - publishing a leaking runtime is worse than not publishing - [Dart on iOS](/quest/m1/dart-ios.md) - the package advertises iOS, which nobody has run From 41e1b5da1112ddaa4f1759e3af24a930271afd3b Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 24 Sep 2026 17:05:06 -0700 Subject: [PATCH 02/31] fix(ci): package moq-cli from the flake package named after its binary (#4076) Co-authored-by: Claude Opus 5.5 --- .github/scripts/package-binary.test.sh | 28 ++++++++++++++++++++++++++ rs/scripts/package-binary.sh | 11 +++++----- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/.github/scripts/package-binary.test.sh b/.github/scripts/package-binary.test.sh index 2b2554b6c1..b1390552f1 100755 --- a/.github/scripts/package-binary.test.sh +++ b/.github/scripts/package-binary.test.sh @@ -42,3 +42,31 @@ cmp "$binary" "$bare" cmp "$binary" "$tmp/extracted/$name/bin/moq-relay" echo "release assets package together without path collisions" + +# Without --binary the script builds the flake package named after the binary: +# `.#moq` for the moq-cli crate, since `.#moq-cli` is a stub refusing the old name. +cat >"$tmp/bin/nix" <<'NIX' +#!/usr/bin/env bash +set -euo pipefail +[[ "$1 $3" == "build --out-link" && "$2" == *"#moq" ]] || { + echo "unexpected: nix $*" >&2 + exit 1 +} +mkdir -p "$4/bin" +printf '#!/usr/bin/env sh\necho moq\n' >"$4/bin/moq" +chmod 0755 "$4/bin/moq" +NIX +chmod 0755 "$tmp/bin/nix" + +PATH="$tmp/bin:$PATH" "$WORKSPACE_DIR/rs/scripts/package-binary.sh" \ + --crate moq-cli \ + --bin moq \ + --version 0.12.2 \ + --target "$target" \ + --output "$tmp/dist" + +name="moq-cli-v0.12.2-$target" +tar -xzf "$tmp/dist/$name.tar.gz" -C "$tmp/extracted" +[[ "$("$tmp/extracted/$name/bin/moq")" == moq ]] + +echo "a nix build packages the flake output named after the binary" diff --git a/rs/scripts/package-binary.sh b/rs/scripts/package-binary.sh index 114b9e269f..0984857a20 100755 --- a/rs/scripts/package-binary.sh +++ b/rs/scripts/package-binary.sh @@ -8,7 +8,7 @@ set -euo pipefail # --bin overrides the binary/command name when it differs from the crate (e.g. # the `moq-cli` crate ships its binary as `moq`); it defaults to the crate name. # -# Builds via `nix build .#` against the flake-pinned toolchain unless +# Builds via `nix build .#` against the flake-pinned toolchain unless # --binary supplies an existing build. Produces # /-v-.tar.gz, named after the release tag so # a URL rewritten from one tag to the next still resolves; the layout matches @@ -97,12 +97,13 @@ if [[ -n "$BINARY" ]]; then BIN_FILE="$BINARY" echo "Packaging prebuilt $CRATE binary for $TARGET..." else - echo "Building $CRATE for $TARGET via nix (output: $CRATE)..." + # The flake names each package after its executable, so `moq-cli` builds as + # `.#moq`; `.#moq-cli` is a stub that refuses the old name. + echo "Building $CRATE for $TARGET via nix (output: $BIN)..." RESULT_LINK="$BUILD_TMP/result" - nix build "$WORKSPACE_DIR#$CRATE" --out-link "$RESULT_LINK" + nix build "$WORKSPACE_DIR#$BIN" --out-link "$RESULT_LINK" - # Crane installs to result/bin/. The binary name is usually the - # crate name; the `moq-cli` crate ships as `moq`. + # Crane installs to result/bin/. BIN_FILE="$RESULT_LINK/bin/$BIN" fi From 374c4eff6d906562adf174a148aae9f0345bb671 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 24 Sep 2026 17:05:08 -0700 Subject: [PATCH 03/31] feat(gateway): expose the loop and handler the gateway binaries run (#3964) Co-authored-by: Claude Opus 5.5 --- doc/bin/rtmp.md | 6 +- quest/m1/README.md | 1 - quest/m1/gateway-embed.md | 32 -- rs/justfile | 1 + rs/moq-hls/src/export/master.rs | 224 ++++++++------ rs/moq-hls/src/export/mod.rs | 51 ++-- rs/moq-hls/src/export/rendition.rs | 25 +- rs/moq-hls/src/lib.rs | 3 +- rs/moq-hls/src/server/mod.rs | 8 + rs/moq-hls/src/server/routes.rs | 474 ++++++++++++++++++----------- rs/moq-rtc/Cargo.toml | 9 +- rs/moq-rtc/src/lib.rs | 9 +- rs/moq-rtc/src/sdp.rs | 5 +- rs/moq-rtc/src/server/mod.rs | 19 +- rs/moq-rtc/src/server/whep.rs | 10 +- rs/moq-rtc/src/server/whip.rs | 10 +- rs/moq-rtmp/README.md | 19 +- rs/moq-rtmp/src/lib.rs | 9 +- rs/moq-rtmp/src/listen.rs | 38 ++- rs/moq-rtmp/src/server.rs | 52 +++- 20 files changed, 618 insertions(+), 387 deletions(-) delete mode 100644 quest/m1/gateway-embed.md diff --git a/doc/bin/rtmp.md b/doc/bin/rtmp.md index da3b3e3e8d..dee1c0f39d 100644 --- a/doc/bin/rtmp.md +++ b/doc/bin/rtmp.md @@ -30,6 +30,6 @@ publish or play request to accept, map to a path, or reject. The CLI listener is unauthenticated; firewall it. Implemented in pure Rust (no librtmp). The CLI speaks plaintext `rtmp://` -only; the library adds RTMPS when the embedder supplies a TLS config. FLAC and -MP3 enhanced-audio payloads are dropped because hang has no catalog codec for -them. +only; the library adds RTMPS on the same port when the embedder supplies a TLS +config. FLAC and MP3 enhanced-audio payloads are dropped because hang has no +catalog codec for them. diff --git a/quest/m1/README.md b/quest/m1/README.md index 077be24662..a65d7dd2be 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -32,7 +32,6 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [IETF subscriptions end cleanly](/quest/m1/ietf-publish-done.md) - a finished moq-transport track ends cleanly for its subscriber instead of reading PUBLISH_DONE as an error - [Data sections](/quest/m1/data-sections.md) - an application lists JSON and binary tracks in its own catalog section with its own per-track fields, published in one moq-mux call; data entries gain `bitrate` and `jitter` - [Broadcast close](/quest/m1/broadcast-close/README.md) - `close()` is the one way to end a broadcast in every language, a permanent retraction that leaves in-flight tracks alone -- [Gateway embedding](/quest/m1/gateway-embed.md) - moq-hls, moq-rtmp, and moq-rtc expose the loop their binaries run to an in-process embedder - [Relay peer set](/quest/m1/relay-peer-set.md) - a wire consumer tells a client hop from a peer hop, and every mesh credential can mark a peer - [Publisher clocks](/quest/m1/publisher-clock.md) - wire the shared clock through native and browser publisher restarts - [CLI inspection](/quest/m1/cli-inspect/README.md) - `moq ls` lists what is live and `moq fetch` reads a group over MoQ, and a guide shows how to inspect a relay diff --git a/quest/m1/gateway-embed.md b/quest/m1/gateway-embed.md deleted file mode 100644 index fac824f4d9..0000000000 --- a/quest/m1/gateway-embed.md +++ /dev/null @@ -1,32 +0,0 @@ -# [M] The gateway crates expose the loop their binaries run - -## Goal - -An in-process embedder of `moq-hls`, `moq-rtmp`, or `moq-rtc` calls the -crate's accept loop and request handler with its own origin handle instead -of copying them. moq.pro's `rs/edge/src/{hls.rs, rtmp/mod.rs}` carry the -copies: the HLS route parser, bearer extraction, broadcaster pool, and the -playable/init/media-playlist dance; the RTMP accept loop with TLS sniffing -and its `ActivePaths` dedup. - -## Plan - -Additive, so on main: - -- `moq_hls::Server::new(config)` with `router(origin)` for the convenience - path and `broadcaster(&self, source: moq_mux::Source)` keyed by the - caller's per-scope consumer; `server::Route` and `parse_route` public; - `Broadcaster::{closed, is_closed}` public and ungated; - `Rendition::playlist(query)` folding the three-step playable/init/media - dance; `master::{VideoVariant, AudioVariant, render}` public with a `uri` - per variant (the recorder forked 160 lines of `export/master.rs` for a - different layout). The layout hook keeps the versioned media URLs, - `init.{hash}.mp4` and `seg/{generation}.{segment}.m4s`, for the DASH - renderer (`Broadcaster::manifest`, `export/mpd.rs`) as well. -- `moq_rtmp::listen::Config.tls: Option>` meaning sniff - and serve both on one port, and `ActivePaths` public or folded into - `Publish::accept`. -- `moq-rtc` gains a `server` feature (default on) gating axum, the routers, - and `pub use axum`; moq.pro's `default-features = false` is a no-op today. - -Public API: additive. Wire: none. diff --git a/rs/justfile b/rs/justfile index fc1cba5482..f926e4c3ea 100644 --- a/rs/justfile +++ b/rs/justfile @@ -784,6 +784,7 @@ features: cargo check --locked -p moq-cli --no-default-features cargo check --locked -p moq-bench --no-default-features cargo check --locked -p moq-boy --no-default-features + cargo check --locked -p moq-hls -p moq-rtmp -p moq-rtc --no-default-features --all-targets cargo clippy --locked --workspace {{ no_fuzz }} --all-targets --all-features -- -D warnings RUSTDOCFLAGS="-D warnings" cargo doc --locked --workspace {{ no_fuzz }} --all-features --no-deps cargo nextest run --locked --workspace {{ no_fuzz }} --all-targets --all-features diff --git a/rs/moq-hls/src/export/master.rs b/rs/moq-hls/src/export/master.rs index 42c2492f07..42845da5f4 100644 --- a/rs/moq-hls/src/export/master.rs +++ b/rs/moq-hls/src/export/master.rs @@ -1,7 +1,9 @@ //! Hand-written HLS multivariant (master) playlist generation. //! -//! URIs are relative to the master playlist (`//master.m3u8`), so a -//! rendition's `//media.m3u8` resolves under the broadcast directory. +//! Each variant carries its own URI, so a caller with a different layout (a VOD +//! recorder writing `/media.m3u8`, say) reuses the grouping and attribute +//! rules. [`Broadcaster::master_playlist`](super::Broadcaster::master_playlist) +//! renders the live layout, relative to `//master.m3u8`. use std::collections::BTreeMap; use std::fmt::Write; @@ -16,19 +18,28 @@ const AUDIO_GROUP: &str = "aud"; /// RFC 3986 unreserved characters, which are safe in one URL path segment. const PATH_SEGMENT: &AsciiSet = &NON_ALPHANUMERIC.remove(b'-').remove(b'.').remove(b'_').remove(b'~'); -fn rendition_uri(kind: Kind, name: &str, suffix: &str) -> String { - format!( - "{}/{}/media.m3u8{suffix}", +/// The live layout's media-playlist URI for a rendition, relative to the master, with an +/// optional query (without the leading `?`) appended. +pub(crate) fn rendition_uri(kind: Kind, name: &str, query: Option<&str>) -> String { + let mut uri = format!( + "{}/{}/media.m3u8", kind.as_str(), utf8_percent_encode(name, PATH_SEGMENT) - ) + ); + if let Some(query) = query { + let _ = write!(uri, "?{query}"); + } + uri } fn quoted_string(value: &str) -> String { let mut quoted = String::with_capacity(value.len()); for character in value.chars() { - if character == '"' || character.is_ascii_control() { - let _ = write!(quoted, "%{:02X}", character as u32); + // `is_control` spans C0, DEL, and C1 (U+0080..=U+009F), all forbidden in a playlist. + if character == '"' || character.is_control() { + for byte in character.encode_utf8(&mut [0; 4]).bytes() { + let _ = write!(quoted, "%{byte:02X}"); + } } else { quoted.push(character); } @@ -36,10 +47,20 @@ fn quoted_string(value: &str) -> String { quoted } +/// A URI on its own playlist line, where a leading `#` would read as a tag or comment. +fn uri_line(uri: &str) -> String { + let quoted = quoted_string(uri); + match quoted.strip_prefix('#') { + Some(rest) => format!("%23{rest}"), + None => quoted, + } +} + /// A video rendition entry for the master playlist. +#[derive(Clone, Debug)] pub struct VideoVariant { - /// Rendition name (the `` in its `//media.m3u8` path). - pub name: String, + /// Media-playlist URI, relative to the master or absolute, including any query. + pub uri: String, /// `BANDWIDTH` attribute, in bits per second. pub bandwidth: u64, /// Coded width for the `RESOLUTION` attribute, if known. @@ -51,9 +72,12 @@ pub struct VideoVariant { } /// An audio rendition entry for the master playlist. +#[derive(Clone, Debug)] pub struct AudioVariant { - /// Rendition name (the `` in its `//media.m3u8` path). + /// Rendition name, rendered as the `NAME` attribute. pub name: String, + /// Media-playlist URI, relative to the master or absolute, including any query. + pub uri: String, /// `BANDWIDTH` attribute, in bits per second. pub bandwidth: u64, /// RFC 6381 codec string (e.g. `mp4a.40.2`). @@ -94,7 +118,7 @@ fn group_audio(audio: &[AudioVariant]) -> Vec> { .collect() } -fn render_video(out: &mut String, variant: &VideoVariant, audio: Option<&AudioGroup<'_>>, suffix: &str) { +fn render_video(out: &mut String, variant: &VideoVariant, audio: Option<&AudioGroup<'_>>) { let bandwidth = variant .bandwidth .saturating_add(audio.map_or(0, |group| group.bandwidth)); @@ -106,22 +130,19 @@ fn render_video(out: &mut String, variant: &VideoVariant, audio: Option<&AudioGr if let (Some(width), Some(height)) = (variant.width, variant.height) { let _ = write!(line, ",RESOLUTION={width}x{height}"); } - let _ = write!(line, ",CODECS=\"{codecs}\""); + let _ = write!(line, ",CODECS=\"{}\"", quoted_string(&codecs)); if let Some(group) = audio { let _ = write!(line, ",AUDIO=\"{}\"", group.id); } let _ = writeln!(out, "{line}"); - let _ = writeln!(out, "{}", rendition_uri(Kind::Video, &variant.name, suffix)); + let _ = writeln!(out, "{}", uri_line(&variant.uri)); } /// Render the multivariant playlist. The first rendition in each audio codec group is default. /// -/// `query` is an optional query string (without the leading `?`, e.g. `jwt=`) -/// appended to every child media-playlist URL, so a credential the master was fetched -/// with propagates to the rendition playlists a stock player loads next. -pub fn render_master(video: &[VideoVariant], audio: &[AudioVariant], query: Option<&str>) -> String { - let suffix = query.map(|q| format!("?{q}")).unwrap_or_default(); - +/// A `"` or control character in a name, codec, or URI is percent-encoded, as is a leading +/// `#` on a variant's URI line, so none can break out of its attribute or line. +pub fn render(video: &[VideoVariant], audio: &[AudioVariant]) -> String { let mut out = String::new(); let _ = writeln!(out, "#EXTM3U"); let _ = writeln!(out, "#EXT-X-VERSION:{VERSION}"); @@ -136,17 +157,17 @@ pub fn render_master(video: &[VideoVariant], audio: &[AudioVariant], query: Opti "#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"{}\",NAME=\"{}\",DEFAULT={default},AUTOSELECT=YES,URI=\"{}\"", group.id, name, - rendition_uri(Kind::Audio, &variant.name, &suffix) + quoted_string(&variant.uri) ); } } for variant in video { if audio_groups.is_empty() { - render_video(&mut out, variant, None, &suffix); + render_video(&mut out, variant, None); } else { for group in &audio_groups { - render_video(&mut out, variant, Some(group), &suffix); + render_video(&mut out, variant, Some(group)); } } } @@ -157,9 +178,10 @@ pub fn render_master(video: &[VideoVariant], audio: &[AudioVariant], query: Opti let _ = writeln!( out, "#EXT-X-STREAM-INF:BANDWIDTH={},CODECS=\"{}\"", - variant.bandwidth, variant.codec + variant.bandwidth, + quoted_string(&variant.codec) ); - let _ = writeln!(out, "{}", rendition_uri(Kind::Audio, &variant.name, &suffix)); + let _ = writeln!(out, "{}", uri_line(&variant.uri)); } } @@ -170,22 +192,31 @@ pub fn render_master(video: &[VideoVariant], audio: &[AudioVariant], query: Opti mod tests { use super::*; - #[test] - fn renders_video_and_audio() { - let video = vec![VideoVariant { - name: "video".into(), + fn video(name: &str, width: Option, height: Option) -> VideoVariant { + VideoVariant { + uri: rendition_uri(Kind::Video, name, None), bandwidth: 2_500_000, - width: Some(1280), - height: Some(720), + width, + height, codec: "avc1.42c01f".into(), - }]; - let audio = vec![AudioVariant { - name: "audio".into(), - bandwidth: 128_000, - codec: "mp4a.40.2".into(), - }]; - - let out = render_master(&video, &audio, None); + } + } + + fn audio(name: &str, bandwidth: u64, codec: &str) -> AudioVariant { + AudioVariant { + name: name.into(), + uri: rendition_uri(Kind::Audio, name, None), + bandwidth, + codec: codec.into(), + } + } + + #[test] + fn renders_video_and_audio() { + let out = render( + &[video("video", Some(1280), Some(720))], + &[audio("audio", 128_000, "mp4a.40.2")], + ); assert!(out.starts_with("#EXTM3U\n#EXT-X-VERSION:9\n")); assert!(out.contains( "#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"aud\",NAME=\"audio\",DEFAULT=YES,AUTOSELECT=YES,URI=\"audio/audio/media.m3u8\"\n" @@ -194,41 +225,30 @@ mod tests { "#EXT-X-STREAM-INF:BANDWIDTH=2628000,RESOLUTION=1280x720,CODECS=\"avc1.42c01f,mp4a.40.2\",AUDIO=\"aud\"\n" )); assert!(out.contains("\nvideo/video/media.m3u8\n")); + } - // A credential rides every child media-playlist URL, audio and video alike. - let signed = render_master(&video, &audio, Some("jwt=abc.def")); - assert!(signed.contains("URI=\"audio/audio/media.m3u8?jwt=abc.def\"\n")); - assert!(signed.contains("\nvideo/video/media.m3u8?jwt=abc.def\n")); + #[test] + fn renders_each_variant_at_its_own_uri() { + let mut hd = video("hd", None, None); + hd.uri = "hd/media.m3u8".into(); + let mut main = audio("main", 128_000, "opus"); + main.uri = "https://cdn.example/main/media.m3u8?jwt=abc".into(); + + let out = render(&[hd], &[main]); + assert!(out.contains("\nhd/media.m3u8\n")); + assert!(out.contains("URI=\"https://cdn.example/main/media.m3u8?jwt=abc\"")); } #[test] fn separates_audio_codecs_into_accurate_variants() { - let video = vec![VideoVariant { - name: "video".into(), - bandwidth: 2_500_000, - width: Some(1280), - height: Some(720), - codec: "avc1.42c01f".into(), - }]; - let audio = vec![ - AudioVariant { - name: "aac-low".into(), - bandwidth: 96_000, - codec: "mp4a.40.2".into(), - }, - AudioVariant { - name: "aac-high".into(), - bandwidth: 128_000, - codec: "mp4a.40.2".into(), - }, - AudioVariant { - name: "opus".into(), - bandwidth: 160_000, - codec: "opus".into(), - }, - ]; - - let out = render_master(&video, &audio, None); + let out = render( + &[video("video", Some(1280), Some(720))], + &[ + audio("aac-low", 96_000, "mp4a.40.2"), + audio("aac-high", 128_000, "mp4a.40.2"), + audio("opus", 160_000, "opus"), + ], + ); assert!(out.contains("GROUP-ID=\"aud-0\",NAME=\"aac-low\",DEFAULT=YES")); assert!(out.contains("GROUP-ID=\"aud-0\",NAME=\"aac-high\",DEFAULT=NO")); assert!(out.contains("GROUP-ID=\"aud-1\",NAME=\"opus\",DEFAULT=YES")); @@ -239,48 +259,52 @@ mod tests { #[test] fn audio_only_is_playable() { - let audio = vec![AudioVariant { - name: "audio".into(), - bandwidth: 128_000, - codec: "opus".into(), - }]; - let out = render_master(&[], &audio, None); + let out = render(&[], &[audio("audio", 128_000, "opus")]); assert!(out.contains("#EXT-X-STREAM-INF:BANDWIDTH=128000,CODECS=\"opus\"\n")); assert!(out.contains("\naudio/audio/media.m3u8\n")); } #[test] fn rendition_names_are_percent_encoded_in_uris() { - let video = vec![VideoVariant { - name: "cam#1/main?alt".into(), - bandwidth: 2_500_000, - width: None, - height: None, - codec: "avc1.42c01f".into(), - }]; - let audio = vec![AudioVariant { - name: "audio #1".into(), - bandwidth: 128_000, - codec: "opus".into(), - }]; - - let out = render_master(&video, &audio, Some("jwt=abc.def")); - - assert!(out.contains("\nvideo/cam%231%2Fmain%3Falt/media.m3u8?jwt=abc.def\n")); - assert!(out.contains("URI=\"audio/audio%20%231/media.m3u8?jwt=abc.def\"")); + assert_eq!( + rendition_uri(Kind::Video, "cam#1/main?alt", Some("jwt=abc.def")), + "video/cam%231%2Fmain%3Falt/media.m3u8?jwt=abc.def" + ); + assert_eq!( + rendition_uri(Kind::Audio, "audio #1", None), + "audio/audio%20%231/media.m3u8" + ); } #[test] - fn audio_names_preserve_unicode_without_injection() { - let audio = vec![AudioVariant { - name: "音声\"\nINJECT\u{7f}\u{1f3b5}".into(), - bandwidth: 128_000, - codec: "opus".into(), - }]; + fn names_and_uris_cannot_inject() { + let mut variant = audio("音声\"\nINJECT\u{7f}\u{85}\u{1f3b5}", 128_000, "opus"); + variant.uri = "a\"\n#EXT-X-INJECT".into(); - let out = render_master(&[], &audio, None); + let out = render(&[], &[variant]); - assert!(out.contains("NAME=\"音声%22%0AINJECT%7F🎵\"")); + assert!(out.contains("NAME=\"音声%22%0AINJECT%7F%C2%85🎵\""), "{out}"); + assert!(out.contains("URI=\"a%22%0A#EXT-X-INJECT\"")); assert!(!out.contains("\nINJECT")); + assert!(!out.contains("\n#EXT-X-INJECT")); + } + + #[test] + fn codecs_and_uri_lines_cannot_inject() { + let mut hd = video("hd", None, None); + hd.codec = "avc1\"\n#EXT-X-ENDLIST".into(); + hd.uri = "#EXT-X-ENDLIST".into(); + let out = render(&[hd], &[]); + assert!( + out.contains("CODECS=\"avc1%22%0A#EXT-X-ENDLIST\"\n%23EXT-X-ENDLIST\n"), + "{out}" + ); + assert!(!out.contains("\n#EXT-X-ENDLIST"), "{out}"); + + let mut main = audio("main", 128_000, "opus\"\n#EXT-X-ENDLIST"); + main.uri = "#frag".into(); + let out = render(&[], &[main]); + assert!(out.contains("CODECS=\"opus%22%0A#EXT-X-ENDLIST\"\n%23frag\n"), "{out}"); + assert!(!out.contains("\n#EXT-X-ENDLIST"), "{out}"); } } diff --git a/rs/moq-hls/src/export/mod.rs b/rs/moq-hls/src/export/mod.rs index e1eb84c9e0..4b5770a10e 100644 --- a/rs/moq-hls/src/export/mod.rs +++ b/rs/moq-hls/src/export/mod.rs @@ -11,14 +11,14 @@ //! The same machinery serves two kinds of consumer: //! //! * the HTTP serve path (pull): [`Broadcaster::rendition`] / -//! [`Broadcaster::master_playlist`] / [`Broadcaster::manifest`] and the crate-internal -//! `Rendition::playlist` / `Rendition::segment`, rendered/fetched per request (that pull -//! surface is gated behind the `server` feature); and +//! [`Broadcaster::master_playlist`] / [`Broadcaster::manifest`] and +//! [`Rendition::playlist`] / [`Rendition::init`] / [`Rendition::segment`], rendered or +//! fetched per request, with or without the `server` feature's router; and //! * a recorder (push): the [`renditions::Consumer`] and [`segments::Consumer`] cursors, which //! yield every rendition and every finalized segment in order, for mirroring a broadcast to //! storage. -mod master; +pub mod master; mod mpd; mod playlist; mod rendition; @@ -114,14 +114,12 @@ impl Broadcaster { } /// Whether the source broadcast has closed (ended or dropped). - #[cfg(feature = "server")] - pub(crate) fn is_closed(&self) -> bool { + pub fn is_closed(&self) -> bool { self.broadcast.is_closed() } - /// Resolve once the source broadcast closes, so the server can evict a dead broadcaster. - #[cfg(feature = "server")] - pub(crate) async fn closed(&self) { + /// Resolve once the source broadcast closes, so a pool can evict a dead broadcaster. + pub async fn closed(&self) { self.broadcast.closed().await; } @@ -184,7 +182,7 @@ impl Broadcaster { } match rendition.kind { Kind::Video => video.push(master::VideoVariant { - name: rendition.name.clone(), + uri: master::rendition_uri(Kind::Video, &rendition.name, query), bandwidth: rendition.bandwidth(), width: rendition.width, height: rendition.height, @@ -192,12 +190,13 @@ impl Broadcaster { }), Kind::Audio => audio.push(master::AudioVariant { name: rendition.name.clone(), + uri: master::rendition_uri(Kind::Audio, &rendition.name, query), bandwidth: rendition.bandwidth(), codec: rendition.codec.clone(), }), } } - master::render_master(&video, &audio, query) + master::render(&video, &audio) } /// Render the DASH manifest (MPD) from the current renditions and their views of the @@ -273,7 +272,7 @@ impl Broadcaster { /// already-ended timeline). Every rendition's window is fed from the same timeline, so the /// first rendition's readiness stands in for the broadcast's. Bounding the wait is the /// caller's policy. - #[cfg(feature = "server")] + #[cfg_attr(not(feature = "server"), allow(dead_code))] pub(crate) async fn playable(&self) { if let Some(rendition) = self.renditions.snapshot().into_iter().next() { rendition.playable().await; @@ -1086,7 +1085,7 @@ mod tests { let master = broadcaster.master_playlist(None); assert!(master.contains("video/video0/media.m3u8"), "master lists the rendition"); - let playlist = rendition.playlist(); + let playlist = rendition.snapshot(); assert_eq!(playlist.segments.len(), 2, "the live-edge group is not listed"); assert_eq!(playlist.segments[0].segment, 0); assert_eq!(playlist.segments[0].duration, Duration::from_secs(2)); @@ -1296,7 +1295,7 @@ mod tests { let _ = tokio::time::timeout(Duration::from_secs(5), rendition.playable()).await; // The timeline section carries no wall field anymore; the root clock names the epoch. - let snapshot = rendition.playlist(); + let snapshot = rendition.snapshot(); assert_eq!( snapshot.program_date_time, Some(SystemTime::UNIX_EPOCH + Duration::from_millis(hang::catalog::MOQ_EPOCH_UNIX_MILLIS)) @@ -1352,7 +1351,7 @@ mod tests { let rendition = broadcaster.rendition(Kind::Video, "video0").expect("video discovered"); let _ = tokio::time::timeout(Duration::from_secs(5), rendition.playable()).await; - assert_eq!(rendition.playlist().program_date_time, None); + assert_eq!(rendition.snapshot().program_date_time, None); let playlist = rendition.media_playlist(None).expect("playlist renders"); assert!(!playlist.contains("PROGRAM-DATE-TIME"), "{playlist}"); @@ -1462,7 +1461,7 @@ mod tests { let rendition = broadcaster.rendition(Kind::Video, "video0").expect("rendition"); let _ = tokio::time::timeout(Duration::from_secs(5), rendition.playable()).await; - let playlist = rendition.playlist(); + let playlist = rendition.snapshot(); assert_eq!(playlist.segments[0].duration, Duration::from_secs(3)); assert_eq!( playlist.target_duration, 3, @@ -1528,8 +1527,8 @@ mod tests { let _ = tokio::time::timeout(Duration::from_secs(5), audio_rendition.playable()).await; // Both playlists list the same segment numbers over the same spans. - let video_playlist = video_rendition.playlist(); - let audio_playlist = audio_rendition.playlist(); + let video_playlist = video_rendition.snapshot(); + let audio_playlist = audio_rendition.snapshot(); assert_eq!(video_playlist.media_sequence, audio_playlist.media_sequence); let video_segments: Vec = video_playlist.segments.iter().map(|s| s.segment).collect(); let audio_segments: Vec = audio_playlist.segments.iter().map(|s| s.segment).collect(); @@ -1905,7 +1904,7 @@ mod tests { async fn until_empty(rendition: &Rendition) { let deadline = tokio::time::Instant::now() + Duration::from_secs(5); loop { - if rendition.playlist().segments.is_empty() { + if rendition.snapshot().segments.is_empty() { return; } assert!( @@ -1955,7 +1954,7 @@ mod tests { .unwrap() .expect("the original sibling is servable"); assert!(contains(&served, OLD), "the first hop serves the original publisher"); - assert!(!rendition.playlist().segments.is_empty()); + assert!(!rendition.snapshot().segments.is_empty()); // The replacement is already announced before the incumbent is dropped, matching a // rival publisher that appears while the current first hop is still serving. @@ -1979,7 +1978,7 @@ mod tests { let _new_track = write_routed_media(&mut new_media, NEW, recorder, 6_000_000); let deadline = tokio::time::Instant::now() + Duration::from_secs(5); loop { - if !rendition.playlist().segments.is_empty() { + if !rendition.snapshot().segments.is_empty() { break; } assert!( @@ -1989,7 +1988,7 @@ mod tests { tokio::task::yield_now().await; } let listed = rendition - .playlist() + .snapshot() .segments .into_iter() .find(|segment| !segment.gap) @@ -2090,13 +2089,13 @@ mod tests { drop((old_server, old_media, _old_track)); until_empty(&rendition).await; for _ in 0..4 { - assert!(rendition.playlist().segments.is_empty()); + assert!(rendition.snapshot().segments.is_empty()); assert!(rendition.segment(0).await.unwrap().is_none()); } let new_server = origin.dynamic("media", sibling_route(11)).unwrap(); let mut new_media = moq_net::broadcast::Info::new().produce(); - let _ = rendition.playlist(); + let _ = rendition.snapshot(); accept_sibling(&new_server, &new_media).await; tokio::time::timeout(Duration::from_secs(5), origin.consume().request_broadcast("media")) .await @@ -2106,7 +2105,7 @@ mod tests { let _new_track = write_routed_media(&mut new_media, NEW, recorder, 6_000_000); let deadline = tokio::time::Instant::now() + Duration::from_secs(5); loop { - if !rendition.playlist().segments.is_empty() { + if !rendition.snapshot().segments.is_empty() { break; } assert!( @@ -2116,7 +2115,7 @@ mod tests { tokio::task::yield_now().await; } let listed = rendition - .playlist() + .snapshot() .segments .into_iter() .find(|segment| !segment.gap) diff --git a/rs/moq-hls/src/export/rendition.rs b/rs/moq-hls/src/export/rendition.rs index 53132a9ef3..f1f1a03691 100644 --- a/rs/moq-hls/src/export/rendition.rs +++ b/rs/moq-hls/src/export/rendition.rs @@ -394,6 +394,7 @@ impl Rendition { } /// The generation every segment URL currently carries. + #[cfg_attr(not(feature = "server"), allow(dead_code))] pub(crate) fn generation(&self) -> Option> { self.run().generation } @@ -450,17 +451,33 @@ impl Rendition { let run = self.run(); let init = self.built_init(&run)?; self.is_playable() - .then(|| super::render_media(&self.snapshot(run.generation), &init.hash, query)) + .then(|| super::render_media(&self.snapshot_as(run.generation), &init.hash, query)) + } + + /// Wait until the media playlist is servable, then render it: at least one segment is + /// listed and the `EXT-X-MAP` init segment it names is built, so a player never loads a + /// map that 404s. `None` when the init cannot be built yet. + /// + /// Waits without bound for the first segment; bounding it is the caller's policy. `query` + /// propagates exactly as in [`media_playlist`](Self::media_playlist). + pub async fn playlist(&self, query: Option<&str>) -> Result> { + self.playable().await; + // An inline-codec init needs a keyframe group fetched first. init() caches, so the + // player's follow-up GET of the init is free. + if self.init().await?.is_none() { + return Ok(None); + } + Ok(self.media_playlist(query)) } /// Snapshot the media playlist from the current timeline window. #[cfg(test)] - pub(crate) fn playlist(&self) -> Snapshot { - self.snapshot(self.generation()) + pub(crate) fn snapshot(&self) -> Snapshot { + self.snapshot_as(self.generation()) } /// Snapshot the media playlist from the current timeline window, labeled `generation`. - fn snapshot(&self, generation: Option>) -> Snapshot { + fn snapshot_as(&self, generation: Option>) -> Snapshot { self.media.sync(&self.live); let window = self.live.window(); diff --git a/rs/moq-hls/src/lib.rs b/rs/moq-hls/src/lib.rs index ae42fa0894..cb79fff34f 100644 --- a/rs/moq-hls/src/lib.rs +++ b/rs/moq-hls/src/lib.rs @@ -10,7 +10,8 @@ //! broadcast's catalog and timeline tracks; media bytes are FETCHed from //! the relay one group at a time, only when a segment is actually requested. //! It serves every request; gate access by layering your own middleware onto -//! [`Server::router`](server::Server::router). +//! [`Server::router`](server::Server::router), or parse and authorize each request +//! yourself and answer it with [`Server::respond`](server::Server::respond). //! //! All CMAF byte handling (import via [`moq_mux::container::fmp4::Import`], //! export via [`moq_mux::container::fmp4::Muxer`]) lives in `moq-mux`; this diff --git a/rs/moq-hls/src/server/mod.rs b/rs/moq-hls/src/server/mod.rs index d19a623011..2686362d40 100644 --- a/rs/moq-hls/src/server/mod.rs +++ b/rs/moq-hls/src/server/mod.rs @@ -28,6 +28,12 @@ //! `project/live`. Decode each segment before matching it against a policy, or a //! name can be encoded past the check. //! +//! An embedder that authorizes each request itself (a token scoping the broadcast, a +//! stats-metered origin per tenant) skips the router instead: parse the path with +//! [`Route::parse`], check [`Route::broadcast`], rewrite it relative to the scope, and +//! answer with [`Server::respond`] on a `Server` built from that scope's origin. The +//! decoded broadcast is what a policy should check. +//! //! ```no_run //! use axum::http::StatusCode; //! use axum::middleware::{self, Next}; @@ -48,6 +54,8 @@ mod routes; +pub use routes::{Resource, Route}; + use std::collections::HashMap; use std::sync::{Arc, Mutex}; use std::time::Duration; diff --git a/rs/moq-hls/src/server/routes.rs b/rs/moq-hls/src/server/routes.rs index b802a51422..a01195b488 100644 --- a/rs/moq-hls/src/server/routes.rs +++ b/rs/moq-hls/src/server/routes.rs @@ -26,34 +26,146 @@ pub fn router(server: Server) -> Router { Router::new().route("/{*path}", get(request)).with_state(server) } -enum Route { - Master { - broadcast: String, - }, - Manifest { - broadcast: String, - }, +/// A parsed request path: the broadcast, and the resource under it. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub struct Route { + /// The broadcast path, percent-decoded. An embedder that scopes its origin rewrites + /// this relative to that scope before calling [`Server::respond`]. + pub broadcast: String, + /// The resource requested under the broadcast. + pub resource: Resource, +} + +/// A resource under a broadcast. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum Resource { + /// `master.m3u8`: the HLS multivariant playlist. + Master, + /// `manifest.mpd`: the DASH manifest. + Manifest, + /// `{kind}/{rendition}/media.m3u8`: a rendition's HLS media playlist. + #[non_exhaustive] Media { - broadcast: String, - kind: String, + /// The rendition's kind. + kind: Kind, + /// The rendition's name, percent-decoded. rendition: String, }, + /// `{kind}/{rendition}/init.{hash}.mp4`: a rendition's CMAF init segment. + #[non_exhaustive] Init { - broadcast: String, - kind: String, + /// The rendition's kind. + kind: Kind, + /// The rendition's name, percent-decoded. rendition: String, + /// The hash of the init bytes the URL names. hash: String, }, + /// `{kind}/{rendition}/seg/[{generation}.]{sequence}.m4s`: a segment by its HLS number. + #[non_exhaustive] Segment { - broadcast: String, - kind: String, + /// The rendition's kind. + kind: Kind, + /// The rendition's name, percent-decoded. + rendition: String, + /// The publisher run the URL names, if the broadcaster carries one. + generation: Option, + /// The segment's aligned number. + sequence: u64, + }, + /// `{kind}/{rendition}/seg/[{generation}.]t{pts}.m4s`: the same bytes, addressed by the + /// DASH timeline pts (`$Time$`). + #[non_exhaustive] + SegmentAt { + /// The rendition's kind. + kind: Kind, + /// The rendition's name, percent-decoded. rendition: String, - file: String, + /// The publisher run the URL names, if the broadcaster carries one. + generation: Option, + /// The segment's timeline pts. + pts: u64, }, } -fn broadcast_path(parts: &[String]) -> Option { - (!parts.is_empty() && parts.iter().all(|part| !part.contains('/'))).then(|| parts.join("/")) +impl Route { + /// Parse a request path such as `/project/live/video/hd/media.m3u8`, or `None` when it + /// addresses nothing. + /// + /// The broadcast occupies every segment before the resource suffix. Each segment is + /// percent-decoded, and one that decodes to a `/` is refused, so an encoded separator + /// cannot smuggle a different broadcast past a policy check on [`broadcast`](Self::broadcast). + pub fn parse(path: &str) -> Option { + let parts = path + .strip_prefix('/')? + .split('/') + .map(|part| { + percent_decode_str(part) + .decode_utf8() + .ok() + .map(|part| part.into_owned()) + }) + .collect::>>()?; + if parts.iter().any(String::is_empty) { + return None; + } + + let (broadcast, resource) = match parts.as_slice() { + [broadcast @ .., file] if file == "master.m3u8" => (broadcast, Resource::Master), + [broadcast @ .., file] if file == "manifest.mpd" => (broadcast, Resource::Manifest), + [broadcast @ .., kind, rendition, file] if file == "media.m3u8" => ( + broadcast, + Resource::Media { + kind: Kind::parse(kind)?, + rendition: rendition.clone(), + }, + ), + [broadcast @ .., kind, rendition, file] if init_hash(file).is_some() => ( + broadcast, + Resource::Init { + kind: Kind::parse(kind)?, + rendition: rendition.clone(), + hash: init_hash(file)?.to_string(), + }, + ), + [broadcast @ .., kind, rendition, directory, file] if directory == "seg" => { + let kind = Kind::parse(kind)?; + let rendition = rendition.clone(); + let stem = file.strip_suffix(".m4s")?; + // `{generation}.{segment}` when the export carries a generation, `{segment}` otherwise. + let (generation, stem) = match stem.split_once('.') { + Some((generation, stem)) => (Some(generation.to_string()), stem), + None => (None, stem), + }; + let resource = match stem.strip_prefix('t') { + Some(pts) => Resource::SegmentAt { + kind, + rendition, + generation, + pts: pts.parse().ok()?, + }, + None => Resource::Segment { + kind, + rendition, + generation, + sequence: stem.parse().ok()?, + }, + }; + (broadcast, resource) + } + _ => return None, + }; + + if broadcast.is_empty() || broadcast.iter().any(|part| part.contains('/')) { + return None; + } + Some(Self { + broadcast: broadcast.join("/"), + resource, + }) + } } /// The content hash in an `init.{hash}.mp4` file name. @@ -63,71 +175,47 @@ fn init_hash(file: &str) -> Option<&str> { .filter(|hash| !hash.is_empty()) } -fn parse_route(path: &str) -> Option { - let parts = path - .strip_prefix('/')? - .split('/') - .map(|part| { - percent_decode_str(part) - .decode_utf8() - .ok() - .map(|part| part.into_owned()) - }) - .collect::>>()?; - if parts.iter().any(String::is_empty) { - return None; - } - - match parts.as_slice() { - [broadcast @ .., file] if file == "master.m3u8" => Some(Route::Master { - broadcast: broadcast_path(broadcast)?, - }), - [broadcast @ .., file] if file == "manifest.mpd" => Some(Route::Manifest { - broadcast: broadcast_path(broadcast)?, - }), - [broadcast @ .., kind, rendition, file] if file == "media.m3u8" => Some(Route::Media { - broadcast: broadcast_path(broadcast)?, - kind: kind.clone(), - rendition: rendition.clone(), - }), - [broadcast @ .., kind, rendition, file] if init_hash(file).is_some() => Some(Route::Init { - broadcast: broadcast_path(broadcast)?, - kind: kind.clone(), - rendition: rendition.clone(), - hash: init_hash(file)?.to_string(), - }), - [broadcast @ .., kind, rendition, directory, file] if directory == "seg" => Some(Route::Segment { - broadcast: broadcast_path(broadcast)?, - kind: kind.clone(), - rendition: rendition.clone(), - file: file.clone(), - }), - _ => None, +async fn request(State(server): State, uri: Uri, RawQuery(query): RawQuery) -> Response { + match Route::parse(uri.path()) { + Some(route) => server.respond(&route, query.as_deref()).await, + None => not_found(), } } -async fn request(State(server): State, uri: Uri, RawQuery(query): RawQuery) -> Response { - match parse_route(uri.path()) { - Some(Route::Master { broadcast }) => master(&server, &broadcast, query.as_deref()).await, - Some(Route::Manifest { broadcast }) => manifest(&server, &broadcast, query.as_deref()).await, - Some(Route::Media { - broadcast, - kind, - rendition, - }) => media(&server, &broadcast, &kind, &rendition, query.as_deref()).await, - Some(Route::Init { - broadcast, - kind, - rendition, - hash, - }) => init(&server, &broadcast, &kind, &rendition, &hash).await, - Some(Route::Segment { - broadcast, - kind, - rendition, - file, - }) => segment(&server, &broadcast, &kind, &rendition, &file).await, - None => not_found(), +impl Server { + /// Answer a parsed [`Route`] from this server's origin: the handler behind + /// [`router`](Self::router), for an embedder that parses and authorizes the request + /// itself. + /// + /// `query` is the raw request query (without the leading `?`), propagated to every + /// child URL a playlist or manifest lists, so a credential carried there reaches the + /// player's follow-up requests. + pub async fn respond(&self, route: &Route, query: Option<&str>) -> Response { + let broadcast = route.broadcast.as_str(); + match &route.resource { + Resource::Master => master(self, broadcast, query).await, + Resource::Manifest => manifest(self, broadcast, query).await, + Resource::Media { kind, rendition } => media(self, broadcast, *kind, rendition, query).await, + Resource::Init { kind, rendition, hash } => init(self, broadcast, *kind, rendition, hash).await, + Resource::Segment { + kind, + rendition, + generation, + sequence, + } => { + let at = SegmentAt::Sequence(*sequence); + segment(self, broadcast, *kind, rendition, generation.as_deref(), at).await + } + Resource::SegmentAt { + kind, + rendition, + generation, + pts, + } => { + let at = SegmentAt::Pts(*pts); + segment(self, broadcast, *kind, rendition, generation.as_deref(), at).await + } + } } } @@ -139,8 +227,6 @@ async fn master(server: &Server, broadcast: &str, query: Option<&str>) -> Respon if broadcaster.is_empty() { return not_found(); } - // Propagate whatever query reached the master (e.g. a credential a wrapping - // middleware required) down to the child media-playlist URLs. m3u8(broadcaster.master_playlist(query)) } @@ -163,50 +249,39 @@ async fn manifest(server: &Server, broadcast: &str, query: Option<&str>) -> Resp } } -async fn media(server: &Server, broadcast: &str, kind: &str, rendition: &str, query: Option<&str>) -> Response { +async fn media(server: &Server, broadcast: &str, kind: Kind, rendition: &str, query: Option<&str>) -> Response { let Some(rendition) = rendition_for(server, broadcast, kind, rendition).await else { return not_found(); }; - - // A playlist with no segments confuses players; give the timeline a moment to index the - // first complete segment before answering. - let _ = tokio::time::timeout(READY_TIMEOUT, rendition.playable()).await; - - // The playlist names its init by a hash of the bytes via EXT-X-MAP, so build it before - // rendering (an inline-codec init needs a keyframe group fetched first). init() caches, so - // the follow-up GET is free. - match rendition.init().await { - Ok(Some(_)) => {} - Ok(None) => return not_found(), - Err(err) => return server_error(err), - } - - match rendition.media_playlist(query) { - Some(playlist) => m3u8(playlist), - None => not_found(), + match tokio::time::timeout(READY_TIMEOUT, rendition.playlist(query)).await { + Ok(Ok(Some(playlist))) => m3u8(playlist), + Ok(Ok(None)) | Err(_) => not_found(), + Ok(Err(err)) => server_error(err), } } -async fn init(server: &Server, broadcast: &str, kind: &str, rendition: &str, hash: &str) -> Response { +async fn init(server: &Server, broadcast: &str, kind: Kind, rendition: &str, hash: &str) -> Response { let Some(rendition) = rendition_for(server, broadcast, kind, rendition).await else { return not_found(); }; - match rendition.init_versioned(hash).await { - Ok(Some(bytes)) => media_bytes(bytes, server), - Ok(None) => not_found(), - Err(err) => server_error(err), - } + media_result(rendition.init_versioned(hash).await, server) } -async fn segment(server: &Server, broadcast: &str, kind: &str, rendition: &str, file: &str) -> Response { - let Some(stem) = file.strip_suffix(".m4s") else { - return not_found(); - }; - // `{generation}.{segment}` when the export carries a generation, `{segment}` otherwise. - let (generation, stem) = match stem.split_once('.') { - Some((generation, stem)) => (Some(generation), stem), - None => (None, stem), - }; +/// How a segment URL addresses its bytes: HLS by aligned number (`seg/0.m4s`), DASH by +/// timeline pts (`seg/t2000.m4s`, the SegmentTemplate's `$Time$`). +enum SegmentAt { + Sequence(u64), + Pts(u64), +} + +async fn segment( + server: &Server, + broadcast: &str, + kind: Kind, + rendition: &str, + generation: Option<&str>, + at: SegmentAt, +) -> Response { let Some(rendition) = rendition_for(server, broadcast, kind, rendition).await else { return not_found(); }; @@ -216,32 +291,19 @@ async fn segment(server: &Server, broadcast: &str, kind: &str, rendition: &str, if !current() { return not_found(); } - // HLS addresses a segment by its aligned number (`seg/0.m4s`); DASH by its timeline pts - // (`seg/t2000.m4s`, the SegmentTemplate's `$Time$`). Same bytes either way. - let result = match stem.strip_prefix('t') { - Some(time) => match time.parse::() { - Ok(time) => rendition.segment_at(time).await, - Err(_) => return not_found(), - }, - None => match stem.parse::() { - Ok(sequence) => rendition.segment(sequence).await, - Err(_) => return not_found(), - }, + let result = match at { + SegmentAt::Sequence(sequence) => rendition.segment(sequence).await, + SegmentAt::Pts(pts) => rendition.segment_at(pts).await, }; // A generation change while fetching may have swapped the rows under the lookup. if !current() { return not_found(); } - match result { - Ok(Some(bytes)) => media_bytes(bytes, server), - Ok(None) => not_found(), - Err(err) => server_error(err), - } + media_result(result, server) } /// Resolve a rendition, waiting for the catalog to populate. -async fn rendition_for(server: &Server, broadcast: &str, kind: &str, rendition: &str) -> Option> { - let kind = Kind::parse(kind)?; +async fn rendition_for(server: &Server, broadcast: &str, kind: Kind, rendition: &str) -> Option> { let broadcaster = server.broadcaster(broadcast).await?; let _ = tokio::time::timeout(READY_TIMEOUT, broadcaster.ready()).await; broadcaster.rendition(kind, rendition) @@ -261,6 +323,14 @@ fn mpd(body: String) -> Response { ([(header::CONTENT_TYPE, MPD), (header::CACHE_CONTROL, "no-cache")], body).into_response() } +fn media_result(result: crate::Result>, server: &Server) -> Response { + match result { + Ok(Some(bytes)) => media_bytes(bytes, server), + Ok(None) => not_found(), + Err(err) => server_error(err), + } +} + fn media_bytes(body: Bytes, server: &Server) -> Response { // Init/segment bytes never change while their URL is listed, but a segment URL without a // generation is not globally unique: a restarted publisher starts a new timeline whose @@ -295,57 +365,85 @@ mod tests { #[test] fn parses_multisegment_broadcast_and_encoded_rendition() { - let Some(Route::Media { - broadcast, - kind, - rendition, - }) = parse_route("/project/live/video/cam%231%2Fmain%3Falt/media.m3u8") - else { - panic!("media route should parse"); - }; - - assert_eq!(broadcast, "project/live"); - assert_eq!(kind, "video"); - assert_eq!(rendition, "cam#1/main?alt"); + assert_eq!( + Route::parse("/project/live/video/cam%231%2Fmain%3Falt/media.m3u8"), + Some(Route { + broadcast: "project/live".to_string(), + resource: Resource::Media { + kind: Kind::Video, + rendition: "cam#1/main?alt".to_string(), + }, + }) + ); } #[test] fn parses_all_resource_routes() { - assert!(matches!( - parse_route("/project/live/master.m3u8"), - Some(Route::Master { .. }) - )); - assert!(matches!( - parse_route("/project/live/manifest.mpd"), - Some(Route::Manifest { .. }) - )); - assert!(matches!( - parse_route("/project/live/video/main/init.0123abcd.mp4"), - Some(Route::Init { hash, .. }) if hash == "0123abcd" - )); - assert!(parse_route("/project/live/video/main/init.mp4").is_none()); - assert!(parse_route("/project/live/video/main/init..mp4").is_none()); - assert!(matches!( - parse_route("/project/live/video/main/seg/42.m4s"), - Some(Route::Segment { .. }) - )); - assert!(matches!( - parse_route("/project/live/video/main/seg/t2000.m4s"), - Some(Route::Segment { .. }) - )); - assert!(parse_route("/master.m3u8").is_none()); - assert!(parse_route("/project/live/video/main/unknown").is_none()); + let resource = |path| Route::parse(path).map(|route| route.resource); + assert_eq!(resource("/project/live/master.m3u8"), Some(Resource::Master)); + assert_eq!(resource("/project/live/manifest.mpd"), Some(Resource::Manifest)); + assert_eq!( + resource("/project/live/audio/main/init.0123abcd.mp4"), + Some(Resource::Init { + kind: Kind::Audio, + rendition: "main".to_string(), + hash: "0123abcd".to_string(), + }) + ); + assert_eq!( + resource("/project/live/video/main/seg/42.m4s"), + Some(Resource::Segment { + kind: Kind::Video, + rendition: "main".to_string(), + generation: None, + sequence: 42, + }) + ); + assert_eq!( + resource("/project/live/video/main/seg/run-1.42.m4s"), + Some(Resource::Segment { + kind: Kind::Video, + rendition: "main".to_string(), + generation: Some("run-1".to_string()), + sequence: 42, + }) + ); + assert_eq!( + resource("/project/live/video/main/seg/t2000.m4s"), + Some(Resource::SegmentAt { + kind: Kind::Video, + rendition: "main".to_string(), + generation: None, + pts: 2000, + }) + ); + assert_eq!( + resource("/project/live/video/main/seg/init.t2000.m4s"), + Some(Resource::SegmentAt { + kind: Kind::Video, + rendition: "main".to_string(), + generation: Some("init".to_string()), + pts: 2000, + }) + ); + assert!(Route::parse("/master.m3u8").is_none()); + assert!(Route::parse("/project/live/video/main/unknown").is_none()); + assert!(Route::parse("/project/live/data/main/media.m3u8").is_none()); + assert!(Route::parse("/project/live/video/main/init.mp4").is_none()); + assert!(Route::parse("/project/live/video/main/init..mp4").is_none()); + assert!(Route::parse("/project/live/video/main/seg/tx.m4s").is_none()); + assert!(Route::parse("/project/live/video/main/seg/1.ts").is_none()); } #[test] fn rejects_empty_path_segments() { - assert!(parse_route("/project//live/master.m3u8").is_none()); + assert!(Route::parse("/project//live/master.m3u8").is_none()); } #[test] fn rejects_encoded_broadcast_separators() { - assert!(parse_route("/project/private%2F/master.m3u8").is_none()); - assert!(parse_route("/project%2Fprivate/master.m3u8").is_none()); + assert!(Route::parse("/project/private%2F/master.m3u8").is_none()); + assert!(Route::parse("/project%2Fprivate/master.m3u8").is_none()); } const TIMEOUT: Duration = Duration::from_secs(10); @@ -505,6 +603,42 @@ mod tests { media.write(vp8_frame(4_000_000, true)).unwrap(); } + /// An embedder parses the path, rewrites the broadcast into its own scope, and answers + /// through `respond` with the query propagated to every child URL. + #[tokio::test] + async fn respond_serves_a_route_rewritten_into_scope() { + let pair = lite_pair().await; + let mut broadcast = pair.pub_origin.create_broadcast("live").expect("publish"); + broadcast.announce(Default::default()).expect("announce"); + let (_catalog, _registration, _track, mut media) = publish_video(&mut broadcast, video_config(), None); + write_three_gops(&mut media); + + let server = Server::new(pair.sub_origin.consume(), crate::export::Config::default()); + let mut route = Route::parse("/tenant/live/video/video0/media.m3u8").expect("media route"); + route.broadcast = route.broadcast.strip_prefix("tenant/").expect("scoped").to_string(); + + let deadline = tokio::time::Instant::now() + TIMEOUT; + let body = loop { + let response = server.respond(&route, Some("jwt=abc")).await; + if response.status() == StatusCode::OK { + let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let body = String::from_utf8(body.to_vec()).unwrap(); + if body.contains("seg/0.m4s") { + break body; + } + } + assert!( + tokio::time::Instant::now() < deadline, + "media playlist never listed a segment" + ); + tokio::time::sleep(Duration::from_millis(50)).await; + }; + assert!(body.contains(".mp4?jwt=abc\""), "{body}"); + assert!(body.contains("seg/0.m4s?jwt=abc"), "{body}"); + + pair.accept.abort(); + } + async fn status(app: &axum::Router, uri: &str) -> StatusCode { oneshot(app.clone(), uri).await.status() } diff --git a/rs/moq-rtc/Cargo.toml b/rs/moq-rtc/Cargo.toml index 83843ef6a8..f8a10f6154 100644 --- a/rs/moq-rtc/Cargo.toml +++ b/rs/moq-rtc/Cargo.toml @@ -12,11 +12,18 @@ rust-version.workspace = true keywords = ["webrtc", "whip", "whep", "moq", "media"] categories = ["multimedia", "network-programming", "web-programming"] +[features] +default = ["server"] +# The bundled axum routers and the `pub use axum`. An embedder that owns its HTTP +# stack and calls `whip::accept` / `whep::accept` drops them with +# `default-features = false`. +server = ["dep:axum"] + # Pure library: the WHIP/WHEP <-> MoQ gateway (axum routers + WHEP/WHIP client). # The CLI lives in `moq-cli` (the `rtc` subcommand); embedders mount the routers # / dial with `Client` against their own origin. [dependencies] -axum = { workspace = true } +axum = { workspace = true, optional = true } bytes = { workspace = true } hang = { workspace = true } moq-mux = { workspace = true } diff --git a/rs/moq-rtc/src/lib.rs b/rs/moq-rtc/src/lib.rs index 7217a4c8f6..127fcf3749 100644 --- a/rs/moq-rtc/src/lib.rs +++ b/rs/moq-rtc/src/lib.rs @@ -25,7 +25,9 @@ //! (resolving the broadcast name from a verified token), skip the routers and //! call [`whip::accept`] (ingest) / [`whep::accept`] (egress) from your own //! handler. Return the [`Response::answer`] in your HTTP response, then run -//! [`Response::run`] to drive the media session for its lifetime. +//! [`Response::run`] to drive the media session for its lifetime. The routers and +//! the `axum` re-export sit behind the default `server` feature, so such an +//! embedder can drop them with `default-features = false`. //! //! ## Bitstream gotcha //! @@ -55,7 +57,8 @@ mod session; /// returned by [`Server::publish_router`] / [`Server::subscribe_router`] (and by /// [`whip::router`] / [`whep::router`]) into their own app without adding their own /// axum dependency (and risking a version mismatch). A major axum bump is therefore -/// a breaking change for this crate. +/// a breaking change for this crate. Only with the `server` feature. +#[cfg(feature = "server")] pub use axum; /// Re-export of the URL type, so consumers can build the [`url::Url`] that @@ -68,7 +71,7 @@ pub use client::Client; pub use error::*; pub use server::{Response, Server, whep, whip}; -#[cfg(test)] +#[cfg(all(test, feature = "server"))] mod tests { use std::time::Duration; diff --git a/rs/moq-rtc/src/sdp.rs b/rs/moq-rtc/src/sdp.rs index 8cbf9863e2..9b2db73e07 100644 --- a/rs/moq-rtc/src/sdp.rs +++ b/rs/moq-rtc/src/sdp.rs @@ -5,7 +5,6 @@ //! parse/serialize is a tiny wrapper to keep the call sites readable. use std::borrow::Cow; -use std::str::FromStr; use crate::{Error, Result}; @@ -59,12 +58,14 @@ pub fn new_resource_id() -> String { /// /// WHIP DELETEs come back to `//`; this strips /// everything but the id so the gateway can look up the session. +#[cfg(feature = "server")] pub fn parse_resource_id(path: &str) -> Result { let last = path .rsplit('/') .find(|s| !s.is_empty()) .ok_or_else(|| Error::InvalidSdp("missing resource id".into()))?; - uuid::Uuid::from_str(last).map_err(|err| Error::InvalidSdp(err.to_string())) + last.parse::() + .map_err(|err| Error::InvalidSdp(err.to_string())) } #[cfg(test)] diff --git a/rs/moq-rtc/src/server/mod.rs b/rs/moq-rtc/src/server/mod.rs index 3585f15b8a..18e0df6831 100644 --- a/rs/moq-rtc/src/server/mod.rs +++ b/rs/moq-rtc/src/server/mod.rs @@ -1,10 +1,10 @@ //! HTTP-server side: accept WHIP/WHEP offers from remote clients. //! -//! Mounts axum routers that publish into [`moq_net::origin::Producer`] (WHIP +//! Accepts offers that publish into [`moq_net::origin::Producer`] (WHIP //! / `server publish`) and pull from [`moq_net::origin::Consumer`] (WHEP / //! `server subscribe`). The HTTP listener itself is the caller's -//! responsibility; the `moq-cli` `rtc` subcommand mounts these under an -//! HTTP server. +//! responsibility; the `moq-cli` `rtc` subcommand mounts the bundled axum +//! routers (the `server` feature) under an HTTP server. pub mod whep; pub mod whip; @@ -16,7 +16,9 @@ use std::net::SocketAddr; use std::sync::{Arc, Mutex}; use std::time::Duration; +#[cfg(feature = "server")] use axum::Router; +#[cfg(feature = "server")] use axum::http::{HeaderValue, StatusCode, Uri}; use tokio::sync::{OnceCell, oneshot}; @@ -117,6 +119,7 @@ fn normalize_session_result(result: Result<()>) -> Result<()> { /// Build the `Location` header for a negotiated session by appending the /// resource id to the request path, preserving whatever prefix the router is /// mounted under. +#[cfg(feature = "server")] pub(crate) fn session_location(uri: &Uri, resource_id: &str) -> Option { let base = uri.path().trim_end_matches('/'); let path = if base.is_empty() { @@ -174,7 +177,7 @@ impl Default for Config { } } -/// Shared WebRTC media state that hands axum routers to the caller. +/// Shared WebRTC media state: the UDP mux and live sessions behind [`whip::accept`] and [`whep::accept`]. #[derive(Clone)] pub struct Server { inner: Arc, @@ -217,7 +220,8 @@ impl Server { /// The router derives the broadcast name from the request path and performs /// no authentication. To own the route and authorize requests yourself /// (resolving the broadcast name from a verified token), skip the router and - /// call [`whip::accept`] directly from your own handler. + /// call [`whip::accept`] directly from your own handler. Only with the `server` feature. + #[cfg(feature = "server")] pub fn publish_router(&self, publisher: moq_net::origin::Producer) -> Router { whip::router(self.clone(), publisher) } @@ -228,7 +232,8 @@ impl Server { /// The router derives the broadcast name from the request path and performs /// no authentication. To own the route and authorize requests yourself /// (resolving the broadcast name from a verified token), skip the router and - /// call [`whep::accept`] directly from your own handler. + /// call [`whep::accept`] directly from your own handler. Only with the `server` feature. + #[cfg(feature = "server")] pub fn subscribe_router(&self, subscriber: moq_net::origin::Consumer) -> Router { whep::router(self.clone(), subscriber) } @@ -269,6 +274,7 @@ impl Server { /// Shared `DELETE` handler for both bundled routers: parse the resource id from /// the trailing path segment and terminate the matching session. +#[cfg(feature = "server")] pub(crate) fn delete(server: &Server, path: &str) -> StatusCode { match crate::sdp::parse_resource_id(path) { Ok(id) if server.terminate(&id.to_string()) => StatusCode::OK, @@ -313,6 +319,7 @@ mod tests { assert!(normalize_session_result(Err(Error::SessionClosed)).is_ok()); } + #[cfg(feature = "server")] #[test] fn session_location_preserves_mount_path() { let uri: Uri = "/whip/live/cam0?token=secret".parse().unwrap(); diff --git a/rs/moq-rtc/src/server/whep.rs b/rs/moq-rtc/src/server/whep.rs index 4a3b3c704c..73965b78ea 100644 --- a/rs/moq-rtc/src/server/whep.rs +++ b/rs/moq-rtc/src/server/whep.rs @@ -3,6 +3,7 @@ //! `POST /` accepts a WHEP SDP offer and returns an SDP //! answer sourced from the matching MoQ broadcast on the subscribe origin. +#[cfg(feature = "server")] use axum::{ Router, body::Bytes, @@ -19,6 +20,7 @@ use crate::{Error, Result, egress::EgressSource, sdp, server::Server, session}; pub use crate::server::Response; +#[cfg(feature = "server")] #[derive(Clone)] struct RouterState { server: Server, @@ -31,13 +33,15 @@ struct RouterState { /// `accept` future parks forever inside the HTTP handler, leaking the request. const CATALOG_TIMEOUT: Duration = Duration::from_secs(5); -/// Build the WHEP axum router. +/// Build the WHEP axum router. Only with the `server` feature. +#[cfg(feature = "server")] pub fn router(server: Server, subscriber: moq_net::origin::Consumer) -> Router { Router::new() .route("/{*path}", post(handle).delete(delete)) .with_state(RouterState { server, subscriber }) } +#[cfg(feature = "server")] async fn handle( state: State, path: Path, @@ -72,6 +76,7 @@ async fn handle( /// Router glue: enforce the WHEP `Content-Type` then hand the raw offer to /// [`accept`], using the request path as the (unauthenticated) broadcast name. +#[cfg(feature = "server")] async fn accept_offer( server: &Server, subscriber: &moq_net::origin::Consumer, @@ -86,6 +91,7 @@ async fn accept_offer( accept(server, subscriber, path, offer).await } +#[cfg(feature = "server")] async fn delete(State(state): State, Path(path): Path) -> StatusCode { crate::server::delete(&state.server, &path) } @@ -172,6 +178,7 @@ pub async fn accept( }) } +#[cfg(feature = "server")] fn is_sdp(headers: &HeaderMap) -> bool { headers .get(header::CONTENT_TYPE) @@ -180,6 +187,7 @@ fn is_sdp(headers: &HeaderMap) -> bool { .unwrap_or(false) } +#[cfg(feature = "server")] fn status_for(err: &Error) -> StatusCode { match err { Error::InvalidSdp(_) => StatusCode::BAD_REQUEST, diff --git a/rs/moq-rtc/src/server/whip.rs b/rs/moq-rtc/src/server/whip.rs index 6d618aad81..bf08db2b1b 100644 --- a/rs/moq-rtc/src/server/whip.rs +++ b/rs/moq-rtc/src/server/whip.rs @@ -4,6 +4,7 @@ //! and returns an SDP answer. The request path becomes the broadcast name on //! the upstream publish origin. +#[cfg(feature = "server")] use axum::{ Router, body::Bytes, @@ -18,19 +19,22 @@ use crate::{Error, Result, ingest::IngestSink, sdp, server::Server, session}; pub use crate::server::Response; +#[cfg(feature = "server")] #[derive(Clone)] struct RouterState { server: Server, publisher: moq_net::origin::Producer, } -/// Build the WHIP axum router. +/// Build the WHIP axum router. Only with the `server` feature. +#[cfg(feature = "server")] pub fn router(server: Server, publisher: moq_net::origin::Producer) -> Router { Router::new() .route("/{*path}", post(handle).delete(delete)) .with_state(RouterState { server, publisher }) } +#[cfg(feature = "server")] async fn handle( State(state): State, Path(path): Path, @@ -64,6 +68,7 @@ async fn handle( /// Router glue: enforce the WHIP `Content-Type` then hand the raw offer to /// [`accept`], using the request path as the (unauthenticated) broadcast name. +#[cfg(feature = "server")] async fn accept_offer( server: &Server, publisher: &moq_net::origin::Producer, @@ -78,6 +83,7 @@ async fn accept_offer( accept(server, publisher, path, offer).await } +#[cfg(feature = "server")] async fn delete(State(state): State, Path(path): Path) -> StatusCode { crate::server::delete(&state.server, &path) } @@ -160,6 +166,7 @@ pub async fn accept( }) } +#[cfg(feature = "server")] fn is_sdp(headers: &HeaderMap) -> bool { headers .get(header::CONTENT_TYPE) @@ -168,6 +175,7 @@ fn is_sdp(headers: &HeaderMap) -> bool { .unwrap_or(false) } +#[cfg(feature = "server")] fn status_for(err: &Error) -> StatusCode { match err { Error::InvalidSdp(_) => StatusCode::BAD_REQUEST, diff --git a/rs/moq-rtmp/README.md b/rs/moq-rtmp/README.md index 4d3197262b..e45d1f14e9 100644 --- a/rs/moq-rtmp/README.md +++ b/rs/moq-rtmp/README.md @@ -50,7 +50,9 @@ connect exchange, then yields a `Request` once the client wants to publish or play. The `Request` is either a `Publish` or a `Play`; you inspect the app and stream key, make a decision, and `accept` or `reject` it. This mirrors `moq-tokio`'s `Server` / `Request`, so there's no callback: the auth policy lives -in your loop. +in your loop. To keep `run`'s first-publisher-wins rule, claim each publish's +resolved path on a shared `moq_rtmp::ActivePaths` and hold the guard while the +publish runs. ```rust let mut server = moq_rtmp::Server::bind("0.0.0.0:1935".parse()?).await?; @@ -83,11 +85,11 @@ while let Some(request) = server.accept().await { Two ways to serve `rtmps://`: - **Let the gateway terminate TLS.** Set `Config::tls` (or call - `Server::with_tls`) with a `rustls::ServerConfig`, and the listener speaks - RTMPS with no other change. Build the config from a `moq_tokio::tls::Listen` - instance (RTMPS has no ALPN), or supply any `rustls::ServerConfig`. To serve - both RTMP and RTMPS, clone one base config so duplicate-publish rejection is - shared across both listeners, then call `run` with a cloned origin. + `Server::with_tls`) with a `rustls::ServerConfig`, and the listener serves + RTMPS alongside plaintext RTMP on the same port: a client that opens with a + TLS ClientHello is TLS-terminated, any other is served as plaintext. Build the + config from a `moq_tokio::tls::Listen` instance (RTMPS has no ALPN), or supply + any `rustls::ServerConfig`. ```rust let mut tls = moq_tokio::tls::Listen::default(); @@ -96,10 +98,7 @@ Two ways to serve `rtmps://`: let mut rtmp = moq_rtmp::Config::default(); rtmp.listen = Some("0.0.0.0:1935".parse()?); - - let mut rtmps = rtmp.clone(); - rtmps.listen = Some("0.0.0.0:443".parse()?); - rtmps.tls = Some(server_config); // Arc + rtmp.tls = Some(server_config); // Arc: rtmp:// and rtmps:// ``` - **Bring your own transport.** Accept the connection and complete the TLS diff --git a/rs/moq-rtmp/src/lib.rs b/rs/moq-rtmp/src/lib.rs index 1563adc85c..51124ff3a9 100644 --- a/rs/moq-rtmp/src/lib.rs +++ b/rs/moq-rtmp/src/lib.rs @@ -32,7 +32,9 @@ //! a [`Publish`] into an origin, or accept a [`Play`] out of one, at a path of //! your choosing (or reject it). This is how an embedder (e.g. a relay verifying //! a JWT and scoping the origin per token) plugs its policy in, with no -//! callback. It mirrors `moq-tokio`'s `Server` / `Request`. +//! callback. It mirrors `moq-tokio`'s `Server` / `Request`. Claim each +//! publish's resolved path on an [`ActivePaths`] to keep [`run`]'s +//! first-publisher-wins rule. //! //! Beyond the listener, [`Client`] is the *dial-out* (client) role: connect to a //! remote RTMP server and either [`publish`](Client::publish) a MoQ broadcast to @@ -48,7 +50,8 @@ //! //! - **Let the gateway terminate TLS**: set [`Config::tls`] (or call //! [`Server::with_tls`]) with a [`rustls::ServerConfig`], and the listener -//! speaks `rtmps://` with no other change. +//! serves `rtmps://` alongside `rtmp://` on the same port, telling them apart +//! by the client's first byte. //! - **Bring your own transport**: accept the connection and complete the TLS //! handshake yourself (any [`Stream`]: a `tokio_rustls` stream, a custom //! socket, a test pipe), then hand the established stream to [`accept_stream`]. @@ -86,7 +89,7 @@ pub const DEFAULT_MAX_AGE: Duration = Duration::from_secs(2); pub use dial::Client; pub use error::{Error, Result}; -pub use listen::{Config, run}; +pub use listen::{ActivePaths, Config, PathGuard, run}; pub use server::{Conn, PUBLISH_IDLE_TIMEOUT, Play, Publish, Request, Server, Stream, accept_stream, configure_socket}; /// Re-export of the `rustls` version this crate builds [`Config::tls`] against, diff --git a/rs/moq-rtmp/src/listen.rs b/rs/moq-rtmp/src/listen.rs index 44cb337601..97d9e2176b 100644 --- a/rs/moq-rtmp/src/listen.rs +++ b/rs/moq-rtmp/src/listen.rs @@ -62,13 +62,15 @@ pub struct Config { pub import_max_age: Option, /// TLS configuration for RTMPS (RTMP over TLS). When set, the - /// [`listen`](Self::listen) address speaks RTMPS instead of plaintext RTMP, - /// so clients connect with `rtmps://`. Build it with + /// [`listen`](Self::listen) address serves both: a client that opens with a + /// TLS ClientHello (`rtmps://`) is TLS-terminated, any other is served as + /// plaintext (`rtmp://`). Build it with /// `moq_tokio::tls::Listen::server_config` (pass an empty ALPN list) or - /// any [`rustls::ServerConfig`]. Leave `None` for plaintext. + /// any [`rustls::ServerConfig`]. Leave `None` for plaintext only. /// - /// To serve both RTMP and RTMPS, clone one base config and call [`run`] for - /// each listener against a cloned origin. + /// To serve RTMP and RTMPS on separate ports instead, clone one base config + /// and call [`run`] for each listener against a cloned origin; the clones + /// share one [`ActivePaths`]. #[cfg(feature = "tls")] pub tls: Option>, @@ -205,25 +207,34 @@ pub(crate) fn resolve_path(prefix: &Path, app: &str, key: &str) -> Option>>); +pub struct ActivePaths(Arc>>); impl ActivePaths { /// Claim `path`, returning a guard that releases it on drop, or `None` if it - /// is already claimed. - pub(crate) fn claim(&self, path: &str) -> Option { + /// is already claimed. Paths compare after normalization, as + /// [`Publish::accept`](crate::Publish::accept) resolves them, so `live//cam` + /// and `/live/cam` are one claim. + pub fn claim(&self, path: impl moq_net::AsPath) -> Option { + let path = path.as_path().as_str().to_string(); let mut set = self.0.lock().expect("active paths mutex poisoned"); - set.insert(path.to_string()).then(|| PathGuard { + set.insert(path.clone()).then(|| PathGuard { paths: self.0.clone(), - path: path.to_string(), + path, }) } } /// Releases a claimed [`ActivePaths`] entry when dropped. -pub(crate) struct PathGuard { +#[must_use = "dropping the guard releases the path"] +pub struct PathGuard { paths: Arc>>, path: String, } @@ -281,6 +292,7 @@ mod tests { let guard = active.claim("live/cam0").expect("first claim succeeds"); assert!(active.claim("live/cam0").is_none()); + assert!(active.claim("/live//cam0/").is_none(), "claims compare normalized"); let other = active.claim("live/cam1").expect("distinct path claims"); drop(guard); diff --git a/rs/moq-rtmp/src/server.rs b/rs/moq-rtmp/src/server.rs index 250eac7c5e..9087b93a4b 100644 --- a/rs/moq-rtmp/src/server.rs +++ b/rs/moq-rtmp/src/server.rs @@ -18,7 +18,8 @@ //! as a JWT) owns that policy. //! //! RTMPS (RTMP over TLS): [`Server::with_tls`] makes the listener terminate TLS -//! before the RTMP handshake, so `rtmps://` clients work with no other change. +//! before the RTMP handshake for any client that opens with a ClientHello, so +//! `rtmps://` and `rtmp://` clients share one port with no other change. //! If you'd rather own the transport (custom TLS, a non-TCP socket, a test //! pipe), accept the connection and complete any handshake yourself, then hand //! the established stream to [`accept_stream`]; everything here is generic over @@ -175,7 +176,7 @@ pub enum Conn { /// A plaintext TCP connection (`rtmp://`). Plain(TcpStream), - /// A TLS connection (`rtmps://`), established by [`Server::with_tls`]. Boxed + /// A TLS connection (`rtmps://`), sniffed and terminated by [`Server::with_tls`]. Boxed /// because a `TlsStream` is large relative to a bare `TcpStream`. #[cfg(feature = "tls")] Tls(Box>), @@ -267,10 +268,11 @@ impl Server { }) } - /// Terminate TLS on every accepted connection, turning this into an RTMPS - /// listener (`rtmps://`). Pass a `rustls::ServerConfig` (e.g. from + /// Serve RTMPS (`rtmps://`) alongside plaintext RTMP on this one port: a + /// connection that opens with a TLS ClientHello is TLS-terminated, any other is + /// served as plaintext. Pass a `rustls::ServerConfig` (e.g. from /// `moq_tokio::tls::Listen::server_config` with an empty ALPN list), or - /// `None` to leave it plaintext. + /// `None` to serve plaintext only. #[cfg(feature = "tls")] pub fn with_tls(mut self, tls: impl Into>>) -> Self { self.tls = tls.into().map(tokio_rustls::TlsAcceptor::from); @@ -318,13 +320,13 @@ impl Server { let outcome = tokio::time::timeout(REQUEST_TIMEOUT, async move { #[cfg(feature = "tls")] let conn = match tls { - Some(acceptor) => Conn::Tls(Box::new( + Some(acceptor) if starts_tls(&stream).await? => Conn::Tls(Box::new( acceptor .accept(stream) .await .map_err(|e| anyhow::anyhow!("rtmps tls handshake: {e}"))?, )), - None => Conn::Plain(stream), + _ => Conn::Plain(stream), }; #[cfg(not(feature = "tls"))] let conn = Conn::Plain(stream); @@ -366,6 +368,21 @@ impl Server { } } +/// The first byte of a TLS record carrying a ClientHello. RTMP's C0 is `0x03` (or `0x06` +/// for the legacy encrypted variant), so one byte tells the two apart. +#[cfg(feature = "tls")] +const TLS_HANDSHAKE_RECORD: u8 = 0x16; + +/// Whether the client opened with a TLS ClientHello, peeked without consuming it. +#[cfg(feature = "tls")] +async fn starts_tls(stream: &TcpStream) -> io::Result { + let mut first = [0u8; 1]; + if stream.peek(&mut first).await? == 0 { + return Err(io::ErrorKind::UnexpectedEof.into()); + } + Ok(first[0] == TLS_HANDSHAKE_RECORD) +} + /// Sleep until `at`, or park forever when there is nothing to wait for. /// /// The `select!` arm that uses this is guarded on `at` being set; the pending branch keeps the arm @@ -2101,11 +2118,12 @@ mod tests { } /// The same publish flow, but over TLS: prove [`Server::with_tls`] terminates - /// RTMPS and yields an identical [`Request`]. Gated on `tls` (RTMPS support); - /// the cert is generated by the `moq-tokio` dev-dependency. + /// RTMPS and yields an identical [`Request`], and that the same port still serves + /// a plaintext client. Gated on `tls` (RTMPS support); the cert is generated by + /// the `moq-tokio` dev-dependency. #[cfg(feature = "tls")] #[tokio::test] - async fn rtmps_accept_yields_publish_request() { + async fn rtmps_and_rtmp_share_one_port() { use std::sync::Arc; use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; @@ -2192,5 +2210,19 @@ mod tests { }; publish.reject("test rejection").await.unwrap(); client.abort(); + + let plain = tokio::spawn(async move { + let stream = TcpStream::connect(addr).await.unwrap(); + run_client(stream, ClientMode::Play).await; + }); + let request = tokio::time::timeout(Duration::from_secs(5), server.accept()) + .await + .expect("plaintext accept timed out") + .expect("server yielded a plaintext request"); + let Request::Play(play) = request else { + panic!("expected a play request"); + }; + play.reject("test rejection").await.unwrap(); + plain.abort(); } } From fa707c0b50d542580ef1f7d1294af07714b20907 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 24 Sep 2026 17:24:35 -0700 Subject: [PATCH 04/31] fix(net): keep a lost spliced group lost (#4077) Co-authored-by: Claude Opus 5.5 --- rs/moq-net/src/model/resume.rs | 49 +++++++++++++++++++++++++++++++--- 1 file changed, 46 insertions(+), 3 deletions(-) diff --git a/rs/moq-net/src/model/resume.rs b/rs/moq-net/src/model/resume.rs index 4e7ee4a34f..da250e34be 100644 --- a/rs/moq-net/src/model/resume.rs +++ b/rs/moq-net/src/model/resume.rs @@ -1096,8 +1096,12 @@ impl Group { } /// No replacement can arrive: report the loss that stalled us, or a clean end. - fn give_up(&mut self) -> Result { - match self.dead.take() { + /// + /// The dead route stays recorded, so every later poll reaches the same verdict. + /// Clearing it would re-resolve the route's reclaimed copy as one still to come + /// and park, and a reader probing `poll_finished` for the loss would hang. + fn give_up(&self) -> Result { + match &self.dead { Some((_, err)) => { // The only place a spliced group's loss becomes visible, so say which // frames went missing rather than leaving a stuck group to explain itself. @@ -1107,7 +1111,7 @@ impl Group { %err, "no route can serve the rest of this group" ); - Err(err) + Err(err.clone()) } None => Ok(false), } @@ -3695,6 +3699,45 @@ mod test { ); } + /// A group the reader gave up on stays lost: every later poll reports the same + /// loss. `finished` is how a reader tells a transport loss from a bad payload, so + /// it must not park once the route's aborted copy has been reclaimed from its cache. + #[tokio::test] + async fn lost_group_stays_lost() { + let (mut track_a, consumer_a) = track_pair("a"); + + let mut producer = Producer::new(); + producer.takeover(&consumer_a).unwrap(); + let mut sub = producer.consume().subscribe(replay()); + + let mut group = track_a.create_group(group::Info { sequence: 0 }).unwrap(); + group.write_frame(Timestamp::ZERO, b"a0".to_vec()).unwrap(); + + let mut reading = sub.recv_group().now_or_never().unwrap().unwrap().unwrap(); + assert_eq!(read(&mut reading), b"a0"); + + // The relay resets the rest of the group, then moves on. The next write + // reclaims the aborted slot, so the route no longer knows the group at all. + group.abort(Error::Stream(crate::StreamError::Old)).unwrap(); + write_group(&mut track_a, 1, "a1"); + + assert!(matches!( + reading.read_frame().now_or_never(), + Some(Err(Error::Stream(crate::StreamError::Old))) + )); + assert!( + matches!( + reading.finished().now_or_never(), + Some(Err(Error::Stream(crate::StreamError::Old))) + ), + "a lost group must not park or change its answer" + ); + assert!(matches!( + reading.read_frame().now_or_never(), + Some(Err(Error::Stream(crate::StreamError::Old))) + )); + } + /// A route whose copy of the group is missing the frames the reader needs is treated /// like a dead one. Reading the tail as if it were the head would silently renumber /// every frame in the group. From d4c93961867e8abd240f576dede5937fd916282f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 24 Sep 2026 18:06:27 -0700 Subject: [PATCH 05/31] fix(publish): resume the audio capture context on a gesture (#3960) Co-authored-by: Claude Opus 5.5 --- doc/lib/js/publish.md | 4 +- .../src/util/gesture.test.ts} | 14 +- js/hang/src/util/gesture.ts | 38 ++++ js/hang/src/util/index.ts | 3 +- js/publish/README.md | 4 +- js/publish/src/audio/capture.test.ts | 173 +++++++++++++++++- js/publish/src/audio/capture.ts | 41 +++-- js/watch/src/audio/decoder.ts | 5 +- js/watch/src/audio/unlock.ts | 35 ---- quest/m1/README.md | 1 - quest/m1/publish-audio-unlock.md | 30 --- test/interop/clients/js/src/fixture.ts | 14 +- 12 files changed, 258 insertions(+), 104 deletions(-) rename js/{watch/src/audio/unlock.test.ts => hang/src/util/gesture.test.ts} (89%) create mode 100644 js/hang/src/util/gesture.ts delete mode 100644 js/watch/src/audio/unlock.ts delete mode 100644 quest/m1/publish-audio-unlock.md diff --git a/doc/lib/js/publish.md b/doc/lib/js/publish.md index 2af452cb20..9601324139 100644 --- a/doc/lib/js/publish.md +++ b/doc/lib/js/publish.md @@ -120,7 +120,9 @@ new Publish.Audio.Encoder("audio", { broadcast, capture: audioCapture, enabled: Standalone components start enabled unless you pass `enabled: false` (or a signal). Camera and microphone sources may prompt for permission on construction, so build an enabled screen source inside the user gesture that -authorizes screen capture. +authorizes screen capture. Audio capture that starts before the page's first +click or keypress waits for one: browsers suspend Web Audio until then, and the +audio rendition stays out of the catalog until samples flow. Every input and output is a signal from [`@moq/signals`](/lib/js/signals). Load from a CDN (`https://esm.sh/@moq/publish/element`) for a no-build embed. diff --git a/js/watch/src/audio/unlock.test.ts b/js/hang/src/util/gesture.test.ts similarity index 89% rename from js/watch/src/audio/unlock.test.ts rename to js/hang/src/util/gesture.test.ts index 1dba2f807e..f65087dbb3 100644 --- a/js/watch/src/audio/unlock.test.ts +++ b/js/hang/src/util/gesture.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; import { Effect } from "@moq/signals"; -import { unlockOnGesture } from "./unlock"; +import { unlock } from "./gesture"; // Minimal AudioContext stand-in: an EventTarget with a mutable `state` and a counting // `resume()`. `transition` mirrors a real context firing `statechange` when its state moves. @@ -38,7 +38,7 @@ const asContext = (ctx: MockContext) => ctx as unknown as AudioContext; test("retries resume() on a user gesture until the context is running", async () => { const ctx = new MockContext(); const effect = new Effect(); - unlockOnGesture(effect, asContext(ctx)); + unlock(effect, asContext(ctx)); await flush(); // The at-load attempt fires once. Browsers requiring a gesture reject it, but we still @@ -53,11 +53,15 @@ test("retries resume() on a user gesture until the context is running", async () document.dispatchEvent(new Event("keydown")); expect(ctx.resumeCalls).toBe(3); + // Touch and pen only grant activation on pointerup, so a tap must retry there too. + document.dispatchEvent(new Event("pointerup")); + expect(ctx.resumeCalls).toBe(4); + // Once the context is actually running, stop retrying: further gestures are no-ops. ctx.transition("running"); await flush(); document.dispatchEvent(new Event("pointerdown")); - expect(ctx.resumeCalls).toBe(3); + expect(ctx.resumeCalls).toBe(4); effect.close(); }); @@ -65,7 +69,7 @@ test("retries resume() on a user gesture until the context is running", async () test("re-arms when Safari drops the context to interrupted", async () => { const ctx = new MockContext(); const effect = new Effect(); - unlockOnGesture(effect, asContext(ctx)); + unlock(effect, asContext(ctx)); await flush(); ctx.transition("running"); @@ -87,7 +91,7 @@ test("re-arms when Safari drops the context to interrupted", async () => { test("stops resuming after the effect closes", async () => { const ctx = new MockContext(); const effect = new Effect(); - unlockOnGesture(effect, asContext(ctx)); + unlock(effect, asContext(ctx)); await flush(); effect.close(); diff --git a/js/hang/src/util/gesture.ts b/js/hang/src/util/gesture.ts new file mode 100644 index 0000000000..e92b72998d --- /dev/null +++ b/js/hang/src/util/gesture.ts @@ -0,0 +1,38 @@ +import { type Effect, type Getter, Signal } from "@moq/signals"; + +/** + * Resume a suspended {@link AudioContext} from a real user gesture, returning whether it is running. + * + * A context built before any user activation starts suspended in browsers that gate audio on a + * gesture, and a `resume()` made then is rejected. A single unconditional attempt would fire once, + * be rejected, and never retry, leaving the graph silent. This instead attempts `resume()` + * immediately (for autoplay-permissive browsers like Chrome with prior engagement), then retries on + * every gesture until the context is actually running, dropping the listeners once it is. A mouse + * grants activation on `pointerdown` but touch and pen only on `pointerup`, so both are listened to, + * plus `keydown`. + * + * Safari also reports an "interrupted" state (a WebKit-only value outside the + * suspended/running/closed set) and can leave it on its own; mirroring `statechange` into the + * returned signal picks that up so the listeners are re-armed or dropped as the state moves. + * + * Scoped to `effect`: the listeners are removed when the effect reruns or closes. + */ +export function unlock(effect: Effect, context: AudioContext): Getter { + const running = new Signal(context.state === "running"); + effect.event(context, "statechange", () => running.set(context.state === "running")); + + effect.run((inner) => { + if (inner.get(running)) return; + + const resume = () => { + context.resume().catch(() => {}); + }; + + resume(); + inner.event(document, "pointerdown", resume); + inner.event(document, "pointerup", resume); + inner.event(document, "keydown", resume); + }); + + return running; +} diff --git a/js/hang/src/util/index.ts b/js/hang/src/util/index.ts index cef64e9f45..a09394873b 100644 --- a/js/hang/src/util/index.ts +++ b/js/hang/src/util/index.ts @@ -1,11 +1,12 @@ /** * Miscellaneous helpers for the hang media layer: AAC and Opus codec constraints, hex encoding, - * and the libav/WebCodecs polyfill. + * the libav/WebCodecs polyfill, and unlocking Web Audio on a user gesture. * * @module */ export * as Aac from "./aac"; +export * as Gesture from "./gesture"; export * as Hacks from "./hacks"; export * as Hex from "./hex"; export * as Libav from "./libav"; diff --git a/js/publish/README.md b/js/publish/README.md index 9412d2bb35..2561ff4c86 100644 --- a/js/publish/README.md +++ b/js/publish/README.md @@ -87,7 +87,9 @@ more encoders: Standalone components start enabled when `enabled` is omitted. Camera and microphone sources may request permission immediately. Create an enabled screen source during the user gesture that -authorizes screen capture, or pass a live input that is false until that gesture. +authorizes screen capture, or pass a live input that is false until that gesture. Audio capture +that starts before the page's first click or keypress waits for one: browsers suspend Web Audio +until then, and the audio rendition stays out of the catalog until samples flow. ```typescript import * as Publish from "@moq/publish"; diff --git a/js/publish/src/audio/capture.test.ts b/js/publish/src/audio/capture.test.ts index ef4799f793..53526249ec 100644 --- a/js/publish/src/audio/capture.test.ts +++ b/js/publish/src/audio/capture.test.ts @@ -21,14 +21,19 @@ function installFakeWebAudio() { let audioWorkletNodes = 0; const requestedRates: (number | undefined)[] = []; - class FakeAudioContext { - state: AudioContextState = "suspended"; + // Already running, as after a gesture, so only the pending module load holds the worklet back. + class FakeAudioContext extends EventTarget { + state: AudioContextState = "running"; audioWorklet = { addModule }; constructor(options?: AudioContextOptions) { + super(); requestedRates.push(options?.sampleRate); } + resume(): Promise { + return Promise.resolve(); + } close(): Promise { - // Firefox/Safari behavior: stays "suspended", never "closed". + // Firefox/Safari behavior: never flips to "closed" synchronously. return Promise.resolve(); } } @@ -50,6 +55,7 @@ function installFakeWebAudio() { } const globals: Record = { + document: new EventTarget(), AudioContext: FakeAudioContext, MediaStream: FakeMediaStream, MediaStreamAudioSourceNode: FakeGraphNode, @@ -231,3 +237,164 @@ test("rejects the removed source prop instead of publishing nothing", async () = "moved to Audio.Capture", ); }); + +// Models a browser that gates audio on a gesture: a context built without user activation starts +// suspended, renders nothing, and `resume()` never settles until the page has been interacted with. +function installGatedWebAudio() { + const page = new EventTarget(); + let activated = false; + const contexts: GatedContext[] = []; + const worklets: GatedWorklet[] = []; + const roots: FakeGraphNode[] = []; + + class GatedContext extends EventTarget { + state: string = "suspended"; + sampleRate: number; + audioWorklet = { addModule: () => Promise.resolve() }; + constructor(options?: AudioContextOptions) { + super(); + this.sampleRate = options?.sampleRate ?? 48_000; + contexts.push(this); + } + resume(): Promise { + // Safari holds an interrupted context until the interruption ends, whatever the page does. + if (!activated || this.state === "interrupted") return new Promise(() => {}); + this.transition("running"); + return Promise.resolve(); + } + close(): Promise { + return Promise.resolve(); + } + transition(state: string): void { + if (this.state === state) return; + this.state = state; + this.dispatchEvent(new Event("statechange")); + } + } + + class GatedWorklet { + port = Object.assign(new EventTarget(), { start: () => {} }); + zero: number; + constructor(_context: unknown, _name: string, options?: AudioWorkletNodeOptions) { + this.zero = options?.processorOptions?.zero; + worklets.push(this); + } + connect(): void {} + disconnect(): void {} + // What the processor posts once the graph renders a quantum. + render(): void { + this.port.dispatchEvent( + new MessageEvent("message", { data: { timestamp: 0, channels: [new Float32Array(128)] } }), + ); + } + } + + class FakeGraphNode { + channelCount = 2; + outputs = new Set(); + constructor() { + roots.push(this); + } + connect(node: unknown): void { + this.outputs.add(node); + } + disconnect(node?: unknown): void { + if (node === undefined) this.outputs.clear(); + else this.outputs.delete(node); + } + } + + const globals: Record = { + document: page, + AudioContext: GatedContext, + MediaStream: class {}, + MediaStreamAudioSourceNode: FakeGraphNode, + AudioWorkletNode: GatedWorklet, + }; + + const originals = new Map(); + for (const [name, value] of Object.entries(globals)) { + originals.set(name, Object.getOwnPropertyDescriptor(globalThis, name)); + Object.defineProperty(globalThis, name, { configurable: true, writable: true, value }); + } + + return { + contexts, + worklets, + roots, + // A real click: the page gains user activation, then the event reaches its listeners. + gesture() { + activated = true; + page.dispatchEvent(new Event("pointerdown")); + }, + [Symbol.dispose]() { + for (const [name, original] of originals) { + if (original) Object.defineProperty(globalThis, name, original); + else Reflect.deleteProperty(globalThis, name); + } + }, + }; +} + +// Regression: a source handed over before any gesture (a pre-granted microphone on page load) built a +// context that stayed suspended forever, since nothing ever resumed it. The worklet never posted, so the +// format and with it the audio catalog never appeared, even after the user clicked. +test("captures once a gesture resumes a context built before one", async () => { + using webaudio = installGatedWebAudio(); + + const capture = new Capture({ enabled: true, source: new Signal(fakeSource()) as never }); + await settle(); + + // Suspended: no worklet stamping frames against a clock that isn't moving, and no format. + expect(webaudio.contexts[0].state).toBe("suspended"); + expect(webaudio.worklets.length).toBe(0); + expect(capture.out.format.peek()).toBeUndefined(); + + const before = performance.now() * 1000; + webaudio.gesture(); + await settle(); + + expect(webaudio.contexts[0].state).toBe("running"); + expect(webaudio.worklets.length).toBe(1); + + // The worklet is anchored when the graph starts, not when the source appeared, so audio stays on + // the same wall clock as video however long the page waited for the click. + expect(webaudio.worklets[0].zero).toBeGreaterThanOrEqual(before); + + webaudio.worklets[0].render(); + expect(capture.out.format.peek()).toEqual({ sampleRate: 48_000, channelCount: 1 }); + + capture.close(); + await settle(); +}); + +// A suspended graph carries nothing, so an interrupted context (Safari, on a phone call) must not +// leave the format behind for the encoder to keep advertising. +test("drops the format while the context is interrupted", async () => { + using webaudio = installGatedWebAudio(); + + const capture = new Capture({ enabled: true, source: new Signal(fakeSource()) as never }); + await settle(); + webaudio.gesture(); + await settle(); + webaudio.worklets[0].render(); + expect(capture.out.format.peek()).toBeDefined(); + + webaudio.contexts[0].transition("interrupted"); + await settle(); + expect(capture.out.format.peek()).toBeUndefined(); + expect(capture.out.frames.peek()).toBeUndefined(); + // The retired worklet is cut from the source, or it keeps posting alongside its replacement. + expect(webaudio.roots[0].outputs.size).toBe(0); + + // Back to running rebuilds the worklet on a fresh anchor. + webaudio.contexts[0].transition("running"); + await settle(); + expect(webaudio.worklets.length).toBe(2); + expect([...webaudio.roots[0].outputs]).toEqual([webaudio.worklets[1]]); + webaudio.worklets[1].render(); + expect(capture.out.format.peek()).toBeDefined(); + + capture.close(); + await settle(); +}); diff --git a/js/publish/src/audio/capture.ts b/js/publish/src/audio/capture.ts index fe60aa1481..15b0114645 100644 --- a/js/publish/src/audio/capture.ts +++ b/js/publish/src/audio/capture.ts @@ -146,26 +146,36 @@ export class Capture { }); effect.cleanup(() => context.close()); + // Nothing guarantees a gesture has happened yet: a pre-granted microphone reaches here on page + // load. A context built then starts suspended and renders nothing until one arrives. + const running = Util.Gesture.unlock(effect, context); + const root = new MediaStreamAudioSourceNode(context, { mediaStream: new MediaStream([source.track]), }); effect.cleanup(() => root.disconnect()); - effect.cleanup(() => { - this.#out.format.set(undefined); - }); + const loaded = new Signal(false); // Async because we need to wait for the worklet to be registered. effect.spawn(async () => { - // Race the module load against teardown. If teardown wins, `loaded` is undefined and we bail - // before constructing the node: the module registration was abandoned, so building against its - // name would throw. Gate on the race result, not `context.state`, because `AudioContext.close()` - // only flips `.state` to "closed" synchronously on Chrome (Firefox/Safari report "suspended"). - const loaded = await Promise.race([ + // Race the module load against teardown. If teardown wins, bail before flagging it loaded: the + // module registration was abandoned, so building against its name would throw. Gate on the race + // result, not `context.state`, because `AudioContext.close()` only flips `.state` to "closed" + // synchronously on Chrome (Firefox/Safari report "suspended"). + const ok = await Promise.race([ context.audioWorklet.addModule(CaptureWorklet).then(() => true), effect.cancel, ]); - if (!loaded) return; + if (ok) loaded.set(true); + }); + + // Only capture while the graph runs. The worklet stamps frames from when it is built, so one built + // while suspended would lag the wall clock by however long the page waited for a gesture. And a + // suspended graph carries nothing, so it has no format: the encoder announces no audio until + // samples actually flow, and drops it again if Safari interrupts the context. + effect.run((inner) => { + if (!inner.get(loaded) || !inner.get(running)) return; const channelCount = requestedChannels ?? settings.channelCount ?? root.channelCount; const worklet = new AudioWorkletNode(context, "capture", { @@ -180,15 +190,16 @@ export class Capture { // tracks share an epoch and stay in sync. processorOptions: { zero: performance.now() * 1000 }, }); - effect.cleanup(() => worklet.disconnect()); - + // The edge originates at root, so only root can remove it; the worklet has no outputs. root.connect(worklet); + inner.cleanup(() => root.disconnect(worklet)); - const fanout = new Fanout(this.#drain(worklet, context.sampleRate, effect), { queue: QUEUE }); - effect.cleanup(() => fanout.close()); + const fanout = new Fanout(this.#drain(worklet, context.sampleRate, inner), { queue: QUEUE }); + inner.cleanup(() => fanout.close()); + inner.cleanup(() => this.#out.format.set(undefined)); - effect.set(this.#out.root, root); - effect.set(this.#out.frames, fanout); + inner.set(this.#out.root, root); + inner.set(this.#out.frames, fanout); }); } diff --git a/js/watch/src/audio/decoder.ts b/js/watch/src/audio/decoder.ts index 65bb0162a4..c469aabdff 100644 --- a/js/watch/src/audio/decoder.ts +++ b/js/watch/src/audio/decoder.ts @@ -25,7 +25,6 @@ import { reanchorFloor, ringSamples } from "./latency"; import RenderWorklet from "./render-worklet.ts?worklet"; import type { Source } from "./source"; import { type DecodedSpan, Terminal } from "./terminal"; -import { unlockOnGesture } from "./unlock"; import { Warmup } from "./warmup"; // How long the latency target must hold steady before a floor increase re-anchors. Coalesces a @@ -237,8 +236,8 @@ export class Decoder { if (!context) return; // The context is built at page load (see #runWorklet), before any user gesture, so it - // must be started from a real interaction. See unlockOnGesture. - unlockOnGesture(effect, context); + // must be started from a real interaction. + Util.Gesture.unlock(effect, context); // NOTE: You should disconnect/reconnect the worklet to save power when disabled. } diff --git a/js/watch/src/audio/unlock.ts b/js/watch/src/audio/unlock.ts deleted file mode 100644 index dc93662090..0000000000 --- a/js/watch/src/audio/unlock.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { type Effect, Signal } from "@moq/signals"; - -/** - * Resume a suspended {@link AudioContext} from a real user gesture. - * - * The context is built at page load, before any user activation can exist, so browsers that - * gate audio on a gesture reject a `resume()` made then. A single unconditional attempt would - * fire once, be rejected, and never retry, leaving audio silent. This instead attempts - * `resume()` immediately (for autoplay-permissive browsers like Chrome with prior engagement), - * then retries on every `pointerdown`/`keydown` until the context is actually running, dropping - * the gesture listeners once it is. `pointerdown` and `keydown` cover mouse, touch, pen, and - * keyboard, and each carries a user activation. - * - * Safari also reports an "interrupted" state (a WebKit-only value outside the - * suspended/running/closed set) and can leave it on its own; mirroring `statechange` into a - * signal picks that up so the listeners are re-armed or dropped as the state moves. - * - * Scoped to `effect`: the listeners are removed when the effect reruns or closes. - */ -export function unlockOnGesture(effect: Effect, context: AudioContext): void { - const running = new Signal(context.state === "running"); - effect.event(context, "statechange", () => running.set(context.state === "running")); - - effect.run((inner) => { - if (inner.get(running)) return; - - const resume = () => { - context.resume().catch(() => {}); - }; - - resume(); - inner.event(document, "pointerdown", resume); - inner.event(document, "keydown", resume); - }); -} diff --git a/quest/m1/README.md b/quest/m1/README.md index a65d7dd2be..c46ec57cd5 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -41,7 +41,6 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Jitter clock](/quest/m1/jitter-flush-clock.md) - renditions advertise `delay` (lag behind the earliest track) and `jitter` (spread), measured at encoder flush, never lowered; js/watch sizes playout over what it subscribes - [Data jitter](/quest/m1/data-jitter.md) - JSON and binary tracks with a capture time advertise a detected `delay` and `jitter` -- [Publisher audio unlock](/quest/m1/publish-audio-unlock.md) - the publisher's capture AudioContext is resumed on a gesture or the source is refused, so no silent audio track is announced - [IETF leftovers](/quest/m1/ietf-leftovers.md) - moq-net: the 0x21 priority property, a NOT_SUPPORTED reply to TRACK_STATUS, and the two FETCH refusal codes come from the registry - [Play tune-in backpressure](/quest/m1/play-tunein-backpressure.md) - moq play: a tune-in burst larger than the video queue parks the decoder, so the clock never reaches live at a wide `--delay` - [JavaScript FETCH](/quest/m1/js-fetch.md) - generic on-demand group serving and IETF FETCH for browser publishers diff --git a/quest/m1/publish-audio-unlock.md b/quest/m1/publish-audio-unlock.md deleted file mode 100644 index e5167c4d23..0000000000 --- a/quest/m1/publish-audio-unlock.md +++ /dev/null @@ -1,30 +0,0 @@ -# [S] Unlock the publisher's capture AudioContext on a gesture - -## Goal - -A page that starts publishing before the user has interacted with it still captures audio, or says -why it cannot. `` never announces an audio track that silently carries nothing. - -## Plan - -`@moq/publish`'s `Audio.Capture` builds its own `AudioContext` the moment a track source appears -(`#runTrack`, `js/publish/src/audio/capture.ts:130`, the context at `:143`) and never resumes it. -`@moq/watch`'s decoder handles the same problem with `unlockOnGesture`; the publisher has no -equivalent. - -Observed in `test/interop/clients/js`, which drives Chromium with no autoplay override: handing the -capture its source at page load left the audio rendition without a catalog config indefinitely, and -the fixture only became ready once the source was withheld until after a real click. The fixture -carries that workaround today (`test/interop/clients/js/src/fixture.ts:109`), with a comment pointing -here. - -- Confirm the mechanism before fixing it. The suspended capture context is the likely cause (a - suspended context renders nothing, so the capture worklet never posts the first frame that sets - the captured format), but the observation above is a symptom, not a proof: reproduce it in - `capture.test.ts` first. -- Then either apply what `unlockOnGesture` gives the decoder, or refuse the source with a clear - error rather than announcing a track that will never carry samples. -- The permission-granted path can reach `#runTrack` with no gesture at all (a pre-granted camera on - page load), so a gesture is not something the capture can assume already happened. -- Drop the workaround in `test/interop/clients/js/src/fixture.ts` and let the harness assert the - behavior instead. diff --git a/test/interop/clients/js/src/fixture.ts b/test/interop/clients/js/src/fixture.ts index ab3b21d90e..17f6faef55 100644 --- a/test/interop/clients/js/src/fixture.ts +++ b/test/interop/clients/js/src/fixture.ts @@ -103,20 +103,17 @@ export class Fixture { }); this.#signals.cleanup(() => video.close()); - // Handed to the capture only once this page has user activation. The capture builds its own - // AudioContext the moment a source appears and never resumes it, so one built before the - // first gesture stays suspended and no audio is ever captured. See - // /quest/m1/publish-audio-unlock.md; until that lands, giving it the source late is what keeps - // this fixture measuring the player rather than that gap. - const audioSource = new Signal(undefined); - + // Handed the source at page load, before any gesture, like a pre-granted microphone. Readiness + // then requires an audio catalog after the click, which covers the capture resuming its own + // suspended graph. + // // No sampleRate or channelCount override: the graph already runs at SAMPLE_RATE, and asking // the capture for one channel puts the AudioWorklet behind an explicit Web Audio downmix // whose output drops PCM under load, which reaches the player as gaps of silence. Publishing // the destination node's own format costs nothing here, since the tone is the same in both // channels. const audioCapture = new Publish.Audio.Capture({ - source: audioSource, + source: { track: audioTrack, kind: "music" }, }); this.#signals.cleanup(() => audioCapture.close()); @@ -139,7 +136,6 @@ export class Fixture { oscillator.start(start); this.#runTone(oscillator, start, fault); this.#runPicture(ctx, videoTrack, start, fault); - audioSource.set({ track: audioTrack, kind: "music" }); }; const unlock = () => void this.#audio.resume().catch(() => {}); From 5d6846d8cc3958b42e0b545577bf053e9c77456e Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 24 Sep 2026 18:11:02 -0700 Subject: [PATCH 06/31] feat(net): hide dot-named broadcasts from discovery (moq-lite-07) (#4060) Co-authored-by: Claude Opus 5.5 --- dart/moq/lib/src/aliases.dart | 2 +- dart/moq/lib/src/client.dart | 8 +- dart/moq_ffi/lib/src/moq.dart | 20 ++- doc/concept/moq-lite.md | 33 +++- doc/concept/standard.md | 3 +- doc/lib/c/index.md | 2 +- doc/lib/dart/index.md | 3 +- doc/lib/go/index.md | 2 + doc/lib/js/net.md | 4 +- doc/lib/kt/index.md | 3 +- doc/lib/py/index.md | 2 + doc/lib/rs/moq-net.md | 4 +- doc/lib/swift/index.md | 2 + drafts/draft-lcurley-moq-hidden.md | 139 ++++++++++++++++ drafts/draft-lcurley-moq-lite.md | 21 ++- go/wrapper/origin.go | 3 + js/net/src/announced.ts | 15 ++ js/net/src/connection/accept.ts | 9 +- js/net/src/connection/connect.test.ts | 8 +- js/net/src/connection/connect.ts | 9 +- js/net/src/connection/established.ts | 3 +- js/net/src/connection/forward.ts | 3 +- js/net/src/connection/handshake.ts | 7 +- js/net/src/connection/pool.ts | 4 +- js/net/src/connection/reload.ts | 6 +- js/net/src/ietf/connection.ts | 15 +- js/net/src/ietf/hidden.ts | 31 ++++ js/net/src/ietf/index.ts | 1 + js/net/src/ietf/parameters.ts | 18 +++ js/net/src/ietf/publisher.ts | 26 +-- js/net/src/ietf/subscribe_namespace.ts | 30 +++- js/net/src/ietf/subscriber.ts | 60 +++++-- js/net/src/integration.test.ts | 55 +++++++ js/net/src/internal.ts | 9 ++ js/net/src/lite/announce.test.ts | 9 ++ js/net/src/lite/announce.ts | 15 +- js/net/src/lite/connection.ts | 4 +- js/net/src/lite/publisher.ts | 14 +- js/net/src/lite/subscriber.ts | 24 ++- js/net/src/lite/version.ts | 26 ++- js/net/src/origin.test.ts | 25 +++ js/net/src/origin.ts | 47 ++++-- .../kotlin/dev/moq/Aliases.kt | 2 +- py/moq-rs/moq/client.py | 4 +- py/moq-rs/moq/origin.py | 9 +- quest/m1/README.md | 2 +- quest/m1/epoch.md | 2 +- quest/m1/hidden-broadcasts.md | 58 ------- quest/m1/libmoq-hidden.md | 14 ++ quest/m2/README.md | 3 +- quest/m2/announce-prefix-table.md | 6 +- quest/m2/hidden-exemption.md | 20 +++ rs/moq-ffi/src/origin.rs | 8 +- rs/moq-ffi/src/test.rs | 32 ++++ rs/moq-net/benches/origin.rs | 4 +- rs/moq-net/src/client.rs | 8 +- rs/moq-net/src/fuzz.rs | 1 + rs/moq-net/src/ietf/hidden.rs | 43 +++++ rs/moq-net/src/ietf/mod.rs | 1 + rs/moq-net/src/ietf/parameters.rs | 2 + rs/moq-net/src/ietf/peer.rs | 6 + rs/moq-net/src/ietf/publisher.rs | 61 +++++++- rs/moq-net/src/ietf/session.rs | 7 +- rs/moq-net/src/ietf/subscribe_namespace.rs | 71 ++++++++- rs/moq-net/src/ietf/subscriber.rs | 19 ++- rs/moq-net/src/lite/announce.rs | 46 +++++- rs/moq-net/src/lite/publisher.rs | 23 ++- rs/moq-net/src/lite/subscriber.rs | 4 + rs/moq-net/src/lite/version.rs | 21 ++- rs/moq-net/src/model/origin.rs | 148 +++++++++++++++++- rs/moq-net/src/path/mod.rs | 6 + rs/moq-net/src/server.rs | 11 +- rs/moq-net/src/version.rs | 16 +- rs/moq-relay/src/cluster.rs | 9 +- rs/moq-relay/src/connection.rs | 20 ++- rs/moq-relay/src/uring.rs | 26 +-- rs/moq-relay/src/websocket.rs | 13 +- rs/moq-relay/tests/hidden_cluster.rs | 98 ++++++++++++ rs/moq-relay/tests/smoke.rs | 108 +++++++++++++ rs/moq-stats/src/aggregate.rs | 3 +- rs/moq-stats/src/consume.rs | 2 +- rs/moq-stats/src/produce.rs | 2 +- rs/moq-tokio/src/connect.rs | 2 + rs/moq-tokio/src/listen.rs | 2 + rs/moq-tokio/tests/broadcast.rs | 10 +- swift/Sources/Moq/Origin.swift | 5 +- test/wasm/README.md | 4 +- test/wasm/run.sh | 4 +- 88 files changed, 1439 insertions(+), 251 deletions(-) create mode 100644 drafts/draft-lcurley-moq-hidden.md create mode 100644 js/net/src/ietf/hidden.ts delete mode 100644 quest/m1/hidden-broadcasts.md create mode 100644 quest/m1/libmoq-hidden.md create mode 100644 quest/m2/hidden-exemption.md create mode 100644 rs/moq-net/src/ietf/hidden.rs create mode 100644 rs/moq-relay/tests/hidden_cluster.rs diff --git a/dart/moq/lib/src/aliases.dart b/dart/moq/lib/src/aliases.dart index 1f43856f01..8727301bc0 100644 --- a/dart/moq/lib/src/aliases.dart +++ b/dart/moq/lib/src/aliases.dart @@ -43,7 +43,7 @@ typedef BroadcastRequest = MoqBroadcastRequest; /// A stream of route announcements and retractions under a prefix. typedef AnnounceConsumer = MoqAnnounceConsumer; -/// A literal prefix plus an optional relative pattern for announcement discovery. +/// A literal prefix, an optional relative pattern, and the hidden-path opt-in for announcement discovery. typedef AnnounceConfig = MoqAnnounceConfig; /// A pending wait for a route to cover a specific path. diff --git a/dart/moq/lib/src/client.dart b/dart/moq/lib/src/client.dart index 3a821799d8..714fda2829 100644 --- a/dart/moq/lib/src/client.dart +++ b/dart/moq/lib/src/client.dart @@ -8,9 +8,13 @@ final class AnnounceOptions { /// Pattern relative to [prefix], or null for every path beneath it. final String? filter; - const AnnounceOptions({this.prefix = '', this.filter}); + /// Also list paths with a segment starting with `.` below [prefix]. + final bool hidden; - AnnounceConfig get _ffi => AnnounceConfig(prefix: prefix, filter: filter); + const AnnounceOptions({this.prefix = '', this.filter, this.hidden = false}); + + AnnounceConfig get _ffi => + AnnounceConfig(prefix: prefix, filter: filter, hidden: hidden); } /// Everything [Moq.connect] can be told beyond the URL. diff --git a/dart/moq_ffi/lib/src/moq.dart b/dart/moq_ffi/lib/src/moq.dart index 512cc11793..58fe8391cf 100644 --- a/dart/moq_ffi/lib/src/moq.dart +++ b/dart/moq_ffi/lib/src/moq.dart @@ -1469,7 +1469,12 @@ class FfiConverterMoqVideoProperties { class MoqAnnounceConfig { final String prefix; final String? filter; - MoqAnnounceConfig({this.prefix = '', this.filter = null}); + final bool hidden; + MoqAnnounceConfig({ + this.prefix = '', + this.filter = null, + this.hidden = false, + }); } class FfiConverterMoqAnnounceConfig { @@ -1489,8 +1494,13 @@ class FfiConverterMoqAnnounceConfig { ); final filter = filter_lifted.value; new_offset += filter_lifted.bytesRead; + final hidden_lifted = FfiConverterBool.read( + Uint8List.view(buf.buffer, new_offset), + ); + final hidden = hidden_lifted.value; + new_offset += hidden_lifted.bytesRead; return LiftRetVal( - MoqAnnounceConfig(prefix: prefix, filter: filter), + MoqAnnounceConfig(prefix: prefix, filter: filter, hidden: hidden), new_offset - buf.offsetInBytes, ); } @@ -1499,6 +1509,7 @@ class FfiConverterMoqAnnounceConfig { final total_length = FfiConverterString.allocationSize(value.prefix) + FfiConverterOptionalString.allocationSize(value.filter) + + FfiConverterBool.allocationSize(value.hidden) + 0; final buf = Uint8List(total_length); write(value, buf); @@ -1515,12 +1526,17 @@ class FfiConverterMoqAnnounceConfig { value.filter, Uint8List.view(buf.buffer, new_offset), ); + new_offset += FfiConverterBool.write( + value.hidden, + Uint8List.view(buf.buffer, new_offset), + ); return new_offset - buf.offsetInBytes; } static int allocationSize(MoqAnnounceConfig value) { return FfiConverterString.allocationSize(value.prefix) + FfiConverterOptionalString.allocationSize(value.filter) + + FfiConverterBool.allocationSize(value.hidden) + 0; } } diff --git a/doc/concept/moq-lite.md b/doc/concept/moq-lite.md index 1423170ed6..2235506c52 100644 --- a/doc/concept/moq-lite.md +++ b/doc/concept/moq-lite.md @@ -29,8 +29,8 @@ implement in an afternoon. The wire spec is A dedicated ALPN selects the wire version for moq-lite 03 and newer. The legacy `moql` ALPN negotiates moq-lite 01 or 02 via `SETUP`. In moq-lite 05 and newer, each side also sends a `SETUP` message with its capabilities. -Rust and TypeScript speak moq-lite 01 through 06 and moq-transport drafts -14 through 22. Clients offer `moq-lite-06` first by default. +Rust and TypeScript speak moq-lite 01 through 07 and moq-transport drafts +14 through 22. Clients offer `moq-lite-07` first by default. ## Discovery @@ -63,6 +63,35 @@ stops new requests from resolving through it but leaves subscriptions already in flight alone: each track runs to its own end, the publisher's FIN or reset. moq-transport sessions behave the same when a namespace is withdrawn. +### Hidden broadcasts + +A path segment starting with `.` hides a route from discovery, the way a +dotfile hides from `ls`. A platform publishes its own broadcasts there (relay +stats under `.stats/`, cluster gossip under `.internal/`) without them turning +up in an app that lists everything and plays what it finds. Only segments +below the requested prefix count: listing the root skips `.stats/node`, but +listing `.stats` shows `node`. A `.` elsewhere in a segment (`catalog.pro`) is +part of the name. + +Hiding narrows discovery and nothing else. Subscribing to a hidden path by +name works without asking, and tokens authorize it like any other path. To +list hidden routes too, opt in per announce request: + +```rust +let announced = origin.consume().with_hidden(true).announced(); +``` + +```typescript +const announced = connection.announced(Path.Pattern.all(), { hidden: true }); +``` + +On the wire, moq-lite 07 carries the opt-in on each announce request, and +moq-transport carries it as a `SUBSCRIBE_NAMESPACE` parameter once the peer's +`SETUP` says it understands one ([hidden](/draft/moq-hidden)). An older peer +never opts in, so it never discovers hidden routes. Rust sessions always opt in +on the wire and filter per local reader, so a relay mirrors everything and +each consumer decides. + ## Path patterns Rust's `moq_net::Pattern` and TypeScript's `Path.Pattern` from `@moq/net` diff --git a/doc/concept/standard.md b/doc/concept/standard.md index 4ccc33e257..91691e3ac6 100644 --- a/doc/concept/standard.md +++ b/doc/concept/standard.md @@ -38,7 +38,8 @@ tracks waiting for a group that never arrives. Several project drafts extend the IETF wire without breaking it, since `SETUP` ignores unknown parameters: [cluster](/draft/moq-cluster) routing hop lists, -[solicit](/draft/moq-solicit) to make announcements opt-in, and +[solicit](/draft/moq-solicit) to make announcements opt-in, +[hidden](/draft/moq-hidden) to keep `.`-named namespaces out of discovery, and [probe](/draft/moq-probe) for bandwidth estimation. [moq-e2ee](/draft/moq-e2ee) is not a transport extension: it encrypts application payloads so relays still forward named tracks they cannot read. diff --git a/doc/lib/c/index.md b/doc/lib/c/index.md index f820be3aad..b20f4db7f8 100644 --- a/doc/lib/c/index.md +++ b/doc/lib/c/index.md @@ -42,7 +42,7 @@ and `target/include/moq.h`. - **Server.** `moq_server_listen` binds before it returns (a bad address or certificate fails there) and hands each incoming session to `on_request` as a request handle. Read `moq_session_request_path` and `_query` to route and authenticate, then `moq_session_request_accept` (a session handle, with origins like `moq_session_connect`) or `moq_session_request_reject` with an HTTP-style code (401 and 403 become the protocol's unauthorized close). An accepted session reports `1` once SETUP completes and never reconnects. `moq_server_addr` reports an ephemeral port and `moq_server_fingerprints` the hashes a client pins for a `tls_generate` certificate. `moq_server_close` stops listening; its terminal callback fires once the sockets are released. - **Demand.** A watcher on a published track (`moq_publish_track_demand`, `moq_publish_media_demand`, `moq_encode_video_demand`, `moq_encode_audio_demand`) calls `on_demand` with `MOQ_DEMAND_USED` or `MOQ_DEMAND_UNUSED` right away and again on every change, so an encoder on a battery-powered device runs only while someone is watching. The first call is the current state, so a track that went unused before the watcher existed still reports it. `moq_publish_demand_cancel` stops it; the terminal callback still fires. A container has no single demand and is refused. Demand follows the last real subscriber: an origin that served the track drops its source copy on the unused edge and keeps only the finished groups it already cached warm for 30 seconds, so the cache linger does not delay the unused edge. - **Requests.** `moq_publish_dynamic` serves subscriptions to tracks the broadcast never declared: each arrives as a request handle, read its name with `moq_track_request_name`, then `moq_track_request_accept` (a raw track handle), `moq_track_request_video` / `_audio` (the media handle `moq_publish_video` / `_audio` return), or `moq_track_request_abort` with an application code the subscriber sees. Without a live handler an unknown name is refused. `moq_publish_track_dynamic` does the same for fetches of groups a track no longer has cached, delivered as `moq_group_request_*` (`sequence`, `priority`, `frame_start`); `moq_group_request_accept` starts the producer at `frame_start` so written frames keep their group indices. Register it with `moq_track_request_dynamic` before accepting a track that was itself requested by a fetch, so that pending group survives the transition. Both handlers stop with `moq_publish_dynamic_cancel`. -- **Everything the bindings can do** ([list](/lib/#what-every-binding-can-do)): media publish and consume with the catalog managed for you, raw pixels and PCM with the codec inside (`moq_encode_video`, `moq_encode_audio`, and the `moq_decode_*` mirrors), raw tracks with timestamps and datagrams, JSON snapshot and stream tracks, group fetch, catalog sections, shared video properties, and stalled hints. The three advertising operations are `moq_origin_create_broadcast` (unannounced producer, invisible to everyone), `moq_publish_announce` / `moq_publish_unannounce` (exact-path advertisement), and `moq_origin_dynamic` (a claim over a path prefix and everything beneath it; `""` for everything). A route is a capability, not an inventory. `moq_origin_announced` takes a literal prefix and an optional relative pattern filter; `moq_announce_update.prefix` stays relative to the origin, while `captures` reports what each wildcard matched when `has_captures` is true. +- **Everything the bindings can do** ([list](/lib/#what-every-binding-can-do)): media publish and consume with the catalog managed for you, raw pixels and PCM with the codec inside (`moq_encode_video`, `moq_encode_audio`, and the `moq_decode_*` mirrors), raw tracks with timestamps and datagrams, JSON snapshot and stream tracks, group fetch, catalog sections, shared video properties, and stalled hints. The three advertising operations are `moq_origin_create_broadcast` (unannounced producer, invisible to everyone), `moq_publish_announce` / `moq_publish_unannounce` (exact-path advertisement), and `moq_origin_dynamic` (a claim over a path prefix and everything beneath it; `""` for everything). A route is a capability, not an inventory. `moq_origin_announced` takes a literal prefix and an optional relative pattern filter; `moq_announce_update.prefix` stays relative to the origin, while `captures` reports what each wildcard matched when `has_captures` is true. Paths with a `.`-prefixed segment below the prefix are [hidden](/concept/moq-lite#hidden-broadcasts); name the dot segment in `prefix` to list them. ```c moq_client_config config; diff --git a/doc/lib/dart/index.md b/doc/lib/dart/index.md index c3dfd82dae..acb5cd1b3c 100644 --- a/doc/lib/dart/index.md +++ b/doc/lib/dart/index.md @@ -71,7 +71,8 @@ every path beneath it (`''` for everything; Dart spells the origin method claim should stay advertised, and reject the requests you will not serve. A route is a capability, not an inventory. `announcements(options:)` takes a literal prefix plus an optional relative pattern; `announcement.prefix()` -stays origin-relative and `captures()` reports the wildcard matches. +stays origin-relative and `captures()` reports the wildcard matches. Paths with +a `.`-prefixed segment below the prefix are [hidden](/concept/moq-lite#hidden-broadcasts) unless `hidden: true`. Sessions reconnect with backoff when the transport drops and re-announce local broadcasts. `Moq.connect` and `Server.listen` take a `ConnectOptions` / diff --git a/doc/lib/go/index.md b/doc/lib/go/index.md index 80cfb212d6..7c58997761 100644 --- a/doc/lib/go/index.md +++ b/doc/lib/go/index.md @@ -78,6 +78,8 @@ while the claim should stay advertised, and reject the requests you will not serve. A route is a capability, not an inventory. `Announced(options)` combines a literal prefix with an optional relative pattern; `ann.Prefix()` stays relative to the origin and `ann.Captures()` reports the wildcard matches. +Paths with a `.`-prefixed segment below the prefix are [hidden](/concept/moq-lite#hidden-broadcasts) unless +`Hidden: true`. Every call that can block takes a `context.Context` first. Cancelling it returns `ctx.Err()` promptly and tears the in-flight native work down, so a diff --git a/doc/lib/js/net.md b/doc/lib/js/net.md index 0ea3b7c819..3b6b3c693a 100644 --- a/doc/lib/js/net.md +++ b/doc/lib/js/net.md @@ -104,7 +104,9 @@ match (otherwise `undefined`), `kind` is `"announced"`, `"updated"` (a reprice in place), or `"retracted"`, and `route` carries hops and cost (on a retraction, its last values). The consumer is an async iterable. A prefix is not a broadcast name; the scope filters locally while sessions request its -literal head on the wire. +literal head on the wire. Paths with a `.`-prefixed segment below that head +are [hidden](/concept/moq-lite#hidden-broadcasts) unless `announced(scope, { hidden: true })` opts in; +`broadcasts(scope, { hidden: true })` takes the same option. Examples in [`js/net/examples/`](https://github.com/moq-dev/moq/tree/main/js/net/examples). diff --git a/doc/lib/kt/index.md b/doc/lib/kt/index.md index 21c2b7953a..3013ca39a2 100644 --- a/doc/lib/kt/index.md +++ b/doc/lib/kt/index.md @@ -59,7 +59,8 @@ path beneath it (`""` for everything). Hold the returned `OriginDynamic` while the claim should stay advertised, and reject the requests you will not serve. A route is a capability, not an inventory. `announcements(config)` takes a literal prefix plus an optional relative pattern; `announcement.prefix()` -stays origin-relative and `captures()` reports the wildcard matches. +stays origin-relative and `captures()` reports the wildcard matches. Paths with +a `.`-prefixed segment below the prefix are [hidden](/concept/moq-lite#hidden-broadcasts) unless `hidden = true`. Sessions reconnect with backoff when the transport drops and re-announce local broadcasts. `moq.epoch()` counts the connections, 1 on the first, pairing with diff --git a/doc/lib/py/index.md b/doc/lib/py/index.md index 94b79d7646..46890352e2 100644 --- a/doc/lib/py/index.md +++ b/doc/lib/py/index.md @@ -79,6 +79,8 @@ advertised, and reject the requests you will not serve. A route is a capability, not an inventory. `announced(prefix, filter=...)` combines a literal root with an optional relative pattern; each announcement `.prefix` stays relative to the origin and `.captures` reports what the pattern wildcards matched. +Paths with a `.`-prefixed segment below the prefix are [hidden](/concept/moq-lite#hidden-broadcasts) unless +`hidden=True`. Sessions reconnect with backoff when the transport drops and re-announce local broadcasts. `session.epoch()` counts the connections, 1 on the first, pairing diff --git a/doc/lib/rs/moq-net.md b/doc/lib/rs/moq-net.md index 0a3dd1b1bc..2f56c399b6 100644 --- a/doc/lib/rs/moq-net.md +++ b/doc/lib/rs/moq-net.md @@ -135,7 +135,9 @@ most specific matching scope member's wildcards stood for when the prefix pins them, and `route` carries hops and cost (on a retraction, its last values). The consumer is also a `futures::Stream`. A prefix is not a broadcast name; sessions request each scope member's literal head and filter -locally. +locally. Routes with a `.`-prefixed segment below that head are [hidden](/concept/moq-lite#hidden-broadcasts) +unless `with_hidden(true)` opts the consumer in. Sessions always ask the peer +for hidden routes, so each local consumer decides. ## Limiting reads diff --git a/doc/lib/swift/index.md b/doc/lib/swift/index.md index 9a205d8405..a8ffa59922 100644 --- a/doc/lib/swift/index.md +++ b/doc/lib/swift/index.md @@ -64,6 +64,8 @@ claim should stay advertised, and reject the requests you will not serve. A route is a capability, not an inventory. `announced(prefix:filter:)` combines a literal root with an optional relative pattern; `announcement.prefix` stays relative to the origin and `captures` reports what the wildcards matched. +Paths with a `.`-prefixed segment below the prefix are [hidden](/concept/moq-lite#hidden-broadcasts) unless +`hidden: true`. For a self-signed relay on your own test network, `try client.setTlsVerify(false)` accepts any certificate; prefer `setTlsRoots` or a fingerprint anywhere else. diff --git a/drafts/draft-lcurley-moq-hidden.md b/drafts/draft-lcurley-moq-hidden.md new file mode 100644 index 0000000000..ee1c14720e --- /dev/null +++ b/drafts/draft-lcurley-moq-hidden.md @@ -0,0 +1,139 @@ +--- +title: "MoQ Hidden Extension" +abbrev: "moq-hidden" +category: info + +docname: draft-lcurley-moq-hidden-latest +submissiontype: IETF # also: "independent", "editorial", "IAB", or "IRTF" +number: +date: +v: 3 +area: wit +workgroup: moq + +author: + - + fullname: Luke Curley + email: kixelated@gmail.com + +normative: + moqt: I-D.ietf-moq-transport + +informative: + +--- abstract + +This document defines an extension for MoQ Transport {{moqt}} that hides namespaces from discovery. +A namespace with a field starting with `.` below the prefix a subscriber asked for is left out of the advertisements it receives, unless its SUBSCRIBE_NAMESPACE opts in. +A platform can then add internal namespaces, such as statistics, without them turning up in applications that list everything and use what they find. + +--- note_Note_to_Readers + +This document was generated by an AI model from the implementation at [github.com/moq-dev/moq](https://github.com/moq-dev/moq) and is maintained alongside it. +Submit an [issue](https://github.com/moq-dev/moq/issues) or [PR](https://github.com/moq-dev/moq/pulls) if this spec sucks and you want to fix anything. + +--- middle + +# Conventions and Definitions +{::boilerplate bcp14-tagged} + +An endpoint **advertises** a namespace by sending PUBLISH_NAMESPACE, or NAMESPACE in response to a SUBSCRIBE_NAMESPACE. + +A namespace is **hidden** from a subscription when one of its fields beyond the subscription's Track Namespace Prefix starts with the byte 0x2E (`.`). +A field inside the prefix never hides anything, so a prefix that names the hidden field itself lists what is under it, and a namespace at or above the prefix has no field beyond it. +Only the first byte counts: `catalog.v2` is not hidden. + + +# Introduction +Discovery in {{moqt}} is all or nothing: a subscriber that asks for a prefix is told every namespace beneath it. +An application that asks for the empty prefix and plays what it finds breaks the moment its platform publishes anything else under the same root, like a relay's own statistics or internal routing state. + +This extension reserves a leading `.` for such namespaces, as file systems do for hidden files. +A hidden namespace is still published, routed, and subscribed to like any other; it is only left out of discovery by default. +A subscriber that wants hidden namespaces too says so on the SUBSCRIBE_NAMESPACE that would list them. + +The name alone decides: there is no publisher-side flag, so a namespace cannot be hidden from one subscriber and listed to another under the same prefix. + + +# Setup Negotiation + +An endpoint declares that it understands the HIDDEN parameter ({{parameter}}) with the following Setup Option ({{moqt}} Section 10.3): + +~~~ +HIDDEN Setup Option { + Option Key (vi64) = 0x40B5C + Option Value (vi64) = 1 +} +~~~ + +A receiver MUST ignore the value. +An endpoint MUST NOT send the HIDDEN parameter to a peer that did not declare this option, because an unknown parameter fails decoding. +A subscriber that wants hidden namespaces therefore waits for the peer's SETUP before sending SUBSCRIBE_NAMESPACE. + +The rest of this extension applies whether or not the peer declared the option: a peer that never heard of it never opts in, so it is never told about hidden namespaces. + + +# Opting In {#parameter} + +A subscriber opts in to hidden namespaces with the following parameter on SUBSCRIBE_NAMESPACE: + +~~~ +HIDDEN Parameter { + Type (vi64) = 0x40B5E + Value (vi64) = 0 or 1 +} +~~~ + +A value of 1 opts in; 0 or an absent parameter does not. +A receiver MUST close the session with a PROTOCOL_VIOLATION on any other value. + + +# Advertising {#advertising} + +A publisher SHOULD NOT advertise a hidden namespace in response to a SUBSCRIBE_NAMESPACE that did not opt in. +Hiding is a convenience for discovery, not access control, so a publisher MAY treat a subscriber it trusts, such as another relay in its own cluster, as opted in. + +An unsolicited PUBLISH_NAMESPACE answers no prefix, so it is measured against the empty one: a publisher SHOULD NOT send one for a hidden namespace. +When unsolicited advertisements are live, a SUBSCRIBE_NAMESPACE is answered with only the namespaces they left out, which is to say those hidden from the empty prefix, that the subscription may see. +That covers both an opt-in and a prefix that names a hidden field itself, and no namespace is advertised twice. + +Hiding narrows discovery and nothing else. +A SUBSCRIBE, FETCH, or TRACK_STATUS for a track in a hidden namespace is served exactly as it would be without this extension. + + +# Security Considerations + +A hidden namespace is not a secret. +Anyone who learns its name can subscribe to it, and a subscriber can opt in at will, so a publisher MUST apply the same authorization to hidden namespaces as to any other. + + +# IANA Considerations + +This document requests the following registrations. +High, distinctive values are requested to avoid the low ranges reserved by {{moqt}} and to minimize collisions with provisional registrations by other extensions. + +## MOQT Setup Options + +This document requests one registration in the "MOQT Setup Options" registry ({{moqt}} Section 15.4), whose policy is Specification Required. + +| Value | Name | Reference | +|:--------|:-------|:--------------| +| 0x40B5C | HIDDEN | This Document | + +## MOQT Message Parameters + +This document requests one registration in the "MOQT Message Parameters" registry ({{moqt}} Section 15.7). + +| Value | Name | Carried In | Reference | +|:--------|:-------|:--------------------|:--------------| +| 0x40B5E | HIDDEN | SUBSCRIBE_NAMESPACE | This Document | + +Both values are even, so each is a bare varint. + + +--- back + +# Acknowledgments +{:numbered="false"} + +This document was drafted with the assistance of Claude, an AI assistant by Anthropic. diff --git a/drafts/draft-lcurley-moq-lite.md b/drafts/draft-lcurley-moq-lite.md index dcac539e93..8c5c464c08 100644 --- a/drafts/draft-lcurley-moq-lite.md +++ b/drafts/draft-lcurley-moq-lite.md @@ -94,7 +94,7 @@ A Session consists of a connection between a client and a server. There is currently no P2P support within QUIC so it's out of scope for moq-lite. The moq-lite version identifier is `moq-lite-xx` where `xx` is the two-digit draft version. -The identifier for this draft is `moq-lite-06`. +The identifier for this draft is `moq-lite-07`. For bare QUIC, this is negotiated as an ALPN token during the QUIC handshake. For WebTransport over HTTP/3, the QUIC ALPN remains `h3` and the moq-lite version is advertised via the `WT-Available-Protocols` and `WT-Protocol` CONNECT headers. @@ -383,6 +383,15 @@ A route covers a path when its prefix is a leading run of the path's segments; m A publisher answering a request stream presents each of its routes clamped to the intersection with the requested prefix: a route above the request's prefix appears as the request prefix itself (an empty suffix), which is exactly the covered set the subscriber may see. There MAY be multiple Announce Streams, potentially containing overlapping prefixes, that get their own ANNOUNCE_OK + announcements. +#### Hidden Paths {#hidden} +A route is hidden from a request when a segment of its path below the requested prefix starts with `.` (0x2E). +A segment inside the prefix never hides anything, so a request that names the hidden segment itself (`.stats`) lists what is under it, and a route at or above the prefix has no segment below it. +Only the first byte counts: `catalog.v2` is not hidden. + +A publisher SHOULD NOT announce a hidden route unless the ANNOUNCE_REQUEST set `Hidden`. +Hiding is a convenience for discovery, not access control: a publisher MAY treat a subscriber it trusts, such as another relay in its own cluster, as opted in, and MUST authorize hidden paths like any other. +SUBSCRIBE, FETCH, and TRACK resolve a hidden path exactly as any other. + #### Routing {#routing} Each advertisement carries the path of Hop IDs it traversed and an accumulated Warm and Cold Route Cost (see [ANNOUNCE_START](#announce-start)), which relays use to build a loop-free mesh. @@ -801,12 +810,17 @@ A subscriber sends an ANNOUNCE_REQUEST message to indicate it wants to receive a ANNOUNCE_REQUEST Message { Message Length (i) Broadcast Path Prefix (s), + Hidden (8), } ~~~ **Broadcast Path Prefix**: Indicate interest for any broadcasts with a path that starts with this prefix. +**Hidden**: +1 to also receive hidden routes (see [Hidden Paths](#hidden)), 0 otherwise. +Any other value is a PROTOCOL_VIOLATION. + The publisher MUST respond with an ANNOUNCE_OK message followed by ANNOUNCE_START messages for any matching routes, followed by ANNOUNCE_START, ANNOUNCE_END, and ANNOUNCE_UPDATE messages for any future updates, subject to [Routing](#routing). Implementations SHOULD consider reasonable limits on the number of matching broadcasts to prevent resource exhaustion. @@ -1314,6 +1328,11 @@ The `Message Length` describes the payload size on the wire. # Appendix A: Changelog +## moq-lite-07 + +- Assigned `moq-lite-07` as this draft's protocol identifier. +- Hid routes with a `.`-prefixed segment below the requested prefix from announce discovery, and added the ANNOUNCE_REQUEST `Hidden` field to opt in. + ## moq-lite-06 - Assigned `moq-lite-06` as this draft's protocol identifier. diff --git a/go/wrapper/origin.go b/go/wrapper/origin.go index cb5c1915f0..67eb77e040 100644 --- a/go/wrapper/origin.go +++ b/go/wrapper/origin.go @@ -125,6 +125,8 @@ type AnnounceOptions struct { Prefix string // Filter is a pattern relative to Prefix. Nil matches every path beneath it. Filter *string + // Hidden also lists paths with a segment starting with "." below Prefix. + Hidden bool } // Announced streams routes under a literal prefix matching an optional pattern filter. @@ -132,6 +134,7 @@ func (o *OriginConsumer) Announced(options AnnounceOptions) (*AnnounceConsumer, inner, err := o.inner.Announced(ffi.MoqAnnounceConfig{ Prefix: options.Prefix, Filter: options.Filter, + Hidden: options.Hidden, }) if err != nil { return nil, err diff --git a/js/net/src/announced.ts b/js/net/src/announced.ts index 8c0f25527f..01056ae153 100644 --- a/js/net/src/announced.ts +++ b/js/net/src/announced.ts @@ -38,6 +38,21 @@ export interface Update { route: Route; } +/** + * Options for an announcement stream. + * + * @public + */ +export interface Options { + /** + * Also report hidden routes: those with a path segment starting with `.` below the + * scope's literal head. Hidden routes are left out by default, so a platform can add + * `.`-named broadcasts (stats, internal routes) without them turning up in apps that + * list everything. Subscribing to a hidden path by name works either way. + */ + hidden?: boolean; +} + /** Whether a route covers the path after an update of this {@link Kind}. */ export function isActive(kind: Kind): boolean { return kind !== "retracted"; diff --git a/js/net/src/connection/accept.ts b/js/net/src/connection/accept.ts index c048ec5768..0430892187 100644 --- a/js/net/src/connection/accept.ts +++ b/js/net/src/connection/accept.ts @@ -87,6 +87,8 @@ async function acceptInner( return acceptSetup(transport, url, Ietf.Version.DRAFT_16, wiring); } else if (protocol === Ietf.ALPN.DRAFT_15) { return acceptSetup(transport, url, Ietf.Version.DRAFT_15, wiring); + } else if (protocol === Lite.ALPN_07) { + return new Lite.Connection({ url, quic: transport, version: Lite.Version.DRAFT_07, ...wiring }); } else if (protocol === Lite.ALPN_06) { return new Lite.Connection({ url, quic: transport, version: Lite.Version.DRAFT_06, ...wiring }); } else if (protocol === Lite.ALPN_05) { @@ -112,7 +114,7 @@ async function acceptAlpn( version: Ietf.IetfVersion, wiring: SessionProps, ): Promise { - const { control, solicit, cluster } = await exchangeSetup(transport, version, "moq-lite-js"); + const { control, solicit, hidden, cluster } = await exchangeSetup(transport, version, "moq-lite-js"); return new Ietf.Connection({ ...wiring, @@ -121,6 +123,7 @@ async function acceptAlpn( quic: transport, control, solicit, + hidden, cluster, // v17+ uses NativeSession which manages its own request IDs; maxRequestId is unused. maxRequestId: 0n, @@ -156,6 +159,7 @@ async function acceptSetup( params.setVarint(Ietf.SetupOption.MaxRequestId, 42069n); params.setBytes(Ietf.SetupOption.Implementation, encoder.encode("moq-lite-js")); Ietf.solicitIntoSetup(params); + Ietf.hiddenIntoSetup(params); const server = new Ietf.ServerSetup({ version, parameters: params }); await server.encode(stream.writer, version); @@ -171,6 +175,7 @@ async function acceptSetup( maxRequestId, version, solicit: Ietf.solicitFromSetup(client.parameters), + hidden: Ietf.hiddenFromSetup(client.parameters), }); } @@ -214,6 +219,7 @@ async function acceptNegotiated( params.setVarint(Ietf.SetupOption.MaxRequestId, 42069n); params.setBytes(Ietf.SetupOption.Implementation, encoder.encode("moq-lite-js")); Ietf.solicitIntoSetup(params); + Ietf.hiddenIntoSetup(params); const server = new Ietf.ServerSetup({ version: selectedVersion, parameters: params }); await server.encode(stream.writer, setupVersion); @@ -237,6 +243,7 @@ async function acceptNegotiated( maxRequestId, version: selectedVersion as Ietf.IetfVersion, solicit: Ietf.solicitFromSetup(client.parameters), + hidden: Ietf.hiddenFromSetup(client.parameters), }); } else { throw new Error(`unsupported version: ${selectedVersion.toString(16)}`); diff --git a/js/net/src/connection/connect.test.ts b/js/net/src/connection/connect.test.ts index 22dc73115b..02b8e6d175 100644 --- a/js/net/src/connection/connect.test.ts +++ b/js/net/src/connection/connect.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { ALPN_05, ALPN_06 } from "../lite/version.ts"; +import { ALPN_05, ALPN_07 } from "../lite/version.ts"; import { createMockTransportPair } from "../mock.ts"; import { type ConnectProps, connect as connectSession } from "./connect.ts"; @@ -54,8 +54,8 @@ function stubWebTransport(transport: WebTransport): () => void { }; } -test("WebTransport offers lite-06 first by default", async () => { - const pair = createMockTransportPair(ALPN_06); +test("WebTransport offers lite-07 first by default", async () => { + const pair = createMockTransportPair(ALPN_07); const original = globalThis.WebTransport; let protocols: string[] | undefined; @@ -72,7 +72,7 @@ test("WebTransport offers lite-06 first by default", async () => { globalThis.WebTransport = original; } - expect(protocols?.[0]).toBe("moq-lite-06"); + expect(protocols?.[0]).toBe("moq-lite-07"); }); test("connect logs the relay URL without its credentials", async () => { diff --git a/js/net/src/connection/connect.ts b/js/net/src/connection/connect.ts index 185cd4fecc..b0fcbc62b1 100644 --- a/js/net/src/connection/connect.ts +++ b/js/net/src/connection/connect.ts @@ -281,6 +281,8 @@ async function negotiate(url: URL, session: WebTransport, wiring: SessionProps): setupVersion = Ietf.Version.DRAFT_16; } else if (protocol === Ietf.ALPN.DRAFT_15) { setupVersion = Ietf.Version.DRAFT_15; + } else if (protocol === Lite.ALPN_07) { + return new Lite.Connection({ url, quic: session, version: Lite.Version.DRAFT_07, ...wiring }); } else if (protocol === Lite.ALPN_06) { return new Lite.Connection({ url, quic: session, version: Lite.Version.DRAFT_06, ...wiring }); } else if (protocol === Lite.ALPN_05) { @@ -304,6 +306,7 @@ async function negotiate(url: URL, session: WebTransport, wiring: SessionProps): params.setVarint(Ietf.SetupOption.MaxRequestId, 42069n); params.setBytes(Ietf.SetupOption.Implementation, encoder.encode("moq-lite-js")); Ietf.solicitIntoSetup(params); + Ietf.hiddenIntoSetup(params); const client = new Ietf.ClientSetup({ versions: @@ -342,6 +345,7 @@ async function negotiate(url: URL, session: WebTransport, wiring: SessionProps): maxRequestId, version: server.version as Ietf.IetfVersion, solicit: Ietf.solicitFromSetup(server.parameters), + hidden: Ietf.hiddenFromSetup(server.parameters), }); } else { throw new Error(`unsupported server version: ${server.version.toString()}`); @@ -358,7 +362,7 @@ async function handshakeAlpn( version: Ietf.IetfVersion, wiring: SessionProps, ): Promise { - const { control, solicit, cluster } = await exchangeSetup(session, version, "moq-lite-js"); + const { control, solicit, hidden, cluster } = await exchangeSetup(session, version, "moq-lite-js"); return new Ietf.Connection({ ...wiring, @@ -367,6 +371,7 @@ async function handshakeAlpn( quic: session, control, solicit, + hidden, cluster, // v17+ uses NativeSession which manages its own request IDs; maxRequestId is unused. maxRequestId: 0n, @@ -433,6 +438,7 @@ async function connectWebTransport( allowPooling: false, congestionControl: "low-latency", protocols: [ + Lite.ALPN_07, Lite.ALPN_06, Lite.ALPN_05, Lite.ALPN_04, @@ -519,6 +525,7 @@ async function connectWebSocket(url: URL, delay: number, cancel: Promise): // advertises every QMux draft it knows about and the server picks one. // Insertion order is the negotiation preference on the wire. const versions = { + [Lite.ALPN_07]: null, [Lite.ALPN_06]: null, [Lite.ALPN_05]: null, [Lite.ALPN_04]: null, diff --git a/js/net/src/connection/established.ts b/js/net/src/connection/established.ts index c8d7278217..f796892762 100644 --- a/js/net/src/connection/established.ts +++ b/js/net/src/connection/established.ts @@ -37,8 +37,9 @@ export interface Established { * Subscribe to broadcast announcements matching `scope`, any pattern (`foo/**` * for a subtree, `room/* /chat` for each room's chat, default `**`). Paths are * relative to the session; captures report what the scope's wildcards stood for. + * Hidden routes are left out unless `options.hidden` opts in. */ - announced(scope?: Path.Pattern): announce.Consumer; + announced(scope?: Path.Pattern, options?: announce.Options): announce.Consumer; /** * Snapshot the transport's counters, querying it fresh on each call. diff --git a/js/net/src/connection/forward.ts b/js/net/src/connection/forward.ts index ff977a61c1..a2c50a1d26 100644 --- a/js/net/src/connection/forward.ts +++ b/js/net/src/connection/forward.ts @@ -47,7 +47,8 @@ export function forwardAnnounced(conn: Established, origin: OriginProducer): voi return; } - const announced = conn.announced(); + // Hidden routes are mirrored too; each local reader opts in on its own. + const announced = conn.announced(undefined, { hidden: true }); const inserted = new Map(); // End the stream the moment the session closes rather than waiting for the wire to diff --git a/js/net/src/connection/handshake.ts b/js/net/src/connection/handshake.ts index ae7154674e..05cd214c8b 100644 --- a/js/net/src/connection/handshake.ts +++ b/js/net/src/connection/handshake.ts @@ -19,11 +19,12 @@ export async function exchangeSetup( transport: WebTransport, version: Ietf.IetfVersion, implementation: string, -): Promise<{ control: Stream; solicit: boolean | undefined; cluster: Ietf.Cluster.Hops }> { +): Promise<{ control: Stream; solicit: boolean | undefined; hidden: boolean; cluster: Ietf.Cluster.Hops }> { const encoder = new TextEncoder(); const params = new Ietf.SetupOptions(); params.setBytes(Ietf.SetupOption.Implementation, encoder.encode(implementation)); Ietf.solicitIntoSetup(params); + Ietf.hiddenIntoSetup(params); // One id per session, like the moq-lite connection: nothing in this process forwards // between sessions, so there is nothing for a shared id to detect. @@ -40,6 +41,7 @@ export async function exchangeSetup( return { control: new Stream({ writer, reader: received.reader }), solicit: received.solicit, + hidden: received.hidden, cluster: { self, peer: received.cluster }, }; } @@ -56,7 +58,7 @@ async function sendSetup(transport: WebTransport, version: Ietf.IetfVersion, set async function receiveSetup( transport: WebTransport, version: Ietf.IetfVersion, -): Promise<{ reader: Reader; solicit: boolean | undefined; cluster: Hop | undefined }> { +): Promise<{ reader: Reader; solicit: boolean | undefined; hidden: boolean; cluster: Hop | undefined }> { const uniReader = transport.incomingUnidirectionalStreams.getReader() as ReadableStreamDefaultReader< ReadableStream >; @@ -75,6 +77,7 @@ async function receiveSetup( return { reader, solicit: Ietf.solicitFromSetup(setup.parameters), + hidden: Ietf.hiddenFromSetup(setup.parameters), cluster: Ietf.Cluster.fromSetup(setup.parameters, version), }; } diff --git a/js/net/src/connection/pool.ts b/js/net/src/connection/pool.ts index 20e4d5d18e..1957d4dc1a 100644 --- a/js/net/src/connection/pool.ts +++ b/js/net/src/connection/pool.ts @@ -265,7 +265,7 @@ export class Connection { * and URL switches: a switch retracts everything from * the old relay's origin, then the new one's arrivals stream in. */ - announced(scope: Path.Pattern = Path.Pattern.all()): Announce.Consumer { + announced(scope: Path.Pattern = Path.Pattern.all(), options?: Announce.Options): Announce.Consumer { const producer = new Announce.Producer(); const consumer = producer.consume(); @@ -283,7 +283,7 @@ export class Connection { const origin = effect.get(this.#origin); if (!origin) return; - const upstream = origin.announced(scope); + const upstream = origin.announced(scope, options); effect.cleanup(() => upstream.close()); // Track what this origin announced so a URL switch retracts it; the last diff --git a/js/net/src/connection/reload.ts b/js/net/src/connection/reload.ts index 5063fbff8d..df47c29a18 100644 --- a/js/net/src/connection/reload.ts +++ b/js/net/src/connection/reload.ts @@ -443,10 +443,10 @@ export class Reload { * * Stays empty while the relay lacks {@link Established.discovery}. */ - announced(scope: Path.Pattern = Path.Pattern.all()): Announce.Consumer { + announced(scope: Path.Pattern = Path.Pattern.all(), options?: Announce.Options): Announce.Consumer { // With a consume origin the table already spans reconnects (the forwarder retracts // a dead session's entries), so its stream is the same thing with less machinery. - if (this.consume) return this.consume.announced(scope); + if (this.consume) return this.consume.announced(scope, options); const producer = new Announce.Producer(); const consumer = producer.consume(); @@ -460,7 +460,7 @@ export class Reload { // consumer empty rather than opening a subscription that can't be answered. if (!conn.discovery) return; - const upstream = conn.announced(scope); + const upstream = conn.announced(scope, options); effect.cleanup(() => upstream.close()); // Track what this connection announced so we can retract it if the connection diff --git a/js/net/src/ietf/connection.ts b/js/net/src/ietf/connection.ts index d4d24eb416..e1d22f9bf8 100644 --- a/js/net/src/ietf/connection.ts +++ b/js/net/src/ietf/connection.ts @@ -85,6 +85,7 @@ export class Connection implements Established { discovery = true, publish, solicit, + hidden = false, cluster, }: { url: URL; @@ -102,6 +103,8 @@ export class Connection implements Established { * nothing, which is the one case where announcing at us unasked is not a bug. */ solicit?: boolean; + /** Whether the peer understands the HIDDEN parameter (MoQ Hidden). */ + hidden?: boolean; /** * The Hop IDs this session declared (MoQ Cluster). `undefined` on a version that * cannot negotiate the extension, as is a `peer` the peer never declared. @@ -138,7 +141,7 @@ export class Connection implements Established { }); this.#solicit = solicit; this.#cluster = cluster; - this.#subscriber = new Subscriber({ session: this.#session, cluster }); + this.#subscriber = new Subscriber({ session: this.#session, cluster, hidden }); registerWire(this, { consume: (path) => this.#subscriber.consume(path) }); void this.#run(); @@ -179,8 +182,8 @@ export class Connection implements Established { } /** Gets an announced reader for `scope`; see {@link Established.announced}. */ - announced(scope?: Path.Pattern): announce.Consumer { - return this.#subscriber.announced(scope); + announced(scope?: Path.Pattern, options?: announce.Options): announce.Consumer { + return this.#subscriber.announced(scope, options); } /** @@ -219,7 +222,11 @@ export class Connection implements Established { } case SubscribeNamespaceLegacy.id: { const legacy = await SubscribeNamespaceLegacy.decode(stream.reader, this.#session.version); - const msg = new SubscribeNamespace({ requestId: legacy.requestId, namespace: legacy.namespace }); + const msg = new SubscribeNamespace({ + requestId: legacy.requestId, + namespace: legacy.namespace, + hidden: legacy.hidden, + }); await this.#publisher.runSubscribeNamespace(msg, stream); break; } diff --git a/js/net/src/ietf/hidden.ts b/js/net/src/ietf/hidden.ts new file mode 100644 index 0000000000..cd22aff02f --- /dev/null +++ b/js/net/src/ietf/hidden.ts @@ -0,0 +1,31 @@ +import { SetupOption, type SetupOptions } from "./parameters.ts"; + +/** + * The MoQ Hidden extension (draft-lcurley-moq-hidden-00). + * + * A namespace with a field starting with `.` below the prefix a subscription asked for is + * left out of discovery unless the SUBSCRIBE_NAMESPACE opts in with the HIDDEN parameter. + * An unknown parameter fails decoding, so the parameter is only sent to a peer whose SETUP + * carried the HIDDEN option. + * + * @module + * @internal + */ + +/** + * Whether the peer understands the HIDDEN parameter. + * + * @internal + */ +export function hiddenFromSetup(params: SetupOptions): boolean { + return params.getVarint(SetupOption.Hidden) !== undefined; +} + +/** + * Declare that we understand the HIDDEN parameter. + * + * @internal + */ +export function hiddenIntoSetup(params: SetupOptions) { + params.setVarint(SetupOption.Hidden, 1n); +} diff --git a/js/net/src/ietf/index.ts b/js/net/src/ietf/index.ts index 9fd3ec8474..e1ebb8199d 100644 --- a/js/net/src/ietf/index.ts +++ b/js/net/src/ietf/index.ts @@ -4,6 +4,7 @@ export * from "./connection.ts"; export * from "./control.ts"; export * from "./fetch.ts"; export * from "./goaway.ts"; +export * from "./hidden.ts"; export * from "./object.ts"; export * from "./parameters.ts"; export * from "./publish.ts"; diff --git a/js/net/src/ietf/parameters.ts b/js/net/src/ietf/parameters.ts index 5d9762b6da..f2a6a3120b 100644 --- a/js/net/src/ietf/parameters.ts +++ b/js/net/src/ietf/parameters.ts @@ -16,6 +16,8 @@ export const SetupOption = { RelayCost: 0x40b56n, /** SOLICIT, from the MoQ Solicit extension. See `solicit.ts`. */ Solicit: 0x40b5an, + /** HIDDEN, from the MoQ Hidden extension. See `hidden.ts`. */ + Hidden: 0x40b5cn, } as const; /// Setup Options — used in SETUP messages. @@ -202,6 +204,8 @@ const MSG_PARAM_SUBSCRIBER_PRIORITY = 0x20n; const MSG_PARAM_GROUP_ORDER = 0x22n; /// ROUTE_COST, from the MoQ Cluster extension. See `cluster.ts`. const MSG_PARAM_ROUTE_COST = 0x40b58n; +/// HIDDEN, from the MoQ Hidden extension. See `hidden.ts`. +const MSG_PARAM_HIDDEN = 0x40b5en; // Bytes parameter IDs (odd) const MSG_PARAM_LARGEST_OBJECT = 0x09n; @@ -225,6 +229,7 @@ function getMessageParamKind(id: bigint): MessageParamKind { case MSG_PARAM_MAX_CACHE_DURATION: case MSG_PARAM_EXPIRES: case MSG_PARAM_ROUTE_COST: + case MSG_PARAM_HIDDEN: return "varint"; case MSG_PARAM_PUBLISHER_PRIORITY: case MSG_PARAM_SUBSCRIBER_PRIORITY: @@ -341,6 +346,19 @@ export class Parameters { this.vars.set(MSG_PARAM_MAX_CACHE_DURATION, v); } + /** HIDDEN (MoQ Hidden): also advertise hidden namespaces. Absent and 0 both mean no. */ + get hidden(): boolean { + const v = this.vars.get(MSG_PARAM_HIDDEN); + if (v === undefined || v === 0n) return false; + if (v === 1n) return true; + throw new Error(`invalid HIDDEN parameter: ${v}`); + } + + set hidden(v: boolean) { + if (v) this.vars.set(MSG_PARAM_HIDDEN, 1n); + else this.vars.delete(MSG_PARAM_HIDDEN); + } + // --- Bytes accessors --- get largest(): MessageLocation | undefined { diff --git a/js/net/src/ietf/publisher.ts b/js/net/src/ietf/publisher.ts index fc358b342a..17384e9b49 100644 --- a/js/net/src/ietf/publisher.ts +++ b/js/net/src/ietf/publisher.ts @@ -3,7 +3,7 @@ import type * as broadcast from "../broadcast.ts"; import { controlTimeout, error, reason, StreamCode, StreamError } from "../error.ts"; import type * as group from "../group.ts"; import { type Route, routesEqual } from "../hop.ts"; -import { hooks } from "../internal.ts"; +import { hiddenBelow, hooks } from "../internal.ts"; import type { Consumer as OriginConsumer } from "../origin.ts"; import * as Path from "../path.ts"; import { type Stream, Writer } from "../stream.ts"; @@ -632,10 +632,10 @@ export class Publisher { /** * Handles an incoming SUBSCRIBE_NAMESPACE on a bidi stream. * - * This carries the advertisements only when the peer asked to be told on request - * (MoQ Solicit); otherwise {@link runPublishNamespaces} has already announced - * everything and repeating it here would leave the peer holding two sources for one - * broadcast. Draft-16+ streams Namespace entries inline; draft-14/15 predate those + * This carries the advertisements when the peer asked to be told on request (MoQ + * Solicit); otherwise {@link runPublishNamespaces} has already announced everything + * visible and repeating it here would leave the peer holding two sources for one + * broadcast, so only the hidden namespaces it may see ride here (MoQ Hidden). Draft-16+ streams Namespace entries inline; draft-14/15 predate those * messages, so each advertisement is a PUBLISH_NAMESPACE request of its own. * * @internal @@ -662,12 +662,12 @@ export class Publisher { await ok.encode(stream.writer, version); } - if (!this.#requiresSolicitation) { - // Already announced, unasked. Hold the stream open until the peer is done. - await stream.reader.closed; - stream.close(); - return; - } + // Hidden namespaces are left out unless the peer opted in (MoQ Hidden). Unless the + // peer asked to be told only on request, it has already heard everything visible + // from the empty prefix unasked, so this stream carries only what that hid. + const carries = (covered: Path.Valid) => + (msg.hidden || !hiddenBelow(prefix, covered)) && + (this.#requiresSolicitation || hiddenBelow(Path.empty(), covered)); // Reports whether the peer now holds the namespace: an inline entry always // lands, but a PUBLISH_NAMESPACE request can be declined. @@ -718,7 +718,7 @@ export class Publisher { const updated = new Map(); for (const [covered, snap] of advertised) { const suffix = Path.stripPrefix(prefix, covered); - if (suffix === null) continue; + if (suffix === null || !carries(covered)) continue; updated.set(suffix, snap); } @@ -847,6 +847,8 @@ export class Publisher { const updated = new Map(); for (const [covered, snap] of advertised) { + // Unasked, a hidden namespace stays off the wire (MoQ Hidden). + if (hiddenBelow(Path.empty(), covered)) continue; updated.set(covered, snap); } diff --git a/js/net/src/ietf/subscribe_namespace.ts b/js/net/src/ietf/subscribe_namespace.ts index 6d7944726f..25f5054a71 100644 --- a/js/net/src/ietf/subscribe_namespace.ts +++ b/js/net/src/ietf/subscribe_namespace.ts @@ -30,10 +30,17 @@ export class SubscribeNamespace { namespace: Path.Valid; requestId: bigint; + /** MoQ Hidden: also advertise hidden namespaces. Only sent to a peer that declared it. */ + hidden: boolean; - constructor({ namespace, requestId }: { namespace: Path.Valid; requestId: bigint }) { + constructor({ + namespace, + requestId, + hidden = false, + }: { namespace: Path.Valid; requestId: bigint; hidden?: boolean }) { this.namespace = namespace; this.requestId = requestId; + this.hidden = hidden; } async #encode(w: Writer, version: IetfVersion): Promise { @@ -42,7 +49,9 @@ export class SubscribeNamespace { } await w.u62(this.requestId); await Namespace.encode(w, this.namespace); - await new Parameters().encode(w, version); + const params = new Parameters(); + params.hidden = this.hidden; + await params.encode(w, version); } async encode(w: Writer, version: IetfVersion): Promise { @@ -59,9 +68,9 @@ export class SubscribeNamespace { } const requestId = await r.u62(); const namespace = await Namespace.decode(r); - await Parameters.decode(r, version); + const params = await Parameters.decode(r, version); - return new SubscribeNamespace({ namespace, requestId }); + return new SubscribeNamespace({ namespace, requestId, hidden: params.hidden }); } } @@ -77,19 +86,24 @@ export class SubscribeNamespaceLegacy { namespace: Path.Valid; requestId: bigint; subscribeOptions: number; // v16/v17: default 0x01 (NAMESPACE only) + /** MoQ Hidden: see {@link SubscribeNamespace.hidden}. */ + hidden: boolean; constructor({ namespace, requestId, subscribeOptions = 1, + hidden = false, }: { namespace: Path.Valid; requestId: bigint; subscribeOptions?: number; + hidden?: boolean; }) { this.namespace = namespace; this.requestId = requestId; this.subscribeOptions = subscribeOptions; + this.hidden = hidden; } async #encode(w: Writer, version: IetfVersion): Promise { @@ -104,7 +118,9 @@ export class SubscribeNamespaceLegacy { if (version === Version.DRAFT_16 || version === Version.DRAFT_17) { await w.u53(this.subscribeOptions); } - await new Parameters().encode(w, version); + const params = new Parameters(); + params.hidden = this.hidden; + await params.encode(w, version); } async encode(w: Writer, version: IetfVersion): Promise { @@ -128,9 +144,9 @@ export class SubscribeNamespaceLegacy { if (version === Version.DRAFT_16 || version === Version.DRAFT_17) { subscribeOptions = await r.u53(); } - await Parameters.decode(r, version); + const params = await Parameters.decode(r, version); - return new SubscribeNamespaceLegacy({ namespace, requestId, subscribeOptions }); + return new SubscribeNamespaceLegacy({ namespace, requestId, subscribeOptions, hidden: params.hidden }); } } diff --git a/js/net/src/ietf/subscriber.ts b/js/net/src/ietf/subscriber.ts index 24dc891ec6..15a013e420 100644 --- a/js/net/src/ietf/subscriber.ts +++ b/js/net/src/ietf/subscriber.ts @@ -4,7 +4,7 @@ import { BroadcastCache } from "../consume.ts"; import { controlTimeout, error, ProtocolViolation, reason } from "../error.ts"; import * as netGroup from "../group.ts"; import { Cost, type Route, routesEqual, UNKNOWN_HOP } from "../hop.ts"; -import { scopeCaptures, scopeHead, scopeOverlaps } from "../internal.ts"; +import { hiddenBelow, scopeCaptures, scopeHead, scopeOverlaps } from "../internal.ts"; import * as Path from "../path.ts"; import type { Reader, Stream } from "../stream.ts"; import { type Timescale, Timestamp } from "../time.ts"; @@ -69,6 +69,14 @@ type SubscribeSetupState = { rejected?: boolean; }; +/** A local announce reader's filter: its scope, the prefix it asked for, and its hidden opt-in. */ +type Filter = { scope: Path.Pattern; prefix: Path.Valid; hidden: boolean }; + +/** Whether a reader with `filter` sees an announcement at `path`. */ +function sees(filter: Filter, path: Path.Valid): boolean { + return scopeOverlaps(filter.scope, path) && (filter.hidden || !hiddenBelow(filter.prefix, path)); +} + /** * Handles subscribing to broadcasts using moq-transport protocol. * Uses the stream-per-request pattern (real bidi streams for v17, virtual for v14-v16). @@ -109,7 +117,10 @@ export class Subscriber { #announced = new Map(); // Any consumers that want each new announcement, keyed by their local filter. - #announcedConsumers = new Map(); + #announcedConsumers = new Map(); + + // Whether the peer understands the HIDDEN parameter (MoQ Hidden). + #hidden: boolean; /** * Creates a new Subscriber instance. @@ -119,14 +130,18 @@ export class Subscriber { constructor({ session, cluster, + hidden = false, }: { /** The session abstraction for bidi streams and request IDs. */ session: Session; /** The Hop IDs the SETUP exchange settled (MoQ Cluster). */ cluster?: Cluster.Hops; + /** Whether the peer understands the HIDDEN parameter (MoQ Hidden). */ + hidden?: boolean; }) { this.#session = session; this.#cluster = cluster; + this.#hidden = hidden; } /** @@ -154,13 +169,19 @@ export class Subscriber { * The peer is asked with SUBSCRIBE_NAMESPACE regardless of what it declared, and an * unsolicited PUBLISH_NAMESPACE lands here too, so a peer that only tells and one * that only answers are both discovered. + * + * Hidden routes (a `.`-prefixed segment below the scope's head) are left out unless + * `options.hidden` opts in. The opt-in rides the SUBSCRIBE_NAMESPACE when the peer + * understands it (MoQ Hidden); the rule is also applied here, since an unsolicited + * PUBLISH_NAMESPACE or a peer that never heard of it hides nothing. */ - announced(scope: Path.Pattern = Path.Pattern.all()): announce.Consumer { + announced(scope: Path.Pattern = Path.Pattern.all(), options?: announce.Options): announce.Consumer { // The wire speaks announce interest by prefix. const prefix = scopeHead(scope); + const filter = { scope, prefix, hidden: options?.hidden ?? false }; const announced = new announce.Producer(); for (const [active, info] of this.#announced) { - if (!scopeOverlaps(scope, active)) continue; + if (!sees(filter, active)) continue; announced.append({ prefix: active, captures: scopeCaptures(scope, active), @@ -168,9 +189,9 @@ export class Subscriber { route: info.route, }); } - this.#announcedConsumers.set(announced, scope); + this.#announcedConsumers.set(announced, filter); - void this.#runAnnounced(announced, prefix).finally(() => { + void this.#runAnnounced(announced, prefix, filter.hidden && this.#hidden).finally(() => { this.#announcedConsumers.delete(announced); announced.close(); }); @@ -191,8 +212,9 @@ export class Subscriber { this.#announced.set(path, { count: 1, route }); console.debug(`announced: broadcast=${path} active=true`); - for (const [consumer, scope] of this.#announcedConsumers) { - if (!scopeOverlaps(scope, path)) continue; + for (const [consumer, filter] of this.#announcedConsumers) { + if (!sees(filter, path)) continue; + const scope = filter.scope; consumer.append({ prefix: path, captures: scopeCaptures(scope, path), kind: "announced", route }); } } @@ -207,8 +229,9 @@ export class Subscriber { if (existing === undefined || routesEqual(existing.route, route)) return; existing.route = route; console.debug(`announced: broadcast=${path} rerouted`); - for (const [consumer, scope] of this.#announcedConsumers) { - if (!scopeOverlaps(scope, path)) continue; + for (const [consumer, filter] of this.#announcedConsumers) { + if (!sees(filter, path)) continue; + const scope = filter.scope; consumer.append({ prefix: path, captures: scopeCaptures(scope, path), kind: "updated", route }); } } @@ -231,8 +254,9 @@ export class Subscriber { this.#consumes.evict(path); console.debug(`announced: broadcast=${path} active=false`); - for (const [consumer, scope] of this.#announcedConsumers) { - if (!scopeOverlaps(scope, path)) continue; + for (const [consumer, filter] of this.#announcedConsumers) { + if (!sees(filter, path)) continue; + const scope = filter.scope; try { consumer.append({ prefix: path, @@ -246,7 +270,7 @@ export class Subscriber { } } - async #runAnnounced(announced: announce.Producer, prefix: Path.Valid) { + async #runAnnounced(announced: announce.Producer, prefix: Path.Valid, hidden: boolean) { const version = this.#session.version; // Suffixes live on this stream, so a repeat is recognized as an update to the @@ -283,10 +307,16 @@ export class Subscriber { version === Version.DRAFT_17 ) { await stream.writer.u53(SubscribeNamespaceLegacy.id); - await new SubscribeNamespaceLegacy({ namespace: prefix, requestId }).encode(stream.writer, version); + await new SubscribeNamespaceLegacy({ namespace: prefix, requestId, hidden }).encode( + stream.writer, + version, + ); } else { await stream.writer.u53(SubscribeNamespace.id); - await new SubscribeNamespace({ namespace: prefix, requestId }).encode(stream.writer, version); + await new SubscribeNamespace({ namespace: prefix, requestId, hidden }).encode( + stream.writer, + version, + ); } console.debug(`subscribe_namespace written: requestId=${requestId}`); diff --git a/js/net/src/integration.test.ts b/js/net/src/integration.test.ts index f6ca858b41..2a98a9a8bc 100644 --- a/js/net/src/integration.test.ts +++ b/js/net/src/integration.test.ts @@ -407,6 +407,61 @@ test("integration: lite draft-06 announce lifecycle", async () => { server.close(); }); +/** Collect announced prefixes until `until` arrives. */ +async function announcedUntil(announced: { next(): Promise<{ prefix: Path.Valid } | undefined> }, until: string) { + const seen: string[] = []; + while (!seen.includes(until)) { + const entry = await withTimeout(announced.next(), 1000, `waiting for ${until}`); + if (!entry) throw new Error("announcements ended"); + seen.push(entry.prefix); + } + return seen; +} + +// A `.`-named broadcast is left out of discovery unless the request opts in or names the +// dot segment. lite-06 cannot carry the opt-in, so its peer never lists the hidden path. +for (const [protocol, carriesOptIn] of [ + [Lite.ALPN_07, true], + [Lite.ALPN_06, false], + [Ietf.ALPN.DRAFT_19, true], + [Ietf.ALPN.DRAFT_16, true], +] as const) { + test(`integration: ${protocol} hides dot paths from discovery`, async () => { + const pair = createMockTransportPair(protocol); + const origin = new OriginProducer(); + const [client, server] = await Promise.all([ + connect(url, { transport: pair.client }), + accept(pair.server, url, { publish: origin.consume() }), + ]); + + // Published first, so a reader that may see it lists it before `visible`. + const hidden = publish(origin, Path.from(".x/y")); + const visible = publish(origin, Path.from("visible")); + + const plain = client.announced(); + expect(await announcedUntil(plain, "visible")).toEqual(["visible"]); + + // An IETF reader shares the session's table, so `visible` may land before the + // opted-in request's own answer; wait on the hidden path itself where it is due. + const opted = client.announced(undefined, { hidden: true }); + if (carriesOptIn) await announcedUntil(opted, ".x/y"); + else expect(await announcedUntil(opted, "visible")).toEqual(["visible"]); + + if (carriesOptIn) { + const named = client.announced(Path.Pattern.parse(".x/**")); + expect(await announcedUntil(named, ".x/y")).toEqual([".x/y"]); + named.close(); + } + + plain.close(); + opted.close(); + hidden.close(); + visible.close(); + client.close(); + server.close(); + }); +} + test("integration: lite draft-05 datagram delivery", async () => { const enc = new TextEncoder(); const dec = new TextDecoder(); diff --git a/js/net/src/internal.ts b/js/net/src/internal.ts index 77316e00c8..6c7d2819e1 100644 --- a/js/net/src/internal.ts +++ b/js/net/src/internal.ts @@ -37,6 +37,15 @@ export function scopeHead(scope: Path.Pattern): Path.Valid { return Path.from(scope.head); } +/** + * Whether a segment of `path` below `prefix` starts with `.`, which hides it from announce + * discovery unless the request opts in. A path at or above the prefix never hides. + */ +export function hiddenBelow(prefix: Path.Valid, path: Path.Valid): boolean { + const below = Path.stripPrefix(prefix, path); + return below !== null && Path.parts(below).some((part) => part.startsWith(".")); +} + /** Whether the announced prefix's subtree overlaps `scope`. */ export function scopeOverlaps(scope: Path.Pattern, prefix: Path.Valid): boolean { return scope.overlaps(Path.Pattern.subtree(prefix)); diff --git a/js/net/src/lite/announce.test.ts b/js/net/src/lite/announce.test.ts index c0efde17c5..4a6acc7dfc 100644 --- a/js/net/src/lite/announce.test.ts +++ b/js/net/src/lite/announce.test.ts @@ -142,6 +142,15 @@ test("AnnounceRequest drops excludeHop on draft-06", async () => { expect(with06.byteLength).toBeLessThan(with05.byteLength); }); +// Draft07 carries the hidden opt-in; every earlier version decodes as not opted in. +test("AnnounceRequest carries hidden from draft-07", async () => { + for (const hidden of [false, true]) { + const msg = new AnnounceRequest(Path.from("room/"), 0n, hidden); + expect((await requestRoundTrip(msg, Version.DRAFT_07)).hidden).toBe(hidden); + expect((await requestRoundTrip(msg, Version.DRAFT_06)).hidden).toBe(false); + } +}); + // The draft reserves Hop ID 0 for a responder that was never assigned an id, or that // withholds it to obscure its routing. Rejecting it tore down the announce stream of a // conforming publisher. diff --git a/js/net/src/lite/announce.ts b/js/net/src/lite/announce.ts index aca95eeaad..d9d6bf7874 100644 --- a/js/net/src/lite/announce.ts +++ b/js/net/src/lite/announce.ts @@ -3,7 +3,7 @@ import { type Cost, type Hop, HopSchema, MAX_HOPS, UNKNOWN_HOP } from "../hop.ts import * as Path from "../path.ts"; import type { Reader, Writer } from "../stream.ts"; import * as Message from "./message.ts"; -import { hasAnnounceId, hasAnnounceOk, hasExcludeHop, hasRouteCost, Version } from "./version.ts"; +import { hasAnnounceId, hasAnnounceOk, hasExcludeHop, hasHidden, hasRouteCost, Version } from "./version.ts"; // Pre-lite-06 inner status values, carried inside the single ANNOUNCE_BROADCAST body. const STATUS_ENDED = 0; @@ -266,10 +266,15 @@ export class AnnounceRequest { * * Must be a bigint: peer origins are up to 62 bits and overflow u53. */ excludeHop: bigint; + /** Lite07+: also announce routes with a `.`-prefixed segment below the prefix. Not on + * the wire earlier, so a value set here is ignored when encoding for an older version + * and decodes as false. */ + hidden: boolean; - constructor(prefix: Path.Valid, excludeHop: bigint = 0n) { + constructor(prefix: Path.Valid, excludeHop: bigint = 0n, hidden = false) { this.prefix = prefix; this.excludeHop = excludeHop; + this.hidden = hidden; } async #encode(w: Writer, version: Version) { @@ -277,12 +282,16 @@ export class AnnounceRequest { if (hasExcludeHop(version)) { await w.u62(this.excludeHop); } + if (hasHidden(version)) { + await w.bool(this.hidden); + } } static async #decode(r: Reader, version: Version): Promise { const prefix = Path.decode(await r.string()); const excludeHop = hasExcludeHop(version) ? await r.u62() : 0n; - return new AnnounceRequest(prefix, excludeHop); + const hidden = hasHidden(version) ? await r.bool() : false; + return new AnnounceRequest(prefix, excludeHop, hidden); } async encode(w: Writer, version: Version): Promise { diff --git a/js/net/src/lite/connection.ts b/js/net/src/lite/connection.ts index 3aca065ab6..75e681d6f8 100644 --- a/js/net/src/lite/connection.ts +++ b/js/net/src/lite/connection.ts @@ -170,8 +170,8 @@ export class Connection implements Established { } } - announced(scope?: Path.Pattern): announce.Consumer { - return this.#subscriber.announced(scope); + announced(scope?: Path.Pattern, options?: announce.Options): announce.Consumer { + return this.#subscriber.announced(scope, options); } async #runSession() { diff --git a/js/net/src/lite/publisher.ts b/js/net/src/lite/publisher.ts index bb2230ed82..5ed2341525 100644 --- a/js/net/src/lite/publisher.ts +++ b/js/net/src/lite/publisher.ts @@ -3,7 +3,7 @@ import type * as broadcast from "../broadcast.ts"; import { error, NotFound, reason, StreamCode, StreamError } from "../error.ts"; import type * as group from "../group.ts"; import { type Hop, type Route, routesEqual } from "../hop.ts"; -import { hooks } from "../internal.ts"; +import { hiddenBelow, hooks } from "../internal.ts"; import type { Consumer as OriginConsumer } from "../origin.ts"; import * as Path from "../path.ts"; import { type Reader, type Stream, Writer } from "../stream.ts"; @@ -32,7 +32,11 @@ import { hasAnnounceId, hasAnnounceOk, hasDatagrams, hasProbeRtt, resolvesStart, // Where each originated route lands under the requested prefix: its suffix beneath // the prefix, or the empty suffix for a route above it, where the most specific // such route wins the way a request through the prefix would resolve. -function presented(prefix: Path.Valid, table: ReadonlyMap): Map { +function presented( + prefix: Path.Valid, + table: ReadonlyMap, + hidden: boolean, +): Map { const out = new Map(); let rootLen = -1; for (const [covered, snap] of table) { @@ -42,6 +46,8 @@ function presented(prefix: Path.Valid, table: ReadonlyMap