diff --git a/.github/actions/uniffi-bindgen/action.yml b/.github/actions/uniffi-bindgen/action.yml index 1adc68f6337..bed8981558c 100644 --- a/.github/actions/uniffi-bindgen/action.yml +++ b/.github/actions/uniffi-bindgen/action.yml @@ -21,7 +21,7 @@ runs: case "$GENERATOR" in cpp) repo=https://github.com/kixelated/uniffi-bindgen-cpp - rev=a5a1ac411772b3f589859edf50aef7bc7538100b # v0.11.0-kixelated.7+v0.32.2 + rev=6ea9f9bb764e2f9e753ff2bb623e899598df7960 # v0.11.0-kixelated.8+v0.32.2 ;; go) repo=https://github.com/kixelated/uniffi-bindgen-go diff --git a/c/moq/test/probe.c b/c/moq/test/probe.c index bbfbb43582e..e878a1db513 100644 --- a/c/moq/test/probe.c +++ b/c/moq/test/probe.c @@ -254,6 +254,125 @@ static moq_connection_state *next_state(moq_session *session, moq_connection_sta return next; } +static void on_announce(void *user_data, moq_error *error, moq_announce_event *value) +{ + on_value(user_data, error, value); +} + +/* Over moq-lite-06, whose wire carries no instance, the session mints the announced one. + * Passed back as is, it pins request and follow; its text, parsed, names another instance. */ +static void minted_instance(void) +{ + moq_instance *empty = NULL; + moq_error *error = moq_instance_parse("", &empty); + CHECK(error && error->tag == MOQ_ERROR_INVALID_INSTANCE && !empty); + moq_error_free(error); + + moq_origin_config origin_config = moq_origin_config_default(); + moq_origin_producer *origin = moq_origin_producer_new(&origin_config); + moq_route_options route_options = moq_route_options_default(); + moq_route *route = NULL; + ok(moq_origin_producer_route(origin, "live", &route_options, &route), "minted route"); + + const char *generate[] = {"localhost"}; + const char *versions[] = {"moq-lite-06"}; + moq_server_config server_config = moq_server_config_default(); + server_config.bind = "127.0.0.1:0"; + server_config.versions.data = versions; + server_config.versions.len = 1; + server_config.tls.generate.data = generate; + server_config.tls.generate.len = 1; + server_config.publish = origin; + moq_server *server = NULL; + ok(moq_server_new(&server_config, &server), "lite-06 server new"); + result listening = {0}; + moq_task *task = moq_server_listen(server, on_string, &listening); + char *addr = await(&listening, "lite-06 listen"); + moq_task_free(task); + + moq_client_config client_config = moq_client_config_default(); + client_config.tls.insecure = true; + client_config.versions = server_config.versions; + client_config.implementation = MOQ_IMPLEMENTATION; + moq_client *client = NULL; + ok(moq_client_new(&client_config, &client), "lite-06 client new"); + char url[128]; + snprintf(url, sizeof(url), "https://%s", addr); + moq_string_free(addr); + result accepting = {0}; + result connecting = {0}; + moq_task *accept_task = moq_server_accept(server, on_request, &accepting); + moq_task *connect_task = moq_client_connect(client, url, on_session, &connecting); + moq_request *request = await(&accepting, "lite-06 accept"); + CHECK(request); + result serving = {0}; + moq_task *serve_task = moq_request_accept(request, NULL, NULL, on_session, &serving); + moq_session *served = await(&serving, "lite-06 request accept"); + moq_session *session = await(&connecting, "lite-06 connect"); + moq_task_free(accept_task); + moq_task_free(connect_task); + moq_task_free(serve_task); + + moq_origin_consumer *consumer = moq_session_consume(session); + moq_announce_config announce_config = moq_announce_config_default(); + moq_announce_consumer *announced = NULL; + ok(moq_origin_consumer_announced(consumer, &announce_config, &announced), "announced"); + result next = {0}; + task = moq_announce_consumer_next(announced, on_announce, &next); + moq_announce_event *event = await(&next, "announce event"); + moq_task_free(task); + CHECK(event && event->tag == MOQ_ANNOUNCE_EVENT_PRESENT); + moq_instance *minted = event->present.announce.route.instance; + CHECK(moq_instance_is_minted(minted)); + + result requested = {0}; + task = moq_origin_consumer_request(consumer, "live", minted, on_broadcast, &requested); + moq_broadcast_consumer_free(await(&requested, "request the minted instance")); + moq_task_free(task); + moq_following *following = NULL; + ok(moq_origin_consumer_follow(consumer, "live", minted, &following), "follow the minted instance"); + result available = {0}; + task = moq_following_available(following, on_broadcast, &available); + moq_broadcast_consumer *followed = await(&available, "available the minted instance"); + CHECK(followed); + moq_broadcast_consumer_free(followed); + moq_task_free(task); + + /* A handle's == compares pointers, so moq_instance_eq compares instances. */ + char *text = moq_instance_text(minted); + moq_instance *named = NULL; + ok(moq_instance_parse(text, &named), "parse the minted text"); + moq_string_free(text); + CHECK(!moq_instance_is_minted(named)); + CHECK(!moq_instance_eq(named, minted) && moq_instance_ne(named, minted)); + CHECK(moq_instance_eq(minted, minted)); + result refused = {0}; + task = moq_origin_consumer_request(consumer, "live", named, on_broadcast, &refused); + CHECK(pump(&refused.done, 5000)); + CHECK(refused.error && refused.error->tag == MOQ_ERROR_PROTOCOL && !refused.value); + moq_error_free(refused.error); + moq_task_free(task); + moq_instance_free(named); + moq_announce_event_free(event); + + moq_following_cancel(following); + moq_announce_consumer_cancel(announced); + moq_session_cancel(session, 0); + moq_session_cancel(served, 0); + moq_server_cancel(server); + moq_route_cancel(route); + moq_following_free(following); + moq_announce_consumer_free(announced); + moq_origin_consumer_free(consumer); + moq_session_free(session); + moq_session_free(served); + moq_request_free(request); + moq_client_free(client); + moq_server_free(server); + moq_route_free(route); + moq_origin_producer_free(origin); +} + int main(void) { #ifdef _WIN32 @@ -274,6 +393,8 @@ int main(void) moq_subscription bare = moq_subscription_default(); CHECK(!bare.delay.max.has_value); + minted_instance(); + /* A synchronous error is a returned value with Rust's Display message. */ moq_server_config unbound_config = moq_server_config_default(); moq_server *unbound = NULL; @@ -294,9 +415,12 @@ int main(void) moq_route *route = NULL; ok(moq_origin_producer_route(origin, "probe", &route_options, &route), "route"); moq_broadcast_producer *broadcast = moq_route_broadcast(route); - char *instance = moq_route_instance_id(route); - CHECK(instance && instance[0] != '\0'); - moq_string_free(instance); + moq_instance *instance = moq_route_instance_id(route); + CHECK(instance && !moq_instance_is_minted(instance)); + char *text = moq_instance_text(instance); + CHECK(text && text[0] != '\0'); + moq_string_free(text); + moq_instance_free(instance); moq_track_info info = moq_track_info_default(); info.timescale.has_value = true; info.timescale.value = 1000000; diff --git a/cpp/moq/README.md b/cpp/moq/README.md index 19aeab1e9b8..06d7cc0ef83 100644 --- a/cpp/moq/README.md +++ b/cpp/moq/README.md @@ -19,7 +19,7 @@ The generator is a fork, [kixelated/uniffi-bindgen-cpp](https://github.com/kixel ```bash cargo install uniffi-bindgen-cpp --locked \ --git https://github.com/kixelated/uniffi-bindgen-cpp \ - --tag v0.11.0-kixelated.7+v0.32.2 + --tag v0.11.0-kixelated.8+v0.32.2 ``` `flake.nix` pins the same tag and lists every other place that names it. @@ -31,6 +31,7 @@ Every generated type lives in `namespace moq`. Objects are `std::shared_ptr`; re - A fallible call returns `moq::expected`, the bundled `tl::expected` at every standard, so bindings built at C++17 link with code built at C++23. With exceptions off, `value()` on an error aborts with a message. - An async call returns `moq::Future`. Block on it with `get()` or `wait_for()`, attach a continuation with `std::move(future).then(executor, callback)`, which returns a `moq::Continuation`, or `co_await` it on C++20. - `moq::Error` is a value holding a `std::variant` of its cases, and `to_string()` gives Rust's `Display` message. Nothing throws. A Rust panic inside a call aborts with a message on stderr, and so does reading an invalid future (see [Cancellation](#cancellation)). A panic in owned background work reaches the next call on that object as a task error; other objects keep running. +- An object Rust compares, hashes, or prints, such as `moq::Instance`, gets `eq()`, `ne()`, `hash()`, and `to_string()`. `==` on its `std::shared_ptr` compares handles, not values. - Futures are polled on one process-wide executor thread unless the application installs its own with `moq::set_executor` before the first async call. The executor passed to `then()` dispatches only the continuation. `moq::shutdown()` stops the moq-ffi runtime workers (`moq_ffi_shutdown`), then the executor, before unloading the code a continuation could call into. ## Cancellation diff --git a/cpp/moq/test/probe.cpp b/cpp/moq/test/probe.cpp index 1270ab75bfc..f9b4d5de3b0 100644 --- a/cpp/moq/test/probe.cpp +++ b/cpp/moq/test/probe.cpp @@ -256,6 +256,65 @@ void audio_roundtrip() ok(broadcast->close(), "close audio broadcast"); } +// Over moq-lite-06, whose wire carries no instance, the session mints the announced one. +// Passed back as is, it pins request and follow; its text, parsed, names another instance. +void minted_instance() +{ + CHECK(std::holds_alternative(moq::Instance::parse("").error().get_variant())); + + auto origin = moq::OriginProducer::init({}); + auto route = ok(origin->route("live", {}), "minted route"); + + moq::ServerConfig server_config; + server_config.bind = "127.0.0.1:0"; + server_config.versions = {"moq-lite-06"}; + server_config.tls.generate = {"localhost"}; + server_config.publish = origin; + auto server = ok(moq::Server::init(server_config), "lite-06 server init"); + auto addr = ok(server->listen().get(), "lite-06 listen"); + + moq::ClientConfig client_config; + client_config.tls.insecure = true; + client_config.versions = server_config.versions; + client_config.implementation = moq::IMPLEMENTATION; + auto client = ok(moq::Client::init(client_config), "lite-06 client init"); + auto accepting = server->accept(); + auto connecting = client->connect("https://" + addr); + auto request = ok(accepting.get(), "lite-06 accept"); + CHECK(request != nullptr); + auto served = ok(request->accept(nullptr, nullptr).get(), "lite-06 request accept"); + auto session = ok(connecting.get(), "lite-06 connect"); + + auto consumer = session->consume(); + auto announced = ok(consumer->announced({}), "announced"); + auto event = ok(announced->next().get(), "announce event"); + CHECK(event); + const auto *present = std::get_if(&event->get_variant()); + CHECK(present != nullptr); + auto minted = present->announce.route.instance; + CHECK(minted->is_minted()); + + ok(consumer->request("live", minted).get(), "request the minted instance"); + auto following = ok(consumer->follow("live", minted), "follow the minted instance"); + CHECK(ok(following->available().get(), "available the minted instance")); + + // A shared_ptr's == compares handles, so the generated eq() compares instances. + auto named = ok(moq::Instance::parse(minted->text()), "parse the minted text"); + CHECK(!named->is_minted()); + CHECK(named->to_string() == minted->to_string()); + CHECK(!named->eq(minted)); + auto refused = consumer->request("live", named).get(); + CHECK(!refused); + CHECK(std::holds_alternative(refused.error().get_variant())); + + following->cancel(); + announced->cancel(); + session->cancel(0); + served->cancel(0); + server->cancel(); + route->cancel(); +} + } // namespace int main() @@ -264,6 +323,7 @@ int main() datagram_tracks(); subscription_defaults(); audio_roundtrip(); + minted_instance(); auto timebase_owner = ok(moq::BroadcastProducer::init(), "timebase owner"); auto timebase_other = ok(moq::BroadcastProducer::init(), "timebase other"); auto timebase = ok(moq::MediaTimebase::init(timebase_owner), "timebase"); @@ -299,7 +359,7 @@ int main() moq::RouteOptions hidden; hidden.hidden = true; auto route = ok(origin->route("probe", hidden), "route"); - CHECK(!route->instance_id().empty()); + CHECK(route->instance_id() && !route->instance_id()->is_minted()); auto broadcast = route->broadcast(); moq::TrackInfo info; info.timescale = 1000000; @@ -339,7 +399,7 @@ int main() auto session = ok(connecting.get(), "connect"); // Subscriber: resolve the announced broadcast and subscribe to its track. - auto announced = ok(session->consume()->follow("probe", std::nullopt), "follow"); + auto announced = ok(session->consume()->follow("probe", nullptr), "follow"); // The first available() is never offline. auto remote = ok(announced->available().get(), "available"); CHECK(remote); diff --git a/cpp/obs/src/moq-output.cpp b/cpp/obs/src/moq-output.cpp index 1b7acf625f0..93d0bf6b383 100644 --- a/cpp/obs/src/moq-output.cpp +++ b/cpp/obs/src/moq-output.cpp @@ -239,7 +239,7 @@ bool MoQOutput::Start() { std::lock_guard lock(mutex); url = server; - instance = (*next_route)->instance_id(); + instance = (*next_route)->instance_id()->text(); } { std::lock_guard lock(media_mutex); diff --git a/cpp/obs/src/moq-source.cpp b/cpp/obs/src/moq-source.cpp index 1796a7afab1..ebacc0468dc 100644 --- a/cpp/obs/src/moq-source.cpp +++ b/cpp/obs/src/moq-source.cpp @@ -436,7 +436,7 @@ static void moq_source_on_connect(struct moq_source *ctx, const std::shared_ptr< // Wait for the broadcast to be announced. Announcements arrive over the session // after it connects, so resolving against only what is announced *now* would race // them and blank the source for a broadcast that is live. - auto announced = conn->session->consume()->follow(conn->broadcast, std::nullopt); + auto announced = conn->session->consume()->follow(conn->broadcast, nullptr); if (!announced) { LOG_ERROR("Failed to request broadcast '%s': %s", conn->broadcast.c_str(), announced.error().to_string().c_str()); diff --git a/cpp/obs/test/moq-audio-encoder-test.cpp b/cpp/obs/test/moq-audio-encoder-test.cpp index a8109e9839f..1104dc182bc 100644 --- a/cpp/obs/test/moq-audio-encoder-test.cpp +++ b/cpp/obs/test/moq-audio-encoder-test.cpp @@ -211,7 +211,7 @@ void CheckSubscriberDecodes(const char *preset, size_t channels) init.data = encoder.ExtraData(); auto track = TestOk(moq::MediaTrackProducer::audio(broadcast, moq::MediaTarget::kNamed{}, init), "audio track"); - auto announced = TestOk(origin->consume()->follow("room", std::nullopt), "follow"); + auto announced = TestOk(origin->consume()->follow("room", nullptr), "follow"); auto consumer = TestOk(announced->available().get(), "available"); auto catalogs = TestOk(moq::MediaCatalogConsumer::subscribe(consumer).get(), "catalog consumer"); std::optional catalog; diff --git a/cpp/obs/test/moq-output-test.cpp b/cpp/obs/test/moq-output-test.cpp index 6fa594eff1d..f1031386ba1 100644 --- a/cpp/obs/test/moq-output-test.cpp +++ b/cpp/obs/test/moq-output-test.cpp @@ -361,7 +361,7 @@ encoder_packet audioPacket(std::vector &payload, int64_t pts) // The output's broadcast as the relay serves it, or null once the wait times out. std::shared_ptr relayBroadcast(TestRelay &relay) { - auto announced = TestOk(relay.origin->consume()->follow("room", std::nullopt), "follow"); + auto announced = TestOk(relay.origin->consume()->follow("room", nullptr), "follow"); auto available = announced->available(); if (available.wait_for(std::chrono::seconds(10)) != std::future_status::ready) return nullptr; @@ -411,7 +411,7 @@ struct MoQOutputStatsTest { const auto *present = std::get_if(&event->get_variant()); if (!present) return std::nullopt; - return present->announce.route.instance; + return present->announce.route.instance->text(); } static std::shared_ptr Session(MoQOutput &output) diff --git a/cpp/obs/test/moq-source-test.cpp b/cpp/obs/test/moq-source-test.cpp index 98b7943c2b2..aa0141bcdd5 100644 --- a/cpp/obs/test/moq-source-test.cpp +++ b/cpp/obs/test/moq-source-test.cpp @@ -1585,7 +1585,7 @@ int main() // A different stream than the one the rendition pins, hidden until announced. moq::RouteOptions options; options.hidden = true; - options.instance = "obs-test-other"; + options.instance = TestOk(moq::Instance::parse("obs-test-other"), "instance"); hidden = TestOk(relay.origin->route("obs/hidden", options), "unannounced sibling"); config.broadcast = "./hidden"; config.instance = "obs-test-pinned"; diff --git a/dart/moq/lib/src/aliases.dart b/dart/moq/lib/src/aliases.dart index a326c1b362b..767de121ea0 100644 --- a/dart/moq/lib/src/aliases.dart +++ b/dart/moq/lib/src/aliases.dart @@ -55,7 +55,7 @@ typedef OriginConsumer = MoqOriginConsumer; /// A requested broadcast not yet answered: serve a held broadcast, route a new one, or reject it. typedef BroadcastRequest = MoqBroadcastRequest; -/// How a route advertises itself: hidden until announced, its static cost, and a stable instance. +/// How a route advertises itself: hidden until announced, its static cost, and a stable [Instance]. typedef RouteOptions = MoqRouteOptions; /// A stream of announce events under a prefix. @@ -70,6 +70,12 @@ typedef Following = MoqFollowing; /// The read-only facts on a received route: a static cost and the stream's instance. typedef RouteInfo = MoqRouteInfo; +/// One continuous stream at a path: `Instance.parse` names one, and only a session mints one. +/// +/// Two are equal only when their text and minted flag both match, so pass an +/// instance a route reported back as is, rather than re-parsing its text. +typedef Instance = MoqInstance; + /// A route over a prefix: its origin-relative path, required wildcard captures (empty without wildcards), and route metadata. typedef Announce = MoqAnnounce; diff --git a/dart/moq/lib/src/client.dart b/dart/moq/lib/src/client.dart index 77533a88a00..be0743c7198 100644 --- a/dart/moq/lib/src/client.dart +++ b/dart/moq/lib/src/client.dart @@ -234,14 +234,14 @@ final class Moq { /// Follow the broadcast at exactly [path]: each `available()` resolves the /// next instance serving it, such as a restarted publisher, or null once the - /// path goes offline. Pass an [instance] to follow only that stream; another - /// one taking over also resolves null. - Following follow(String path, {String? instance}) => + /// path goes offline. Pass an [instance] a route reported to follow only that + /// stream; another one taking over also resolves null. + Following follow(String path, {Instance? instance}) => session.consume().follow(path: path, instance: instance); /// Resolve one publisher instance of the broadcast at [path]. Pass an - /// [instance] to refuse any other stream. - Future request(String path, {String? instance}) => + /// [instance] a route reported to refuse any other stream. + Future request(String path, {Instance? instance}) => session.consume().request(path: path, instance: instance); /// The session's bandwidth allocator. @@ -304,18 +304,20 @@ extension GrantConsumerUpdates on GrantConsumer { /// A route's instance under the name the other bindings use. extension RouteInstance on MoqRoute { - /// The stream this route serves now. A named instance never changes. A job's - /// route takes its claim's current one when it first announces or when this - /// first reads it, so a hidden job route returns it before announcing, and - /// [MoqRoute.announce] fails with `Unroutable` once the claim has moved past it. - String instance() => instanceId(); + /// The stream this route serves now, or once cancelled, the last one it + /// reported or announced under (null if it did neither). A named instance + /// never changes. A job's route takes its claim's current one when it first + /// announces or when this first reads it, so a hidden job route returns it + /// before announcing, and [MoqRoute.announce] fails with `Unroutable` once + /// the claim has moved past it. + Instance? instance() => instanceId(); } /// A claim's instance under the name the other bindings use. extension ClaimInstance on MoqClaim { /// The stream this claim serves now. A named instance never changes. Unnamed, /// the claim moves to a fresh one each time a job holding its current one ends. - String instance() => instanceId(); + Instance instance() => instanceId(); } /// A [Stream] view over an announcement cursor. diff --git a/dart/moq/test/moq_test.dart b/dart/moq/test/moq_test.dart index b7b2db779c6..0f5d2d468a0 100644 --- a/dart/moq/test/moq_test.dart +++ b/dart/moq/test/moq_test.dart @@ -234,6 +234,64 @@ void main() { server.cancel(); }); + test('an announced instance pins request and follow', () async { + // moq-lite-06 carries no instance, so the session mints one for the route. + const version = ['moq-lite-06']; + final origin = MoqOriginProducer(config: MoqOriginConfig()); + final server = MoqServer( + config: MoqServerConfig( + bind: '127.0.0.1:0', + versions: version, + tls: MoqServerTls(generate: ['localhost']), + quic: MoqQuicConfig(), + publish: origin, + ), + ); + final address = await server.listen().timeout(timeout); + final accepted = () async { + final request = await server.accept().timeout(timeout); + if (request == null) throw StateError('server closed before accept'); + return request.accept().timeout(timeout); + }(); + final route = origin.route(path: 'live', options: RouteOptions()); + + final client = await Moq.connect( + 'https://$address', + options: ConnectOptions( + tls: ClientTls(insecure: true), + bind: '127.0.0.1:0', + versions: version, + once: true, + ), + ).timeout(timeout); + final serverSession = await accepted; + + final announced = client.announced(); + final present = await nextRoute(announced) as AnnounceEventPresent; + final minted = present.announce.route.instance; + expect(minted.isMinted(), isTrue); + + await client.request('live', instance: minted).timeout(timeout); + final following = client.follow('live', instance: minted); + expect(await following.available().timeout(timeout), isNotNull); + + // The same text, parsed, names another instance that nothing serves. + final named = Instance.parse(text: minted.text()); + expect(named.isMinted(), isFalse); + expect(named, isNot(minted)); + await expectLater( + client.request('live', instance: named).timeout(timeout), + throwsA(isA()), + ); + + following.cancel(); + announced.cancel(); + await client.close(); + serverSession.cancel(code: 0); + route.cancel(); + server.cancel(); + }); + test('connects, announces, subscribes, and delivers a frame', () async { final relay = MoqOriginProducer(config: MoqOriginConfig()); final server = MoqServer( @@ -619,24 +677,23 @@ void main() { expect(present.announce.route.instance, first.instance()); (await following.available().timeout(timeout))!.dispose(); + final camera = Instance.parse(text: 'camera'); + expect(camera.isMinted(), isFalse); final second = origin.route( path: 'instance', - options: RouteOptions(instance: 'camera', epoch: 1), + options: RouteOptions(instance: camera, epoch: 1), ); - expect(second.instance(), 'camera'); + expect(second.instance(), camera); (await following.available().timeout(timeout))!.dispose(); // The takeover was not listed, so the next event is the path going absent. first.withdraw(); second.withdraw(); final absent = await nextRoute(announced) as AnnounceEventAbsent; - expect(absent.announce.route.instance, 'camera'); + expect(absent.announce.route.instance, camera); expect( - () => origin.route( - path: 'refused', - options: RouteOptions(instance: ''), - ), + () => Instance.parse(text: ''), throwsA(isA()), ); first.cancel(); diff --git a/dart/moq_ffi/lib/src/moq.dart b/dart/moq_ffi/lib/src/moq.dart index 07b389286d1..bcaaa3bcde9 100644 --- a/dart/moq_ffi/lib/src/moq.dart +++ b/dart/moq_ffi/lib/src/moq.dart @@ -3010,7 +3010,7 @@ class FfiConverterMoqOriginConfig { class MoqRouteInfo { final int cost; - final String instance; + final MoqInstance instance; MoqRouteInfo({required this.cost, required this.instance}); } @@ -3026,7 +3026,7 @@ class FfiConverterMoqRouteInfo { ); final cost = cost_lifted.value; new_offset += cost_lifted.bytesRead; - final instance_lifted = FfiConverterString.read( + final instance_lifted = FfiConverterMoqInstance.read( Uint8List.view(buf.buffer, new_offset), ); final instance = instance_lifted.value; @@ -3040,7 +3040,7 @@ class FfiConverterMoqRouteInfo { static RustBuffer lower(MoqRouteInfo value) { final total_length = FfiConverterUInt64.allocationSize(value.cost) + - FfiConverterString.allocationSize(value.instance) + + FfiConverterMoqInstance.allocationSize(value.instance) + 0; final buf = Uint8List(total_length); write(value, buf); @@ -3053,7 +3053,7 @@ class FfiConverterMoqRouteInfo { value.cost, Uint8List.view(buf.buffer, new_offset), ); - new_offset += FfiConverterString.write( + new_offset += FfiConverterMoqInstance.write( value.instance, Uint8List.view(buf.buffer, new_offset), ); @@ -3062,7 +3062,7 @@ class FfiConverterMoqRouteInfo { static int allocationSize(MoqRouteInfo value) { return FfiConverterUInt64.allocationSize(value.cost) + - FfiConverterString.allocationSize(value.instance) + + FfiConverterMoqInstance.allocationSize(value.instance) + 0; } } @@ -3070,7 +3070,7 @@ class FfiConverterMoqRouteInfo { class MoqRouteOptions { final bool hidden; final int cost; - final String? instance; + final MoqInstance? instance; final int epoch; MoqRouteOptions({ this.hidden = false, @@ -3097,7 +3097,7 @@ class FfiConverterMoqRouteOptions { ); final cost = cost_lifted.value; new_offset += cost_lifted.bytesRead; - final instance_lifted = FfiConverterOptionalString.read( + final instance_lifted = FfiConverterOptionalMoqInstance.read( Uint8List.view(buf.buffer, new_offset), ); final instance = instance_lifted.value; @@ -3122,7 +3122,7 @@ class FfiConverterMoqRouteOptions { final total_length = FfiConverterBool.allocationSize(value.hidden) + FfiConverterUInt64.allocationSize(value.cost) + - FfiConverterOptionalString.allocationSize(value.instance) + + FfiConverterOptionalMoqInstance.allocationSize(value.instance) + FfiConverterUInt64.allocationSize(value.epoch) + 0; final buf = Uint8List(total_length); @@ -3140,7 +3140,7 @@ class FfiConverterMoqRouteOptions { value.cost, Uint8List.view(buf.buffer, new_offset), ); - new_offset += FfiConverterOptionalString.write( + new_offset += FfiConverterOptionalMoqInstance.write( value.instance, Uint8List.view(buf.buffer, new_offset), ); @@ -3154,7 +3154,7 @@ class FfiConverterMoqRouteOptions { static int allocationSize(MoqRouteOptions value) { return FfiConverterBool.allocationSize(value.hidden) + FfiConverterUInt64.allocationSize(value.cost) + - FfiConverterOptionalString.allocationSize(value.instance) + + FfiConverterOptionalMoqInstance.allocationSize(value.instance) + FfiConverterUInt64.allocationSize(value.epoch) + 0; } @@ -11634,6 +11634,161 @@ class FfiConverterMoqTrackDemand { } } +abstract class MoqInstanceInterface { + bool isMinted(); + String text(); +} + +final _MoqInstanceFinalizer = Finalizer>((ptr) { + rustCall((status) => uniffi_moq_ffi_fn_free_moqinstance(ptr, status)); +}); + +class MoqInstance implements MoqInstanceInterface { + late final Pointer _ptr; + bool _isDisposed = false; + MoqInstance._(this._ptr) { + _MoqInstanceFinalizer.attach(this, _ptr, detach: this); + } + MoqInstance.parse({required String text}) + : _ptr = rustCall( + (status) => uniffi_moq_ffi_fn_constructor_moqinstance_parse( + FfiConverterString.lower(text), + status, + ), + moqExceptionErrorHandler, + ) { + _MoqInstanceFinalizer.attach(this, _ptr, detach: this); + } + factory MoqInstance.lift(Pointer ptr) { + return MoqInstance._(ptr); + } + Pointer uniffiClonePointer() { + if (_isDisposed) { + throw StateError("Object has already been disposed"); + } + return rustCall( + (status) => uniffi_moq_ffi_fn_clone_moqinstance(_ptr, status), + ); + } + + void dispose() { + if (_isDisposed) { + return; + } + final status = calloc(); + _isDisposed = true; + _MoqInstanceFinalizer.detach(this); + try { + uniffi_moq_ffi_fn_free_moqinstance(_ptr, status); + checkCallStatus(NullRustCallStatusErrorHandler(), status); + } finally { + calloc.free(status); + } + } + + String debugString() { + return rustCallWithLifter( + (status) => uniffi_moq_ffi_fn_method_moqinstance_uniffi_trait_debug( + uniffiClonePointer(), + status, + ), + FfiConverterString.lift, + null, + ); + } + + @override + String toString() { + return rustCallWithLifter( + (status) => uniffi_moq_ffi_fn_method_moqinstance_uniffi_trait_display( + uniffiClonePointer(), + status, + ), + FfiConverterString.lift, + null, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other is! MoqInstance) { + return false; + } + return rustCallWithLifter( + (status) => uniffi_moq_ffi_fn_method_moqinstance_uniffi_trait_eq_eq( + uniffiClonePointer(), + FfiConverterMoqInstance.lower(other), + status, + ), + FfiConverterBool.lift, + null, + ); + } + + @override + int get hashCode { + return rustCallWithLifter( + (status) => uniffi_moq_ffi_fn_method_moqinstance_uniffi_trait_hash( + uniffiClonePointer(), + status, + ), + FfiConverterUInt64.lift, + null, + ); + } + + bool isMinted() { + return rustCallWithLifter( + (status) => uniffi_moq_ffi_fn_method_moqinstance_is_minted( + uniffiClonePointer(), + status, + ), + FfiConverterBool.lift, + null, + ); + } + + String text() { + return rustCallWithLifter( + (status) => uniffi_moq_ffi_fn_method_moqinstance_text( + uniffiClonePointer(), + status, + ), + FfiConverterString.lift, + null, + ); + } +} + +class FfiConverterMoqInstance { + static MoqInstance lift(Pointer ptr) { + return MoqInstance.lift(ptr); + } + + static Pointer lower(MoqInstance value) { + return value.uniffiClonePointer(); + } + + static int allocationSize(MoqInstance value) { + return 8; + } + + static LiftRetVal read(Uint8List buf) { + final handle = buf.buffer.asByteData(buf.offsetInBytes).getInt64(0); + final pointer = Pointer.fromAddress(handle); + return LiftRetVal(MoqInstance.lift(pointer), 8); + } + + static int write(MoqInstance value, Uint8List buf) { + final handle = lower(value); + buf.buffer.asByteData(buf.offsetInBytes).setInt64(0, handle.address); + return 8; + } +} + abstract class MoqJsonSnapshotConsumerInterface { void cancel(); UniffiRustFuture latest(); @@ -13551,7 +13706,7 @@ class FfiConverterMoqBroadcastRequest { abstract class MoqClaimInterface { void announce(); void cancel(); - String instanceId(); + MoqInstance instanceId(); UniffiRustFuture requested(); void setCost({required int cost}); void withdraw(); @@ -13604,13 +13759,13 @@ class MoqClaim implements MoqClaimInterface { }, null); } - String instanceId() { + MoqInstance instanceId() { return rustCallWithLifter( (status) => uniffi_moq_ffi_fn_method_moqclaim_instance_id( uniffiClonePointer(), status, ), - FfiConverterString.lift, + FfiConverterMoqInstance.lift, null, ); } @@ -13763,10 +13918,10 @@ class FfiConverterMoqFollowing { abstract class MoqOriginConsumerInterface { MoqAnnounceConsumer announced({required MoqAnnounceConfig config}); - MoqFollowing follow({required String path, String? instance = null}); + MoqFollowing follow({required String path, MoqInstance? instance = null}); UniffiRustFuture request({ required String path, - String? instance = null, + MoqInstance? instance = null, }); } @@ -13819,12 +13974,12 @@ class MoqOriginConsumer implements MoqOriginConsumerInterface { ); } - MoqFollowing follow({required String path, String? instance = null}) { + MoqFollowing follow({required String path, MoqInstance? instance = null}) { return rustCallWithLifter( (status) => uniffi_moq_ffi_fn_method_moqoriginconsumer_follow( uniffiClonePointer(), FfiConverterString.lower(path), - FfiConverterOptionalString.lower(instance), + FfiConverterOptionalMoqInstance.lower(instance), status, ), FfiConverterMoqFollowing.lift, @@ -13834,13 +13989,13 @@ class MoqOriginConsumer implements MoqOriginConsumerInterface { UniffiRustFuture request({ required String path, - String? instance = null, + MoqInstance? instance = null, }) { return uniffiRustCallAsync( () => uniffi_moq_ffi_fn_method_moqoriginconsumer_request( uniffiClonePointer(), FfiConverterString.lower(path), - FfiConverterOptionalString.lower(instance), + FfiConverterOptionalMoqInstance.lower(instance), ), ffi_moq_ffi_rust_future_poll_u64, ffi_moq_ffi_rust_future_complete_u64, @@ -14000,7 +14155,7 @@ abstract class MoqRouteInterface { void announce(); MoqBroadcastProducer broadcast(); void cancel(); - String instanceId(); + MoqInstance? instanceId(); void setCost({required int cost}); void withdraw(); } @@ -14063,13 +14218,13 @@ class MoqRoute implements MoqRouteInterface { }, null); } - String instanceId() { + MoqInstance? instanceId() { return rustCallWithLifter( (status) => uniffi_moq_ffi_fn_method_moqroute_instance_id( uniffiClonePointer(), status, ), - FfiConverterString.lift, + FfiConverterOptionalMoqInstance.lift, null, ); } @@ -18505,6 +18660,53 @@ class FfiConverterOptionalMoqGroupConsumer { } } +class FfiConverterOptionalMoqInstance { + static MoqInstance? lift(RustBuffer buf) { + return FfiConverterOptionalMoqInstance.read(buf.asUint8List()).value; + } + + static LiftRetVal read(Uint8List buf) { + if (ByteData.view(buf.buffer, buf.offsetInBytes).getInt8(0) == 0) { + return LiftRetVal(null, 1); + } + final result = FfiConverterMoqInstance.read( + Uint8List.view(buf.buffer, buf.offsetInBytes + 1), + ); + return LiftRetVal(result.value, result.bytesRead + 1); + } + + static int allocationSize([MoqInstance? value]) { + if (value == null) { + return 1; + } + return FfiConverterMoqInstance.allocationSize(value) + 1; + } + + static RustBuffer lower(MoqInstance? value) { + if (value == null) { + return toRustBuffer(Uint8List.fromList([0])); + } + final buf = Uint8List( + FfiConverterOptionalMoqInstance.allocationSize(value), + ); + FfiConverterOptionalMoqInstance.write(value, buf); + return toRustBuffer(buf); + } + + static int write(MoqInstance? value, Uint8List buf) { + if (value == null) { + buf[0] = 0; + return 1; + } + buf[0] = 1; + return FfiConverterMoqInstance.write( + value, + Uint8List.view(buf.buffer, buf.offsetInBytes + 1), + ) + + 1; + } +} + class FfiConverterOptionalMoqJsonValue { static MoqJsonValue? lift(RustBuffer buf) { return FfiConverterOptionalMoqJsonValue.read(buf.asUint8List()).value; @@ -20925,6 +21127,88 @@ external Pointer uniffi_moq_ffi_fn_method_moqtrackdemand_wanted_changed( Pointer ptr, ); +@Native Function(Pointer, Pointer)>( + assetId: _uniffiAssetId, +) +external Pointer uniffi_moq_ffi_fn_clone_moqinstance( + Pointer handle, + Pointer uniffiStatus, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external void uniffi_moq_ffi_fn_free_moqinstance( + Pointer handle, + Pointer uniffiStatus, +); + +@Native Function(RustBuffer, Pointer)>( + assetId: _uniffiAssetId, +) +external Pointer uniffi_moq_ffi_fn_constructor_moqinstance_parse( + RustBuffer text, + Pointer uniffiStatus, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external int uniffi_moq_ffi_fn_method_moqinstance_is_minted( + Pointer ptr, + Pointer uniffiStatus, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external RustBuffer uniffi_moq_ffi_fn_method_moqinstance_text( + Pointer ptr, + Pointer uniffiStatus, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external RustBuffer uniffi_moq_ffi_fn_method_moqinstance_uniffi_trait_debug( + Pointer ptr, + Pointer uniffiStatus, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external RustBuffer uniffi_moq_ffi_fn_method_moqinstance_uniffi_trait_display( + Pointer ptr, + Pointer uniffiStatus, +); + +@Native, Pointer, Pointer)>( + assetId: _uniffiAssetId, +) +external int uniffi_moq_ffi_fn_method_moqinstance_uniffi_trait_eq_eq( + Pointer ptr, + Pointer other, + Pointer uniffiStatus, +); + +@Native, Pointer, Pointer)>( + assetId: _uniffiAssetId, +) +external int uniffi_moq_ffi_fn_method_moqinstance_uniffi_trait_eq_ne( + Pointer ptr, + Pointer other, + Pointer uniffiStatus, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external int uniffi_moq_ffi_fn_method_moqinstance_uniffi_trait_hash( + Pointer ptr, + Pointer uniffiStatus, +); + @Native Function(Pointer, Pointer)>( assetId: _uniffiAssetId, ) @@ -21795,10 +22079,10 @@ external void uniffi_moq_ffi_fn_method_moqclaim_cancel( Pointer uniffiStatus, ); -@Native, Pointer)>( +@Native Function(Pointer, Pointer)>( assetId: _uniffiAssetId, ) -external RustBuffer uniffi_moq_ffi_fn_method_moqclaim_instance_id( +external Pointer uniffi_moq_ffi_fn_method_moqclaim_instance_id( Pointer ptr, Pointer uniffiStatus, ); @@ -23917,6 +24201,12 @@ external int uniffi_moq_ffi_checksum_method_moqtrackdemand_wanted(); @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqtrackdemand_wanted_changed(); +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqinstance_is_minted(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqinstance_text(); + @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqjsonsnapshotconsumer_cancel(); @@ -24497,6 +24787,9 @@ external int uniffi_moq_ffi_checksum_constructor_moqaudioproducer_new(); @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_constructor_moqratecontrol_new(); +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_constructor_moqinstance_parse(); + @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_constructor_moqjsonsnapshotconsumer_new(); @@ -24812,6 +25105,12 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqtrackdemand_wanted_changed() != 29509) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } + if (uniffi_moq_ffi_checksum_method_moqinstance_is_minted() != 53450) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqinstance_text() != 16268) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } if (uniffi_moq_ffi_checksum_method_moqjsonsnapshotconsumer_cancel() != 52062) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); @@ -24990,7 +25289,7 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqclaim_cancel() != 17885) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqclaim_instance_id() != 12635) { + if (uniffi_moq_ffi_checksum_method_moqclaim_instance_id() != 26697) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqclaim_requested() != 13700) { @@ -25011,10 +25310,10 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqoriginconsumer_announced() != 35661) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqoriginconsumer_follow() != 55889) { + if (uniffi_moq_ffi_checksum_method_moqoriginconsumer_follow() != 57020) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqoriginconsumer_request() != 40910) { + if (uniffi_moq_ffi_checksum_method_moqoriginconsumer_request() != 819) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqoriginproducer_claim() != 5226) { @@ -25035,7 +25334,7 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqroute_cancel() != 12780) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqroute_instance_id() != 37303) { + if (uniffi_moq_ffi_checksum_method_moqroute_instance_id() != 47013) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqroute_set_cost() != 30911) { @@ -25410,6 +25709,9 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_constructor_moqratecontrol_new() != 63053) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } + if (uniffi_moq_ffi_checksum_constructor_moqinstance_parse() != 18674) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } if (uniffi_moq_ffi_checksum_constructor_moqjsonsnapshotconsumer_new() != 47962) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); diff --git a/doc/concept/identity.md b/doc/concept/identity.md index 9dac288466a..3a401cbfb4f 100644 --- a/doc/concept/identity.md +++ b/doc/concept/identity.md @@ -22,8 +22,8 @@ and incarnation that shares it. Two instances are two independent streams, never stitched together. An instance names a run, not who publishes it: it is no credential, and auth decides who may publish the path. -- **A new stream takes a new instance.** Every broadcast mints a random one - when it is created and keeps it while it is withdrawn and announced again or +- **A new stream takes a new instance.** A broadcast that names no instance + generates a random one when it is created and keeps it while it is withdrawn and announced again or reconnects, since its numbering continues. A restarted process, or anything that starts its group numbers over, is a new broadcast with a new instance. Replaying a recording is a new stream too: [`moq import archive`](/bin/cli#archive) @@ -56,7 +56,7 @@ one is, so it fences out an older run. Nothing assumes it is a clock. | Situation | Instance | What readers see | | --- | --- | --- | -| One broadcast, reconnecting | The one it minted | The same stream; subscriptions resume | +| One broadcast, reconnecting | The one it generated | The same stream; subscriptions resume | | One stream carried by several relays or CDNs | Shared by every route | Failover resumes from the first missing frame | | A restarted or replaced publisher | A fresh one | A restart, then the new stream from its start | | A publisher that continues its sequences across restarts | Stable, with a higher epoch each run | The same stream; readers continue without a seam | @@ -92,11 +92,11 @@ order, and so does every relay deciding what to advertise. Instances and epochs travel only over moq-lite 07 (`moq-lite-07-wip`, opt-in). Older moq-lite versions and moq-transport carry neither, so the receiving session mints an instance for each announcement it receives, with -epoch 0. A minted instance starts with `~`, so a publisher naming a stable -instance should not start it with `~`: nothing refuses one, since a relay -forwarding a route keeps the instance it received, but every node then treats -it as minted. A minted instance names the announcement, not the publisher's -stream, so: +epoch 0. Whether an instance was minted is a flag beside it +(`Instance::is_minted` in Rust, `Instance.isMinted` in JavaScript) that only a +session sets: parsing text always names an instance, whatever the text, and +moq-lite 07 carries the flag, so a relay forwarding a route keeps it. A minted +instance names the announcement, not the publisher's stream, so: - An announcement ending and starting again, such as a job re-announced on one session, is a new stream, never spliced to the earlier one. @@ -105,6 +105,9 @@ stream, so: an older hop. - A catalog reference naming an instance cannot be checked against a minted one, so there it is best effort. +- To pin a request to a minted instance, pass back the instance the route + reported. Its text parses to a different, named instance, which never + resolves to that route. - A pool reached through an older-lite relay hop can pick different winners per relay, since each relay mints its own instances. Use moq-lite 07 between relays. diff --git a/doc/concept/moq-lite.md b/doc/concept/moq-lite.md index e3a246bc529..d0d096eadee 100644 --- a/doc/concept/moq-lite.md +++ b/doc/concept/moq-lite.md @@ -144,7 +144,7 @@ epoch, and `TRACK`, `TRACKS`, `SUBSCRIBE` and `FETCH` name the instance they pin. A restart onto another instance, or a higher epoch of the same one, is advertised before the old one is withdrawn, so the path is never absent. Older moq-lite versions and moq-transport carry no instance, so the receiving session -mints one for each announcement, marked with a leading `~`: a reconnect or a +mints one for each announcement, flagged as minted: a reconnect or a re-announcement through such a hop is another stream, and the first subscription through a claim is reset once the output's route arrives. A peer that ignores the end and start may keep cached objects of the old run under diff --git a/doc/lib/c/index.md b/doc/lib/c/index.md index 7bf11315f0f..1402c5a7147 100644 --- a/doc/lib/c/index.md +++ b/doc/lib/c/index.md @@ -96,8 +96,15 @@ answer with `moq_broadcast_request_serve()`, `moq_broadcast_request_route()`, or `moq_claim_set_cost()`, and `moq_claim_free()` control the claim. Unless one is named, a claim moves to a fresh instance (`moq_claim_instance()`) each time a job holding its current one ends, and a job from `moq_broadcast_request_route()` takes the claim's, so on moq-lite 07 a reader resolved through the -claim does not restart; older wires mint another instance for the job's route, restarting it. The string `moq_route_instance()` or `moq_claim_instance()` writes is -borrowed until the next such call on that handle, or until it is freed. +claim does not restart; older wires mint another instance for the job's route, restarting it. + +An instance is a handle: `moq_route_instance()` and `moq_claim_instance()` return one you free +with `moq_instance_free()`, and an announcement's `route.instance` lives as long as the +announcement. `moq_instance_parse()` names one from text; `moq_instance_text()`, +`moq_instance_minted()`, and `moq_instance_equal()` read it. A session mints an instance for a +route whose wire (moq-lite 06 or older, or moq-transport) carries none, and only a session can, +so pass the handle a route or announcement reported, not its text, to pin +`moq_origin_request()` or `moq_origin_follow()` to that stream. Raw video encoders take an exact numerator/denominator frame rate, such as 30000/1001. Publication track names stay separate from codec settings. For simulcast, give each rendition the next one down as the `lower` handle in its `moq_video_encode_options`: it stops encoding, advertised as disabled, while its bandwidth grant cannot fund it, and resumes once the grant recovers. A rung only disables while the session probes for bandwidth, which on native transports takes moq-quic with BBR3. For pre-encoded video, publish from the lowest rendition up and set `moq_video_init.lower` to the next lower media handle. Reserve each track with `moq_allocator_reserve_media`, then bind its reservation with `moq_rate_control_create` and call `moq_rate_control_update` on every frame. Apply changed targets to your encoder and `moq_publish_media_set_enabled`, resuming video on a keyframe; close the control and reservation separately when done. diff --git a/doc/lib/cpp/index.md b/doc/lib/cpp/index.md index e5043ef7b28..fef1c6f21c1 100644 --- a/doc/lib/cpp/index.md +++ b/doc/lib/cpp/index.md @@ -93,7 +93,7 @@ void ok(moq::expected result) { // Subscribe. auto client = ok(moq::Client::init({})); // a moq::ClientConfig; {} takes every default auto session = ok(client->connect("https://relay.example.com").get()); -auto announced = ok(session->consume()->follow("my-stream.hang", std::nullopt)); +auto announced = ok(session->consume()->follow("my-stream.hang", nullptr)); auto broadcast = ok(announced->available().get()); // the first call is never null auto catalogs = ok(moq::MediaCatalogConsumer::subscribe(broadcast).get()); if (auto catalog = ok(catalogs->next().get())) { // std::nullopt once the catalog ends diff --git a/drafts/draft-lcurley-moq-hang.md b/drafts/draft-lcurley-moq-hang.md index d1182edd90a..c61e9acf4aa 100644 --- a/drafts/draft-lcurley-moq-hang.md +++ b/drafts/draft-lcurley-moq-hang.md @@ -756,7 +756,7 @@ It MUST fail the rendition rather than fall back to another stream, including on A later catalog update naming another Instance for an already-resolved path MUST NOT replace the resolved broadcast; the renditions naming the other Instance fail instead. A new catalog instance resolves its references afresh. -A route that crossed an older moq-lite version or MoQ Transport carries an Instance its receiver minted, starting with `~` {{moql}}, which names the announcement rather than the publisher's stream, so there `instance` is best effort: the consumer reads the broadcast it resolved. +A route that crossed an older moq-lite version or MoQ Transport carries an Instance its receiver minted {{moql}}, which names the announcement rather than the publisher's stream, so there `instance` is best effort: the consumer reads the broadcast it resolved. A consumer MUST NOT treat a catalog's `instance` as evidence that a subscription may resume across routes. A consumer that predates the field ignores it and does not gain its guarantee. diff --git a/drafts/draft-lcurley-moq-lite.md b/drafts/draft-lcurley-moq-lite.md index f945112a02b..84de261cdbe 100644 --- a/drafts/draft-lcurley-moq-lite.md +++ b/drafts/draft-lcurley-moq-lite.md @@ -523,8 +523,7 @@ Claims with the same Instance serve the same Broadcast, so a relay MAY move a li A relay MUST NOT move a subscription to a claim with a different Instance: it stays on its Instance's claims and ends with the last of them. When another Broadcast wins the path, subscriptions already open stay on theirs until the subscriber drops them or the claim goes; new requests resolve the winner, and the ANNOUNCE_RESTART tells subscribers to request again. A receiver of an ANNOUNCE_RESTART (or of the ANNOUNCE_END and ANNOUNCE_START standing in for one) MUST NOT serve a new request under its prefix from anything resolved through the replaced claim. -A version before this one carries no Instance, so a receiver of such a session mints one for each ANNOUNCE_START it receives, with Epoch 0, and forwards it as the claim's Instance. -A minted Instance SHOULD start with `~` (0x7E), so a reader can tell it names an announcement rather than the publisher's stream; a publisher SHOULD NOT name its own Instance with that prefix. +A version before this one carries no Instance, so a receiver of such a session mints one for each ANNOUNCE_START it receives, with Epoch 0, and forwards it as the claim's Instance with `Minted` set, so a reader can tell it names an announcement rather than the publisher's stream. A minted Instance is new for every announcement, so a publisher reconnecting or re-announcing through such a hop is another stream. #### Resolution {#resolution} @@ -1273,6 +1272,7 @@ ANNOUNCE_START Message { Claim Prefix Suffix (s), Route ID (i), Instance (b), + Minted (8), Epoch (i), Announce Cost (i), [Lite-06 Chain (..)], @@ -1298,6 +1298,12 @@ The stream the claim serves (see [Broadcast](#broadcast)): non-empty UTF-8 text. An empty or non-UTF-8 Instance is a PROTOCOL_VIOLATION. A relay forwards the Instance unchanged. +**Minted**: +1 when a receiver minted the Instance for a version that carries none (see [Routing](#routing)), 0 when the publisher named it. +Any other value is a PROTOCOL_VIOLATION. +A relay forwards it unchanged. +An Instance minted and one named with the same text are different Instances. + **Epoch**: Which incarnation of the Instance the claim serves: a counter the publisher raises within one Instance, 0 unless it keeps an Instance across restarts. A relay forwards the Epoch unchanged. @@ -1375,6 +1381,7 @@ ANNOUNCE_RESTART Message { Message Length (i) Announce ID (i), Instance (b), + Minted (8), Epoch (i), Announce Cost (i), [Lite-06 Chain (..)], @@ -1387,7 +1394,7 @@ Set to 0x3 to indicate an ANNOUNCE_RESTART message. **Announce ID**: As defined for [ANNOUNCE_END](#announce-end). -**Instance**, **Epoch**, **Announce Cost**, and **Lite-06 Chain**: +**Instance**, **Minted**, **Epoch**, **Announce Cost**, and **Lite-06 Chain**: As defined for [ANNOUNCE_START](#announce-start), replacing the claim's. ## ANNOUNCE_FLOOR {#announce-floor} @@ -1421,6 +1428,7 @@ SUBSCRIBE Message { Subscribe ID (i) Broadcast Path (s) Instance (b) + Minted (8) Node ID (i) Track Name (s) Subscriber Priority (8) @@ -1450,7 +1458,7 @@ Anchor { A unique identifier chosen by the subscriber. A Subscribe ID MUST NOT be reused within the same session, even if the prior subscription has been closed. -**Instance** and **Node ID**: +**Instance**, **Minted**, and **Node ID**: As in [TRACK](#track). **Subscriber Priority**: @@ -1568,6 +1576,7 @@ TRACK Message { Message Length (i) Broadcast Path (s) Instance (b) + Minted (8) Node ID (i) Track Name (s) } @@ -1576,10 +1585,10 @@ TRACK Message { **Broadcast Path**: The broadcast path of the track. -**Instance**: -The stream the subscriber expects, encoded as in [ANNOUNCE_START](#announce-start), or empty for whichever wins the path. +**Instance** and **Minted**: +The stream the subscriber expects, encoded as in [ANNOUNCE_START](#announce-start), or an empty Instance with Minted 0 for whichever wins the path; an empty Instance with Minted 1 is a PROTOCOL_VIOLATION. A publisher MUST refuse a request with UNROUTABLE when no claim of that Instance serves the path, so a request is never answered by a different stream than the one it named. -A relay fills in the Instance of the claim it resolved when forwarding a request upstream; TRACKS, SUBSCRIBE, FETCH, and DATAGRAMS carry it the same way. +A relay fills in the Instance and Minted of the claim it resolved when forwarding a request upstream; TRACKS, SUBSCRIBE, FETCH, and DATAGRAMS carry them the same way. **Node ID**: The origin node that serves the request, as the publisher names it on this session, or 0 for whichever the publisher selects (see [Routing](#routing)). @@ -1650,6 +1659,7 @@ TRACKS Message { Message Length (i) Broadcast Path (s) Instance (b) + Minted (8) Node ID (i) } ~~~ @@ -1657,7 +1667,7 @@ TRACKS Message { **Broadcast Path**: The path of the Broadcast to list. -**Instance** and **Node ID**: +**Instance**, **Minted**, and **Node ID**: As in [TRACK](#track). The listing is pinned to the stream that answers it and ends with that stream. @@ -1717,6 +1727,7 @@ DATAGRAMS Message { Subscribe ID (i) Broadcast Path (s) Instance (b) + Minted (8) Node ID (i) Track Name (s) Subscriber Priority (8) @@ -1925,6 +1936,7 @@ FETCH Message { Message Length (i) Broadcast Path (s) Instance (b) + Minted (8) Node ID (i) Track Name (s) Subscriber Priority (8) @@ -1938,7 +1950,7 @@ FETCH Message { **Broadcast Path**: The broadcast path of the track to fetch from. -**Instance** and **Node ID**: +**Instance**, **Minted**, and **Node ID**: As in [TRACK](#track). **Track Name**: @@ -2213,7 +2225,7 @@ The `Message Length` describes the payload size on the wire. - Added `Instance` and `Epoch` to ANNOUNCE_START and ANNOUNCE_RESTART, and `Instance` to TRACK, TRACKS, SUBSCRIBE, and FETCH. The Instance is non-empty UTF-8 text naming one continuous stream, and a path and an Instance name one Broadcast; the Epoch is a counter within one Instance. A relay MAY move a subscription between claims with the same Instance, continuing from the first frame the subscriber lacks instead of at a group boundary, and never between claims with different Instances. Within one Instance only its highest Epoch is eligible. A request's Instance is empty when it pins none. Replaces the first-hop identity. - Added ANNOUNCE_RESTART (0x3): a live advertisement now serves another Instance, or a higher Epoch of the same one. - An advertiser MAY announce a path it answers through a claim under the claim's Instance, so subscriptions resolved through the claim continue, and a claim still serving that Instance when the announcement ends restarts under a new one. -- A receiver mints an Instance, starting with `~`, for each announcement a session of an earlier version delivers. +- A receiver mints an Instance for each announcement a session of an earlier version delivers, and a `Minted` field beside every Instance (ANNOUNCE_START, ANNOUNCE_RESTART, TRACK, TRACKS, SUBSCRIBE, FETCH, and DATAGRAMS) says whether a receiver minted it, so no Instance text is reserved for minting. - Added announce compression: ANNOUNCE_START gains `Path Base` and `Path Keep` to copy the head of a live claim's suffix. - Split routing into two layers on the Announce Stream. ROUTE_START (0x6), ROUTE_UPDATE (0x7), and ROUTE_END (0x8) advertise how the publisher reaches an origin node, named by a Node ID, with a seqno, an additive metric, and a down-only bit, and loops are prevented by Babel's feasibility condition. Every ANNOUNCE_START names its route's Route ID in place of the Hop ID list, so a link change sends one ROUTE_UPDATE per origin rather than an update per claim. Removed the Hop ID list and ANNOUNCE_OK's Hop ID, and replaced the SETUP Hop parameter (0x5) with a [Node ID parameter](#node-id-parameter), so an endpoint that reaches itself detects it at setup. - Added ROUTE_HOLD (0xB), which holds a route while it recovers; ROUTE_END lets it go. Neither withdraws the origin's claims, and the subscriber echoes every ROUTE_END back. diff --git a/flake.nix b/flake.nix index fa9e3567f6d..476b8bfea12 100644 --- a/flake.nix +++ b/flake.nix @@ -424,21 +424,22 @@ # This points at a fork of LiveKit's async branch (livekit/uniffi-bindgen-cpp # PR #1): neither LiveKit nor NordSecurity has a uniffi 0.32 generator, # and the fork adds `error_style = "expected"`, `strip_type_prefix`, and - # `strip_function_prefix`, which cpp/moq/uniffi.toml turns on. Its tags + # `strip_function_prefix`, which cpp/moq/uniffi.toml turns on, and C + # functions for an object's exported traits (`moq_instance_eq`). Its tags # add a `-kixelated.N` pre-release so they never collide with upstream's. # Move back upstream once one tags both. uniffi-bindgen-cpp = pkgs.rustPlatform.buildRustPackage rec { pname = "uniffi-bindgen-cpp"; - version = "0.11.0-kixelated.7+v0.32.2"; + version = "0.11.0-kixelated.8+v0.32.2"; src = pkgs.fetchFromGitHub { owner = "kixelated"; repo = "uniffi-bindgen-cpp"; rev = "v${version}"; - hash = "sha256-nErLn2hQus5omIaFheJWy06wKQS9HiKNtksxaJm9Z8k="; + hash = "sha256-ziqwP54PsSOmOzZXax6WZDtrnEtwzyMhTHD1g7aPraM="; }; - cargoHash = "sha256-fz7fn6D12zmFFSVSdySDfNRtQe+n4KCB9bXAsuvq/QY="; + cargoHash = "sha256-jWMxmzxU815OhdDnoVfIdOkOC+HijX/gs6BOdDIDTyY="; # The workspace's other member is the fixture crate, which pulls the # uniffi examples in from git; build only the generator. diff --git a/go/wrapper/client.go b/go/wrapper/client.go index dcd4ddead79..61e1ccef700 100644 --- a/go/wrapper/client.go +++ b/go/wrapper/client.go @@ -314,7 +314,7 @@ func (c *Client) Announced(options AnnounceOptions) (*AnnounceConsumer, error) { // Follow follows path, resolving each stream that serves it. A non-nil // instance follows only that stream. -func (c *Client) Follow(path string, instance *string) (*Following, error) { +func (c *Client) Follow(path string, instance *Instance) (*Following, error) { return c.consumer.Follow(path, instance) } @@ -323,7 +323,7 @@ func (c *Client) Follow(path string, instance *string) (*Following, error) { // route, or a dynamic fallback on the origin, or an error. Unlike Follow, it // does not wait for a future announcement. A non-nil instance refuses any other // stream. -func (c *Client) Request(ctx context.Context, path string, instance *string) (*BroadcastConsumer, error) { +func (c *Client) Request(ctx context.Context, path string, instance *Instance) (*BroadcastConsumer, error) { return c.consumer.Request(ctx, path, instance) } diff --git a/go/wrapper/moq_test.go b/go/wrapper/moq_test.go index 6fbe347132b..4ed433bac8d 100644 --- a/go/wrapper/moq_test.go +++ b/go/wrapper/moq_test.go @@ -92,7 +92,7 @@ func TestDynamicBroadcastRequest(t *testing.T) { t.Fatal(err) } defer runtime.KeepAlive(served) - if served.Instance() != dynamic.Instance() { + if !served.Instance().Eq(dynamic.Instance()) { t.Fatal("a job's route continues the instance its claim lends") } broadcast := served.Broadcast() @@ -1639,7 +1639,7 @@ func TestBroadcastIsReachableOnlyWhileAnnounced(t *testing.T) { if err := route.Announce(); err != nil { t.Fatal(err) } - if ann := nextAnnounced(t, ctx, announced); ann.Route.Instance != route.Instance() { + if ann := nextAnnounced(t, ctx, announced); !ann.Route.Instance.Eq(route.Instance()) { t.Fatalf("reannounce: ann=%+v, want instance %s", ann, route.Instance()) } if _, err := consumer.Request(ctx, "live", nil); err != nil { @@ -1747,9 +1747,9 @@ func TestNewRouteReachesFollowers(t *testing.T) { defer following.Cancel() route, _ := newRoute(t, origin, "instance", moq.RouteOptions{}) - minted := nextAnnounced(t, ctx, announced).Route.Instance - if minted == "" || minted != route.Instance() { - t.Fatalf("present instance = %q, want the minted %q", minted, route.Instance()) + fresh := nextAnnounced(t, ctx, announced).Route.Instance + if !fresh.Eq(route.Instance()) || fresh.IsMinted() { + t.Fatalf("present instance = %v, want the route's own %v", fresh, route.Instance()) } if _, err := following.Available(ctx); err != nil { t.Fatal(err) @@ -1758,8 +1758,8 @@ func TestNewRouteReachesFollowers(t *testing.T) { if err := route.SetCost(1); err != nil { t.Fatal(err) } - stable := "cam" - second, _ := newRoute(t, origin, "instance", moq.RouteOptions{Instance: &stable, Epoch: 1}) + stable := parseInstance(t, "cam") + second, _ := newRoute(t, origin, "instance", moq.RouteOptions{Instance: stable, Epoch: 1}) if _, err := following.Available(ctx); err != nil { t.Fatalf("follow after the instance change: %v", err) } @@ -1773,14 +1773,9 @@ func TestNewRouteReachesFollowers(t *testing.T) { t.Fatal(err) } event := nextRoute(t, ctx, announced) - if absent, ok := event.(moq.AnnounceEventAbsent); !ok || absent.Announce.Route.Instance != stable { + if absent, ok := event.(moq.AnnounceEventAbsent); !ok || !absent.Announce.Route.Instance.Eq(stable) { t.Fatalf("event = %+v, want absent with instance %s", event, stable) } - - empty := "" - if _, err := origin.Route("refused", moq.RouteOptions{Instance: &empty}); !errors.Is(err, moq.ErrInvalidInstance) { - t.Fatalf("Route(empty instance) = %v, want ErrInvalidInstance", err) - } } // A follower reports the path going offline as nil, then the next broadcast once it returns. @@ -1821,25 +1816,110 @@ func TestPinnedFollowReportsTakeoverOffline(t *testing.T) { defer cancel() origin := newOrigin(t) - pinned := "cam" - following, err := origin.Consume().Follow("pinned", &pinned) + pinned := parseInstance(t, "cam") + following, err := origin.Consume().Follow("pinned", pinned) if err != nil { t.Fatal(err) } defer following.Cancel() - newRoute(t, origin, "pinned", moq.RouteOptions{Instance: &pinned}) + newRoute(t, origin, "pinned", moq.RouteOptions{Instance: pinned}) if broadcast, err := following.Available(ctx); err != nil || broadcast == nil { t.Fatalf("first Available = %v, %v; want a broadcast", broadcast, err) } - other := "other" - newRoute(t, origin, "pinned", moq.RouteOptions{Instance: &other, Epoch: 1}) + other := parseInstance(t, "other") + newRoute(t, origin, "pinned", moq.RouteOptions{Instance: other, Epoch: 1}) if broadcast, err := following.Available(ctx); err != nil || broadcast != nil { t.Fatalf("Available after takeover = %v, %v; want nil", broadcast, err) } } +// Callers only name instances: the same text names the same one, and empty text names none. +func TestParseInstance(t *testing.T) { + cam := parseInstance(t, "cam") + if cam.Text() != "cam" || cam.String() != "cam" || cam.IsMinted() { + t.Fatalf("parsed = %v (minted %v), want the named cam", cam, cam.IsMinted()) + } + again := parseInstance(t, "cam") + if !cam.Eq(again) || cam.Hash() != again.Hash() || cam.Eq(parseInstance(t, "other")) { + t.Fatal("instances compare by text and the minted flag") + } + if _, err := moq.ParseInstance(""); !errors.Is(err, moq.ErrInvalidInstance) { + t.Fatalf("ParseInstance(empty) = %v, want ErrInvalidInstance", err) + } +} + +// Over a wire that carries no instance, the session mints the announced one, and +// passing it back as is pins Request and Follow to that stream. +func TestMintedInstancePinsRequestAndFollow(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + const version = "moq-lite-06" + origin := newOrigin(t) + server, err := moq.Listen(ctx, "127.0.0.1:0", moq.WithTLSGenerate("localhost"), moq.WithServerVersions(version), moq.WithServerPublishOrigin(origin)) + if err != nil { + t.Fatal(err) + } + defer server.Close() + go func() { _ = server.Serve(ctx) }() + newRoute(t, origin, "live", moq.RouteOptions{}) + + client, err := moq.Dial(ctx, "https://"+server.LocalAddr(), moq.ClientConfig{ + TLS: moq.ClientTLS{Insecure: true}, + Versions: []string{version}, + Once: true, + }) + if err != nil { + t.Fatal(err) + } + defer client.Close() + + announced, err := client.Announced(moq.AnnounceOptions{}) + if err != nil { + t.Fatal(err) + } + defer announced.Cancel() + minted := nextAnnounced(t, ctx, announced).Route.Instance + if !minted.IsMinted() { + t.Fatalf("announced instance %v over %s is not minted", minted, version) + } + + if _, err := client.Request(ctx, "live", minted); err != nil { + t.Fatalf("Request(minted) = %v", err) + } + following, err := client.Follow("live", minted) + if err != nil { + t.Fatal(err) + } + defer following.Cancel() + if broadcast, err := following.Available(ctx); err != nil || broadcast == nil { + t.Fatalf("Available(minted) = %v, %v; want a broadcast", broadcast, err) + } + + // The same text, named, is another instance, which nothing serves. + named := parseInstance(t, minted.Text()) + if named.Eq(minted) || named.IsMinted() { + t.Fatalf("parsed %v equals the minted %v", named, minted) + } + _, err = client.Request(ctx, "live", named) + if protocol, ok := moq.ProtocolError(err); !ok || protocol.Kind != moq.ProtocolKindUnroutable { + t.Fatalf("Request(named) = %v, want Unroutable", err) + } +} + +// parseInstance names an instance, failing the test on an error. +func parseInstance(t *testing.T, text string) *moq.Instance { + t.Helper() + + instance, err := moq.ParseInstance(text) + if err != nil { + t.Fatal(err) + } + return instance +} + // nextRoute returns the next announce event. func nextRoute(t *testing.T, ctx context.Context, announced *moq.AnnounceConsumer) moq.AnnounceEvent { t.Helper() diff --git a/go/wrapper/origin.go b/go/wrapper/origin.go index 44de3326386..c616cf86936 100644 --- a/go/wrapper/origin.go +++ b/go/wrapper/origin.go @@ -35,6 +35,36 @@ func (o *OriginProducer) Consume() *OriginConsumer { return &OriginConsumer{inner: o.inner.Consume()} } +// RouteOptions is how a [Route] or [Claim] advertises itself. The zero value is visible at +// once, at cost 0, under a fresh instance the route keeps for its lifetime, so a restarted +// publisher is a new stream viewers restart onto. +type RouteOptions struct { + // Hidden creates the route invisible and unroutable until [Route.Announce]. + Hidden bool + // Cost is the static production cost: lower wins among routes covering the same prefix. + Cost uint64 + // Instance is a stable stream to serve, or nil to mint a fresh one. Set it only for a + // publisher that continues its group sequences across restarts and raises Epoch on each, + // or to forward an announced route's stream as is: readers stitch every route of one + // instance together, so restarting the sequences under an old instance splices unrelated + // groups into their tracks. + Instance *Instance + // Epoch is which run of a stable Instance this is: only the highest is eligible. Ignored without one. + Epoch uint64 +} + +func (o RouteOptions) ffi() ffi.MoqRouteOptions { + return ffi.MoqRouteOptions{Hidden: o.Hidden, Cost: o.Cost, Instance: optional(o.Instance), Epoch: o.Epoch} +} + +// optional lowers a nil instance to the ffi's None. +func optional(instance *Instance) **ffi.MoqInstance { + if instance == nil { + return nil + } + return &instance +} + // Route creates a route at exactly path, owning a new broadcast to fill. // // The route is visible at once unless options.Hidden: a hidden route is @@ -44,7 +74,7 @@ func (o *OriginProducer) Consume() *OriginConsumer { // options names one. Keep the route reachable: [Route.Cancel], or the collector // reclaiming it, retracts it, and [BroadcastProducer.Close] ends both. func (o *OriginProducer) Route(path string, options RouteOptions) (*Route, error) { - inner, err := o.inner.Route(path, options) + inner, err := o.inner.Route(path, options.ffi()) if err != nil { return nil, err } @@ -58,7 +88,7 @@ func (o *OriginProducer) Route(path string, options RouteOptions) (*Route, error // Answer each [Claim.Requested] request, and keep the claim reachable while it // should stay advertised. func (o *OriginProducer) Claim(prefix string, options RouteOptions) (*Claim, error) { - inner, err := o.inner.Claim(prefix, options) + inner, err := o.inner.Claim(prefix, options.ffi()) if err != nil { return nil, err } @@ -88,9 +118,13 @@ func (r *Route) Broadcast() *BroadcastProducer { // changes. A job's route takes its claim's current one when it first announces // or when this first reads it. Read first, the job announces under it, and // [Route.Announce] fails with Unroutable once the claim has moved past it, so a -// signer built from it stays valid. -func (r *Route) Instance() string { - return r.inner.InstanceId() +// signer built from it stays valid. Once cancelled, it is the last one read or +// announced, or nil for a hidden route that did neither. +func (r *Route) Instance() *Instance { + if instance := r.inner.InstanceId(); instance != nil { + return *instance + } + return nil } // Announce makes the route visible, or keeps it so. A route from @@ -132,7 +166,7 @@ type Claim struct { // Instance returns the stream this claim serves now. A named instance never // changes. Unnamed, the claim moves to a fresh one each time a job holding its // current one ends. -func (c *Claim) Instance() string { +func (c *Claim) Instance() *Instance { return c.inner.InstanceId() } @@ -202,7 +236,7 @@ func (r *BroadcastRequest) Path() (string, error) { // the waiting requesters; created hidden, they wait // until [Route.Announce], so the job can fill its catalog first. func (r *BroadcastRequest) Route(options RouteOptions) (*Route, error) { - inner, err := r.inner.Route(options) + inner, err := r.inner.Route(options.ffi()) if err != nil { return nil, err } @@ -252,8 +286,8 @@ func (o *OriginConsumer) Announced(options AnnounceOptions) (*AnnounceConsumer, // Follow follows an exact path, resolving each stream that serves it and reporting // when it goes offline. A non-nil instance follows only that stream: another one // taking over reads as offline, and the stream is handed out again if it returns. -func (o *OriginConsumer) Follow(path string, instance *string) (*Following, error) { - inner, err := o.inner.Follow(path, instance) +func (o *OriginConsumer) Follow(path string, instance *Instance) (*Following, error) { + inner, err := o.inner.Follow(path, optional(instance)) if err != nil { return nil, err } @@ -267,8 +301,8 @@ func (o *OriginConsumer) Follow(path string, instance *string) (*Following, erro // serve it, including a broadcast created but not announced. Unlike Follow, it // does not wait for a future announcement. A non-nil instance refuses any // other stream. Blocks until resolved. -func (o *OriginConsumer) Request(ctx context.Context, path string, instance *string) (*BroadcastConsumer, error) { - inner, err := o.inner.Request(ctx, path, instance) +func (o *OriginConsumer) Request(ctx context.Context, path string, instance *Instance) (*BroadcastConsumer, error) { + inner, err := o.inner.Request(ctx, path, optional(instance)) if err != nil { return nil, err } diff --git a/go/wrapper/types.go b/go/wrapper/types.go index c2661a307be..f12ff58d20e 100644 --- a/go/wrapper/types.go +++ b/go/wrapper/types.go @@ -29,16 +29,11 @@ type ( ProtocolKind = ffi.MoqProtocolKind // OriginConfig configures a new origin, such as its maximum cache size in bytes. OriginConfig = ffi.MoqOriginConfig - // RouteOptions is how a [Route] advertises itself: Hidden until [Route.Announce], its static - // production Cost (lower wins) and, optionally, a stable Instance. The zero value is visible at - // once, at cost 0, under a fresh instance the route keeps for its lifetime, so a restarted - // publisher is a new stream viewers restart onto. Set Instance only for a publisher that - // continues its group sequences across restarts and raises Epoch on each: readers stitch every - // route of one instance together, so restarting the sequences under an old instance splices - // unrelated groups into their tracks. - RouteOptions = ffi.MoqRouteOptions // RouteInfo is the read-only facts on a received route: the Instance of the stream it serves (routes with one instance are one continuous stream) and its static production and link cost (lower wins). RouteInfo = ffi.MoqRouteInfo + // Instance names one continuous stream at a path. Compare two with Eq: a session mints one + // for a route whose wire carries none, and it differs from a named instance with the same Text. + Instance = ffi.MoqInstance // Announce is a route over a prefix: the origin-relative Prefix, what each filter // wildcard matched (Captures), and the RouteInfo serving it. It carries no broadcast; // resolve a path with [OriginConsumer.Request]. Captures is a required slice, @@ -170,6 +165,11 @@ const ( CongestionControlNone = ffi.MoqCongestionControlNone ) +// ParseInstance names an instance with text, failing with ErrInvalidInstance when it is empty. +func ParseInstance(text string) (*Instance, error) { + return ffi.MoqInstanceParse(text) +} + // Versions lists the protocol versions offered by default, most preferred // first, spelled the way ClientConfig.Versions takes them. func Versions() []string { diff --git a/js/crypto/src/e2ee/epoch.ts b/js/crypto/src/e2ee/epoch.ts index cffee22f9dd..5d54fca16b0 100644 --- a/js/crypto/src/e2ee/epoch.ts +++ b/js/crypto/src/e2ee/epoch.ts @@ -2,13 +2,13 @@ * One publisher instance's generation epoch, the lowercase hyphenated text of a UUIDv7. The * instance announces it as its route's instance, so a subscriber reads it back from the * broadcast it resolved ({@link of}). A wire that carries no instance hands the subscriber one - * its relay minted, which is no epoch: the application supplies the epoch over its own + * its session minted, which is no epoch: the application supplies the epoch over its own * authenticated channel then. Mirrors `moq_crypto::e2ee::Epoch`. * * @module */ -import type { Broadcast, Instance } from "@moq/net"; +import { type Broadcast, Instance } from "@moq/net"; import { v7 } from "uuid"; /** A validated UUIDv7, ordered newest last by ordinary string comparison. */ @@ -36,7 +36,8 @@ export function instance(epoch: Valid): Instance.Valid { /** The epoch a resolved broadcast's route announced, if its instance is one. */ export function of(broadcast: Broadcast.Consumer): Valid | undefined { const instance = broadcast.instance; - if (instance === undefined) return undefined; + // One a session minted for a wire without instances names no epoch. + if (instance === undefined || Instance.isMinted(instance)) return undefined; try { return parse(instance); } catch { diff --git a/js/crypto/src/sign/scope.ts b/js/crypto/src/sign/scope.ts index 9f900f41480..cfeb63b4a78 100644 --- a/js/crypto/src/sign/scope.ts +++ b/js/crypto/src/sign/scope.ts @@ -1,5 +1,5 @@ // The signed message: what a frame's signature binds besides its payload. -import type { Instance, Path, Time } from "@moq/net"; +import { Instance, type Path, type Time } from "@moq/net"; import { Failure } from "./failure.ts"; import type { PublicKey, SecretKey } from "./key.ts"; @@ -16,7 +16,7 @@ export class Scope { #prefix: Uint8Array; constructor(path: Path.Valid, instance: Instance.Valid, track: string) { - const fields = [path, instance, track].map((field) => encoder.encode(field)); + const fields = [path, Instance.text(instance), track].map((field) => encoder.encode(field)); this.#prefix = new Uint8Array(LABEL.byteLength + fields.reduce((sum, field) => sum + 8 + field.byteLength, 0)); const view = new DataView(this.#prefix.buffer); this.#prefix.set(LABEL); diff --git a/js/hang/src/catalog/path.ts b/js/hang/src/catalog/path.ts index 28b68289c6f..ad1ccce349d 100644 --- a/js/hang/src/catalog/path.ts +++ b/js/hang/src/catalog/path.ts @@ -31,11 +31,14 @@ export type RelativeBroadcast = string & { __brand: "Relative" }; /** * Zod schema for the stream a catalog reference names: an entry's `instance` beside its - * `broadcast`. Any non-empty text, the same text MoQ routes carry; an empty one refuses the - * catalog. + * `broadcast`. Any non-empty text, the same text MoQ routes carry, naming the publisher's + * stream; an empty or ill-formed one (a lone surrogate) refuses the catalog. */ export const InstanceSchema: z.ZodMiniType = z.pipe( - z.string().check(z.minLength(1, "expected a non-empty instance")), + z.string().check( + z.minLength(1, "expected a non-empty instance"), + z.refine((text) => text.isWellFormed(), "expected well-formed instance text"), + ), z.transform((text) => text as ReferenceInstance), ); diff --git a/js/net/api/index.api.md b/js/net/api/index.api.md index c691e957847..899ecfa7c4c 100644 --- a/js/net/api/index.api.md +++ b/js/net/api/index.api.md @@ -433,12 +433,18 @@ export namespace Group { } export namespace Instance { - export type Valid = string & { + export interface Minted { + get text(): string; + toString(): string; + } + export type Named = string & { readonly _brand: "instance"; }; - export function isMinted(instance: Instance.Valid): boolean; - export function parse(text: string): Instance.Valid; - export function random(): Instance.Valid; + export type Valid = Instance.Named | Instance.Minted; + export function isMinted(instance: Instance.Valid): instance is Instance.Minted; + export function parse(text: string): Instance.Named; + export function random(): Instance.Named; + export function text(instance: Instance.Valid): string; } export namespace Origin { diff --git a/js/net/src/ietf/subscriber.ts b/js/net/src/ietf/subscriber.ts index 517f86a997c..ee5420b10f3 100644 --- a/js/net/src/ietf/subscriber.ts +++ b/js/net/src/ietf/subscriber.ts @@ -23,9 +23,9 @@ import { import { type Fill, fillChanged, type Requests, wantedRuns } from "../fill.ts"; import type * as netGroup from "../group.ts"; import { Cost, routesEqual, UNKNOWN_HOP, type WireRoute } from "../hop.ts"; -import * as Instance from "../instance.ts"; import { finalSequence, hiddenBelow, hooks, maxDelayOf, scopeCaptures, scopeHead, scopeOverlaps } from "../internal.ts"; import type { Lister } from "../listing.ts"; +import * as Mint from "../mint.ts"; import * as Path from "../path.ts"; import { receiveFrame, skipPayload } from "../payload.ts"; import { type Cursor, Reader, type Stream } from "../stream.ts"; @@ -396,7 +396,7 @@ export class Subscriber { } // moq-transport carries no instance, so each namespace the peer advertises anew is a // stream of its own, even one it advertised before on this session. - route = this.#drained({ ...route, instance: Instance.minted(), epoch: 0n }); + route = this.#drained({ ...route, instance: Mint.fresh(), epoch: 0n }); const entry: Announced = { adverts: [{ id, route }], route }; this.#announced.set(path, entry); this.#consumes.announce(path, entry, route); diff --git a/js/net/src/instance.ts b/js/net/src/instance.ts index 0a60a91185f..4d45bf132b6 100644 --- a/js/net/src/instance.ts +++ b/js/net/src/instance.ts @@ -1,41 +1,52 @@ /** - * Stream instances: a string naming one continuous stream at a path. Within one, group - * sequences never restart and never name two different groups, so a reader continues across - * every route that shares it; different instances are never stitched together. + * Stream instances: one continuous stream at a path. Within one, group sequences never + * restart and never name two different groups, so a reader continues across every route that + * shares it; different instances are never stitched together. * Mirrors `Instance` in rs/moq-net. * * @module */ -/** A validated instance: any non-empty text. */ -export type Valid = string & { readonly _brand: "instance" }; +import { hex, Minted } from "./mint.ts"; -/** What every {@link isMinted minted} instance starts with. */ -const MINTED = "~"; +/** A named instance: its text, as a publisher or catalog names it. */ +export type Named = string & { readonly _brand: "instance" }; -/** Parse instance text, refusing an empty one. */ -export function parse(text: string): Valid { +/** An instance a session {@link isMinted minted}; see {@link Valid}. */ +export type { Minted }; + +/** + * A validated instance, compared with `===` and used as a map key. A named instance is its + * text; one a session minted is an object no caller can build, interned so equal ones are the + * same object, so the two kinds never match even with the same text. Read the text with + * {@link text}. + */ +export type Valid = Named | Minted; + +/** Parse a named instance's text, refusing an empty or ill-formed one (a lone surrogate, which UTF-8 cannot carry). */ +export function parse(text: string): Named { if (text.length === 0) throw new RangeError("invalid instance: empty"); - return text as Valid; + if (!text.isWellFormed()) throw new RangeError("invalid instance: ill-formed text"); + return text as Named; } /** A fresh random instance, for a new stream: 128 random bits as hex. */ -export function random(): Valid { - const bytes = crypto.getRandomValues(new Uint8Array(16)); - return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("") as Valid; +export function random(): Named { + return hex() as Named; } -/** A fresh instance a session mints for a route its wire delivered without one. @internal */ -export function minted(): Valid { - return `${MINTED}${random()}` as Valid; +/** The instance's text, which alone does not tell a minted instance from a named one. */ +export function text(instance: Valid): string { + return typeof instance === "string" ? instance : instance.text; } /** - * Whether a relay minted this instance, for a route whose wire (an older moq-lite, or + * Whether a session minted this instance, for a route whose wire (an older moq-lite, or * moq-transport) carries none. It names the announcement rather than the publisher's stream, * so it can neither match nor contradict an instance the publisher named elsewhere, such as in - * a catalog. Hops that forward it keep the mark. + * a catalog. Hops that forward it keep the flag, and only a session sets it: {@link parse} + * never builds a minted instance. */ -export function isMinted(instance: Valid): boolean { - return instance.startsWith(MINTED); +export function isMinted(instance: Valid): instance is Minted { + return Minted.is(instance); } diff --git a/js/net/src/integers.test.ts b/js/net/src/integers.test.ts index b8ad167b0ee..33b90ef3347 100644 --- a/js/net/src/integers.test.ts +++ b/js/net/src/integers.test.ts @@ -164,7 +164,11 @@ for (const version of [Lite.DRAFT_06, Lite.DRAFT_07]) { for (const index of [0, 1]) { const body = await Writer.collect(version, async (w) => { await w.string("room"); - if (version === Lite.DRAFT_07) await w.u53(0); + if (version === Lite.DRAFT_07) { + // No pinned instance: empty and unminted. + await w.u53(0); + await w.bool(false); + } await w.string("video"); await w.u8(0); if (version === Lite.DRAFT_07) await w.u53(0); diff --git a/js/net/src/internal.ts b/js/net/src/internal.ts index 156e35bf6a9..10a73768995 100644 --- a/js/net/src/internal.ts +++ b/js/net/src/internal.ts @@ -15,7 +15,7 @@ import { StreamCode, StreamError, toStreamCode } from "./error.ts"; import type { Consumer as FrameConsumer } from "./frame.ts"; import type { Frame, Consumer as GroupConsumer, Producer as GroupProducer } from "./group.ts"; import { isAnonymous, type WireRoute } from "./hop.ts"; -import type * as Instance from "./instance.ts"; +import * as Instance from "./instance.ts"; import type { Consumer as OriginConsumer, Producer as OriginProducer } from "./origin.ts"; import * as Path from "./path.ts"; import type { Timestamp } from "./time.ts"; @@ -1331,8 +1331,8 @@ export function compareRoutes(a: WireRoute, b: WireRoute): number { export function compareRouteCandidates(path: Path.Valid, prefix: Path.Valid, a: WireRoute, b: WireRoute): number { const order = compareRoutes(a, b) || a.hops.length - b.hops.length; if (order !== 0 || path === prefix) return order; - const ha = spreadHash(path, a.instance ?? ""); - const hb = spreadHash(path, b.instance ?? ""); + const ha = spreadHash(path, a.instance ? Instance.text(a.instance) : ""); + const hb = spreadHash(path, b.instance ? Instance.text(b.instance) : ""); if (ha !== hb) return ha < hb ? -1 : 1; const ca = chainHash(path, a.hops); const cb = chainHash(path, b.hops); diff --git a/js/net/src/lite/announce.test.ts b/js/net/src/lite/announce.test.ts index 82e45f79ab1..1dad2e81ceb 100644 --- a/js/net/src/lite/announce.test.ts +++ b/js/net/src/lite/announce.test.ts @@ -341,18 +341,19 @@ const GOLDEN_RESOLVED = [ { start: { suffix: Path.from("room/b"), hops: [hop(0x5555n), relay] } }, ]; -// Pinned from the Rust encoder (`lite::compress::tests::golden_stream_is_pinned`), so the -// JS decoder is checked against real compressed output. +// Rust's compressed output (`lite::compress::tests::golden_stream_is_pinned`) before lite-07's +// route layer, which js/net does not speak yet; quest/m2/cluster-routing/routes/js.md realigns +// both streams with Rust's. const GOLDEN = - "001800000a726f6f6d2f612f63616d01610000029111a2220000000f0102036d69630161000101b33301000208000101c044440100010101000e020101620161000201c055550100"; + "001900000a726f6f6d2f612f63616d0161000000029111a222000000100102036d6963016100000101b33301000208000101c044440100010101000f02010162016100000201c055550100"; test("AnnounceHistory resolves the Rust encoder's compressed stream", async () => { expect(await resolveStream(unhex(GOLDEN))).toEqual(GOLDEN_RESOLVED); }); -// JS always encodes literally; Rust decodes these bytes too (`js_literal_stream_decodes`). +// JS always encodes literally. Rust's `js_literal_stream_decodes` pins the route-layer form. const JS_LITERAL = - "001800000a726f6f6d2f612f63616d01610000029111a2220000001800000a726f6f6d2f612f6d69630161000002b333a2220000020a000002c04444a22200000101010015000006726f6f6d2f620161000002c05555a2220000"; + "001900000a726f6f6d2f612f63616d0161000000029111a2220000001900000a726f6f6d2f612f6d6963016100000002b333a2220000020a000002c04444a22200000101010016000006726f6f6d2f62016100000002c05555a2220000"; test("the literal draft-07 stream matches what Rust decodes", async () => { const wire = await bytes(async (w) => { @@ -471,12 +472,12 @@ test("route costs saturate at 2^62-1 on every version", async () => { expect(got).toMatchObject({ cost: ceiling }); } - // ANNOUNCE_START: path base, path keep, empty suffix, instance "a", epoch 0, hop base, no - // hops, hop keep, then a cost of 2^64-1, which only lite-07's varints can carry. + // ANNOUNCE_START: path base, path keep, empty suffix, instance "a", unminted, epoch 0, hop + // base, no hops, hop keep, then a cost of 2^64-1, which only lite-07's varints can carry. const wire = await bytes(async (w) => { await w.u53(0); - await w.u53(18); - for (const byte of [0, 0, 0, 1, 0x61, 0, 0, 0, 0]) await w.u8(byte); + await w.u53(19); + for (const byte of [0, 0, 0, 1, 0x61, 0, 0, 0, 0, 0]) await w.u8(byte); await w.u62(huge); }, Version.DRAFT_07); const got = await decodeAnnounceBroadcast(new Reader(undefined, wire, Version.DRAFT_07), Version.DRAFT_07); @@ -494,8 +495,8 @@ test("wip start and update carry one static cost", async () => { ), v, ); - // Path base, path keep, empty suffix, instance "b", epoch 0, hop base, no hops, hop keep, cost. - expect([...start]).toEqual([0, 10, 0, 0, 0, 1, 0x62, 0, 0, 0, 0, 0]); + // Path base, path keep, empty suffix, instance "b", unminted, epoch 0, hop base, no hops, hop keep, cost. + expect([...start]).toEqual([0, 11, 0, 0, 0, 1, 0x62, 0, 0, 0, 0, 0, 0]); const update = await bytes( (w) => encodeAnnounceBroadcast(w, { status: "update", id: 0n, hops: [], cost: Cost.zero }, v), v, diff --git a/js/net/src/lite/datagram.test.ts b/js/net/src/lite/datagram.test.ts index 7a65aa65f5f..d7222b80210 100644 --- a/js/net/src/lite/datagram.test.ts +++ b/js/net/src/lite/datagram.test.ts @@ -95,7 +95,7 @@ test("lite-07 datagram requests match Rust's size-prefixed fields", async () => const { Timescale } = await import("../time.ts"); const request = new Datagrams({ id: 7n, broadcast: Path.from("room/alice"), track: "audio", priority: -3 }); const body = await Writer.collect(Version.DRAFT_07, (writer) => request.encode(writer, Version.DRAFT_07)); - expect([...body]).toEqual([20, 7, 10, ...enc.encode("room/alice"), 0, 5, ...enc.encode("audio"), 125]); + expect([...body]).toEqual([21, 7, 10, ...enc.encode("room/alice"), 0, 0, 5, ...enc.encode("audio"), 125]); const decoded = await Datagrams.decode(new Reader(undefined, body, Version.DRAFT_07), Version.DRAFT_07); expect(decoded).toEqual(request); for (const timescale of [undefined, Timescale.MILLI]) { diff --git a/js/net/src/lite/fetch.test.ts b/js/net/src/lite/fetch.test.ts index 416cf656145..30b3495a67e 100644 --- a/js/net/src/lite/fetch.test.ts +++ b/js/net/src/lite/fetch.test.ts @@ -80,7 +80,8 @@ test("Fetch decodes the optional delay offset before converting to milliseconds" const encoded = await Writer.collect(version, (writer) => Message.encode(writer, async (w) => { await w.string("room/1"); - await w.u53(0); // no instance pin + await w.u53(0); // no instance pin: empty and unminted + await w.bool(false); await w.string("video"); await w.u8(0); await w.u62(delay + 1n); diff --git a/js/net/src/lite/instance.test.ts b/js/net/src/lite/instance.test.ts index f06b2f087b2..64ee08d0fd7 100644 --- a/js/net/src/lite/instance.test.ts +++ b/js/net/src/lite/instance.test.ts @@ -1,5 +1,6 @@ import { expect, test } from "bun:test"; import * as Instance from "../instance.ts"; +import * as Mint from "../mint.ts"; import { Reader, Writer } from "../stream.ts"; import { decodePin, decodeRoute, encodePin, encodeRoute } from "./instance.ts"; import { Version } from "./version.ts"; @@ -29,3 +30,53 @@ test("an instance keeps a leading byte order mark", async () => { }); } }); + +// The flag, not the text, says a session minted an instance: each round-trips as itself, and a +// named instance that looks like the old `~` mark stays named. +test("a minted instance keeps its flag, and text never builds one", async () => { + const minted = Mint.fresh(); + const named = Instance.parse(Instance.text(minted)); + expect(Instance.isMinted(minted)).toBe(true); + expect(Instance.isMinted(named)).toBe(false); + expect(named).not.toBe(minted); + expect(Instance.isMinted(Instance.parse("~named"))).toBe(false); + // U+10000 is the surrogate pair D800 DC00: well-formed text that starts with a surrogate. + for (const text of ["\u{10000}", "\u{10000}x", "\u{FFFF}"]) { + const astral = Instance.parse(text); + expect(Instance.isMinted(astral)).toBe(false); + expect(Instance.text(astral)).toBe(text); + } + // A lone surrogate is ill-formed text, which UTF-8 cannot carry. + expect(() => Instance.parse("\uDC00x")).toThrow(); + // Minting interns by text, so equal minted instances are the same object. + expect(Mint.mint(Instance.text(minted))).toBe(minted); + expect(`${minted}`).toBe(Instance.text(minted)); + // Nothing outside the session can build one, even holding its class. + const Class = Object.getPrototypeOf(minted).constructor; + expect(() => new Class(Symbol("mint"), "forged")).toThrow(); + expect(Instance.isMinted(Object.create(Class.prototype))).toBe(false); + for (const instance of [minted, named]) { + const pin = await encoded((w) => encodePin(w, Version.DRAFT_07, instance)); + expect(await decodePin(new Reader(undefined, pin, Version.DRAFT_07), Version.DRAFT_07)).toBe(instance); + const route = await encoded((w) => encodeRoute(w, Version.DRAFT_07, instance, 0n)); + expect((await decodeRoute(new Reader(undefined, route, Version.DRAFT_07), Version.DRAFT_07)).instance).toBe( + instance, + ); + } +}); + +test("a pin refuses a bad minted flag", async () => { + const decode = async (text: string, flag: number) => { + const pin = await encoded(async (w) => { + const bytes = new TextEncoder().encode(text); + await w.u53(bytes.byteLength); + await w.write(bytes); + await w.u8(flag); + }); + return decodePin(new Reader(undefined, pin, Version.DRAFT_07), Version.DRAFT_07); + }; + await expect(decode("a", 2)).rejects.toThrow(); + // No instance is never minted. + await expect(decode("", 1)).rejects.toThrow(); + expect(await decode("", 0)).toBeUndefined(); +}); diff --git a/js/net/src/lite/instance.ts b/js/net/src/lite/instance.ts index 8d7e682718e..e98d2ac9e31 100644 --- a/js/net/src/lite/instance.ts +++ b/js/net/src/lite/instance.ts @@ -1,4 +1,5 @@ import * as Instance from "../instance.ts"; +import * as Mint from "../mint.ts"; import type { Reader, Writer } from "../stream.ts"; import { hasInstance, type Version } from "./version.ts"; @@ -6,25 +7,28 @@ const encoder = new TextEncoder(); // `ignoreBOM` keeps a leading U+FEFF, which is instance text like any other, as Rust does. const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); -/** Encode the instance a request pins as its UTF-8 bytes, or empty for none. Older versions - * carry nothing, so the peer resolves whichever instance wins the path. Mirrors rs/moq-net. */ +/** Encode the instance a request pins as its UTF-8 bytes and minted flag, or empty and unminted + * for none. Older versions carry nothing, so the peer resolves whichever instance wins the path. + * Mirrors rs/moq-net. */ export async function encodePin(w: Writer, version: Version, instance: Instance.Valid | undefined): Promise { if (!hasInstance(version)) return; - const bytes = instance === undefined ? new Uint8Array() : encoder.encode(instance); - await w.u53(bytes.byteLength); - await w.write(bytes); + if (instance !== undefined) return encode(w, instance); + await w.u53(0); + await w.bool(false); } -/** Decode the instance a request pins: empty is none, and anything but UTF-8 is refused. */ +/** Decode the instance a request pins: empty is none, and anything but UTF-8, or an empty one + * flagged minted, is refused. */ export async function decodePin(r: Reader, version: Version): Promise { if (!hasInstance(version)) return undefined; - const size = await r.u53(); - if (size === 0) return undefined; - return Instance.parse(decoder.decode(await r.read(size))); + const bytes = await r.read(await r.u53()); + const minted = await r.bool(); + if (bytes.byteLength === 0 && !minted) return undefined; + return decode(bytes, minted); } -/** Encode the instance and epoch an announcement serves. Every route carries an instance, so - * lite-07 refuses to announce one without; older versions carry neither. */ +/** Encode the instance, its minted flag, and the epoch an announcement serves. Every route + * carries an instance, so lite-07 refuses to announce one without; older versions carry neither. */ export async function encodeRoute( w: Writer, version: Version, @@ -33,16 +37,26 @@ export async function encodeRoute( ): Promise { if (!hasInstance(version)) return; if (instance === undefined) throw new Error("a lite-07 route needs an instance"); - const bytes = encoder.encode(instance); - await w.u53(bytes.byteLength); - await w.write(bytes); + await encode(w, instance); await w.u62(epoch ?? 0n); } /** Decode the instance and epoch an announcement serves; see {@link encodeRoute}. */ export async function decodeRoute(r: Reader, version: Version): Promise<{ instance?: Instance.Valid; epoch?: bigint }> { if (!hasInstance(version)) return {}; - const size = await r.u53(); - const instance = Instance.parse(decoder.decode(await r.read(size))); + const bytes = await r.read(await r.u53()); + const instance = decode(bytes, await r.bool()); return { instance, epoch: await r.u62() }; } + +async function encode(w: Writer, instance: Instance.Valid): Promise { + const bytes = encoder.encode(Instance.text(instance)); + await w.u53(bytes.byteLength); + await w.write(bytes); + await w.bool(Instance.isMinted(instance)); +} + +function decode(bytes: Uint8Array, minted: boolean): Instance.Valid { + const named = Instance.parse(decoder.decode(bytes)); + return minted ? Mint.mint(named) : named; +} diff --git a/js/net/src/lite/subscriber.ts b/js/net/src/lite/subscriber.ts index 411cb59d4c1..673ce052dea 100644 --- a/js/net/src/lite/subscriber.ts +++ b/js/net/src/lite/subscriber.ts @@ -40,6 +40,7 @@ import { staleHole, } from "../internal.ts"; import type { Lister } from "../listing.ts"; +import * as Mint from "../mint.ts"; import * as Path from "../path.ts"; import { sendOrder } from "../priority.ts"; import { type OpenOptions, type Reader, Stream, Writer } from "../stream.ts"; @@ -234,7 +235,7 @@ export class Minted { acquire(path: Path.Valid): Instance.Valid { let entry = this.#paths.get(path); if (!entry) { - entry = { instance: Instance.minted(), holders: 0 }; + entry = { instance: Mint.fresh(), holders: 0 }; this.#paths.set(path, entry); } entry.holders += 1; @@ -1746,14 +1747,9 @@ export class Subscriber { // Different freshness budgets use independent streams: once a FETCH is on the // wire its limit cannot be widened for a later, more tolerant caller. Equal // budgets still coalesce and each caller keeps an independent mirror. - const key = JSON.stringify([ - consumed.id, - broadcast, - instance, - track, - String(sequence), - options.maxDelay?.asNanos(), - ]); + // A minted instance is an object, which JSON prints as {}, so key on its text and flag. + const pin = instance === undefined ? null : [Instance.text(instance), Instance.isMinted(instance)]; + const key = JSON.stringify([consumed.id, broadcast, pin, track, String(sequence), options.maxDelay?.asNanos()]); let entry = this.#fetches.get(key); let consumer: netGroup.Consumer; if (entry && !entry.group.isClosed) { diff --git a/js/net/src/mint.ts b/js/net/src/mint.ts new file mode 100644 index 00000000000..0881a6f0481 --- /dev/null +++ b/js/net/src/mint.ts @@ -0,0 +1,74 @@ +/** + * Minting instances, which only a session does. Not exported from the package entrypoint, and + * {@link Minted}'s constructor refuses anyone without this module's token, so the public API + * builds only named instances. + * + * @module + */ + +const TOKEN = Symbol("mint"); + +// Set by Minted's static block, which alone may call its private constructor. +let construct: (text: string) => Minted; + +/** + * An instance a session minted for a route whose wire (an older moq-lite, or moq-transport) + * carries none. Interned by text while any copy is alive, so `===` and map keys compare minted + * instances by value, and a named instance, being a string, never equals one. + */ +export class Minted { + readonly #text: string; + + // The token refuses a caller that reaches the class through an instance's prototype. + private constructor(token: symbol, text: string) { + if (token !== TOKEN) throw new TypeError("only a session mints an instance"); + this.#text = text; + Object.freeze(this); + } + + static { + construct = (text) => new Minted(TOKEN, text); + } + + /** Whether `value` is a minted instance: a brand check no other object passes. */ + static is(value: unknown): value is Minted { + return typeof value === "object" && value !== null && #text in value; + } + + /** The instance's text, which alone does not tell it from a named instance. */ + get text(): string { + return this.#text; + } + + toString(): string { + return this.#text; + } +} + +// One live Minted per text, so equality stays `===`. An entry goes once nothing holds its +// instance, after which nobody can compare against it. +const interned = new Map>(); +const released = new FinalizationRegistry((text) => { + if (interned.get(text)?.deref() === undefined) interned.delete(text); +}); + +/** The minted instance with `text`. */ +export function mint(text: string): Minted { + const live = interned.get(text)?.deref(); + if (live) return live; + const minted = construct(text); + interned.set(text, new WeakRef(minted)); + released.register(minted, text); + return minted; +} + +/** 128 random bits as hex. */ +export function hex(): string { + const bytes = crypto.getRandomValues(new Uint8Array(16)); + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +/** A fresh instance a session mints for a route its wire delivered without one. */ +export function fresh(): Minted { + return mint(hex()); +} diff --git a/js/net/src/origin.ts b/js/net/src/origin.ts index cec0e001414..870a017ac02 100644 --- a/js/net/src/origin.ts +++ b/js/net/src/origin.ts @@ -1296,9 +1296,6 @@ export interface RouteOptions { * sequences under an old instance splices unrelated groups into their tracks. The highest * epoch wins over every older one of the instance, cheaper or not. Naming an instance grants * nothing: publishing still needs the path's grant. - * - * Do not start a named instance with `~`: that marks one a relay minted (see - * {@link Instance.isMinted}), which signing refuses to bind and a catalog's instance check skips. */ instance?: { id: Instance.Valid; epoch: bigint }; } diff --git a/js/net/src/resume.model.test.ts b/js/net/src/resume.model.test.ts index 66c9d824afb..cc210425f44 100644 --- a/js/net/src/resume.model.test.ts +++ b/js/net/src/resume.model.test.ts @@ -32,7 +32,7 @@ const GROUPS = 12; /** Routes per instance, cheaper the higher the slot. */ const SLOTS = 3; /** The first instance, then one whose routes all undercut it. */ -const INSTANCES = [Instance.parse("first"), Instance.parse("second")]; +const INSTANCES: readonly Instance.Valid[] = [Instance.parse("first"), Instance.parse("second")]; const READERS = 4; const PATH = Path.from("room/alice"); /** A budget nothing in a case outlives. */ diff --git a/js/watch/src/broadcast.test.ts b/js/watch/src/broadcast.test.ts index cd470ff8504..653672239b4 100644 --- a/js/watch/src/broadcast.test.ts +++ b/js/watch/src/broadcast.test.ts @@ -6,6 +6,7 @@ import * as Msf from "@moq/msf"; import * as Moq from "@moq/net"; import { Origin, Path } from "@moq/net"; import { Effect, Signal } from "@moq/signals"; +import * as Mint from "../../net/src/mint.ts"; import { Broadcast } from "./broadcast"; function publish(origin: Origin.Producer, path: Path.Valid) { @@ -342,7 +343,7 @@ describe("relativeBroadcast", () => { const siblingRoute = owner.route(Path.from("a/source"), { instance: { id: siblingInstance, epoch: 0n } }); const sibling = siblingRoute.broadcast; // A route over a wire that carries no instance has one its session minted. - const plainRoute = owner.route(Path.from("a/plain"), { instance: { id: Moq.Instance.minted(), epoch: 0n } }); + const plainRoute = owner.route(Path.from("a/plain"), { instance: { id: Mint.fresh(), epoch: 0n } }); const plain = plainRoute.broadcast; const source = new Broadcast({ origin: owner, diff --git a/js/watch/src/include.test.ts b/js/watch/src/include.test.ts index 0fea6752a07..fa5376512e9 100644 --- a/js/watch/src/include.test.ts +++ b/js/watch/src/include.test.ts @@ -4,6 +4,7 @@ import * as Catalog from "@moq/hang/catalog"; import * as Json from "@moq/json"; import { Instance, Origin, Path, type Track } from "@moq/net"; import { Effect, Signal } from "@moq/signals"; +import * as Mint from "../../net/src/mint.ts"; import { Broadcast } from "./broadcast"; import { Source } from "./video/source"; @@ -296,7 +297,7 @@ it("never reads an include of another instance, but reads a minted one", async ( const proven = publish(origin, "room/proven.hang", served); const track = proven.createTrack(Catalog.TRACK); // A route over a wire that carries no instance has one its session minted. - publish(origin, "room/plain.hang", Instance.minted()); + publish(origin, "room/plain.hang", Mint.fresh()); const named = Instance.random(); const include = (instance: Instance.Valid) => Catalog.RootSchema.parse({ diff --git a/js/watch/tsconfig.build.json b/js/watch/tsconfig.build.json index b0d79fdc2cb..b58733f6162 100644 --- a/js/watch/tsconfig.build.json +++ b/js/watch/tsconfig.build.json @@ -3,5 +3,8 @@ // are neither published nor discovered by `bun test` at the package root. // `tsc --noEmit` still uses tsconfig.json, so tests stay type-checked. "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "./src" + }, "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"] } diff --git a/js/watch/tsconfig.json b/js/watch/tsconfig.json index 96547728fc4..d3b0de1b32d 100644 --- a/js/watch/tsconfig.json +++ b/js/watch/tsconfig.json @@ -1,7 +1,9 @@ { "extends": "../tsconfig.json", "compilerOptions": { - "rootDir": "./src", + // The minted-instance tests borrow `@moq/net`'s session-only minting by relative path, so + // the type-check spans the workspace. The build pins `rootDir` to src and skips tests. + "rootDir": "..", "outDir": "dist", "emitDeclarationOnly": true, "types": ["audioworklet", "bun"] diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt index fa055559642..57fe666279b 100644 --- a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt @@ -158,15 +158,21 @@ typealias Frame = uniffi.moq.MoqFrame /** An exact media timestamp: `value` units at `scale` units per second. */ typealias Timestamp = uniffi.moq.MoqTimestamp +/** + * Names one continuous stream at a path: `Instance.parse` names one, only a session mints one, and + * equality also compares `isMinted()`, so pin a reported stream with the instance, not its text. + */ +typealias Instance = uniffi.moq.MoqInstance + /** * How a [Route] advertises itself: `hidden` until `announce`, its static production cost (lower - * wins), an optional stable instance (null mints a fresh one the route keeps for its lifetime), and - * the epoch, which only matters with a stable instance. + * wins), an optional stable [Instance] (null mints a fresh one the route keeps for its lifetime), + * and the epoch, which only matters with a stable instance. */ typealias RouteOptions = uniffi.moq.MoqRouteOptions /** - * The read-only facts on a received route: the instance of the stream it serves, relay hop ids + * The read-only facts on a received route: the [Instance] of the stream it serves, relay hop ids * (oldest first), and static production and link cost (lower wins). */ typealias RouteInfo = uniffi.moq.MoqRouteInfo diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Moq.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Moq.kt index c7d738c2eb4..1a007569ab2 100644 --- a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Moq.kt +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Moq.kt @@ -43,7 +43,7 @@ internal constructor( * the path goes offline. Pass an [instance] to follow only that stream; another one taking over * also returns null. Cancel the returned handle to stop waiting. */ - fun follow(path: String, instance: String? = null): Following = + fun follow(path: String, instance: Instance? = null): Following = session.consume().follow(path, instance) /** @@ -53,7 +53,7 @@ internal constructor( * Unlike [follow] this does not wait for a future announcement; it throws when neither can * serve the path. Pass an [instance] to refuse any other stream. */ - suspend fun request(path: String, instance: String? = null): BroadcastConsumer = + suspend fun request(path: String, instance: Instance? = null): BroadcastConsumer = session.consume().request(path, instance) /** diff --git a/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt b/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt index 440c0374400..30b2f2fe435 100644 --- a/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt +++ b/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt @@ -29,7 +29,9 @@ import java.net.InetAddress import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertFalse import kotlin.test.assertIs +import kotlin.test.assertNotEquals import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -859,24 +861,24 @@ class SmokeTest { val following = consumer.follow("instance", null) origin.route("instance", RouteOptions()).use { first -> val present = assertIs(announced.nextRoute()) - assertTrue(present.announce.route.instance.isNotEmpty()) + assertFalse(present.announce.route.instance.isMinted()) assertEquals(first.instance(), present.announce.route.instance) assertNotNull(following.available()).close() - origin.route("instance", RouteOptions(instance = "camera", epoch = 1uL)).use { - second -> + val camera = Instance.parse("camera") + origin.route("instance", RouteOptions(instance = camera, epoch = 1uL)).use { second + -> assertNotNull(following.available()).close() // The takeover was not listed, so the next event is the path going absent. first.withdraw() second.withdraw() val absent = assertIs(announced.nextRoute()) - assertEquals("camera", absent.announce.route.instance) + assertEquals(camera, absent.announce.route.instance) + assertEquals("camera", absent.announce.route.instance.toString()) } - assertFailsWith { - origin.route("refused", RouteOptions(instance = "")) - } + assertFailsWith { Instance.parse("") } } following.cancel() } @@ -1038,6 +1040,59 @@ class SmokeTest { } } + /** + * Over moq-lite-06, whose wire carries no instance, the session mints one: passing it back pins + * `request` and `follow`, while its text parsed is another, named instance that nothing serves. + */ + @Test + fun `an announced instance pins request and follow`() = runTest { + val version = listOf("moq-lite-06") + Server.listen( + ServerConfig( + bind = "127.0.0.1:0", + versions = version, + tls = ServerTls(generate = listOf("localhost")), + ) + ) + .use { server -> + server.route("live", RouteOptions()).use { + // Not requests().first(): ending that collection cancels the server. + val accepted = async { server.server.accept()!!.accept() } + OriginProducer(OriginConfig()).use { viewer -> + Moq.connect( + "https://${server.localAddr}", + ClientConfig( + bind = "127.0.0.1:0", + versions = version, + tls = ClientTls(insecure = true), + consume = viewer, + once = true, + ), + ) + .use { moq -> + val session = accepted.await() + val present = + assertIs(moq.announced().nextRoute()) + val minted = present.announce.route.instance + assertTrue(minted.isMinted()) + + moq.request("live", minted) + val following = moq.follow("live", minted) + assertNotNull(following.available()).close() + following.cancel() + + val named = Instance.parse(minted.text()) + assertFalse(named.isMinted()) + assertEquals(minted.text(), named.toString()) + assertNotEquals(minted, named) + assertFailsWith { moq.request("live", named) } + session.cancel(0u) + } + } + } + } + } + /** The returned Session owns its pending dial even after Client cancellation. */ @Test fun `session owns and cancels its first dial`() = runTest { diff --git a/py/moq-rs/README.md b/py/moq-rs/README.md index 8fe08fbc52b..6d4e8618115 100644 --- a/py/moq-rs/README.md +++ b/py/moq-rs/README.md @@ -284,14 +284,14 @@ moq-transport on every relay hop; this API does not add pre-07 capability detect - **`OriginProducer(*, cache_capacity_bytes=None)`**. Manage broadcast announcements. Set `cache_capacity_bytes` to bound cached groups under this origin. - `.consume() → OriginConsumer` - - `.route(path, *, hidden=False, cost=0, instance=None, epoch=0) → Route`. An exact route owning a new broadcast, visible at once unless `hidden`, under a fresh instance unless `instance` names one. + - `.route(path, *, hidden=False, cost=0, instance=None, epoch=0) → Route`. An exact route owning a new broadcast, visible at once unless `hidden`, under a fresh instance unless `instance` names an `Instance`. - `.claim(prefix, *, hidden=False, cost=0, instance=None, epoch=0) → Claim`. Claim `prefix` and every path beneath it, yielding the requests there. - **`Route`**. An exact route on an origin, owning one broadcast. Hold it: dropping or cancelling it retracts it, and so does closing its broadcast. - `.broadcast → BroadcastProducer`. The broadcast the route owns. - - `.instance`. The stream it serves now, kept across `withdraw()` and `announce()`. Unless one is named, a job's route takes its claim's when it first announces or when `.instance` first reads it. Read first, the job announces under it, and `announce()` fails with `Unroutable` once the claim has moved past it. + - `.instance → Instance | None`. The stream it serves now, kept across `withdraw()` and `announce()`, or `None` once cancelled without one. Unless one is named, a job's route takes its claim's when it first announces or when `.instance` first reads it. Read first, the job announces under it, and `announce()` fails with `Unroutable` once the claim has moved past it. - `.announce()`, `.withdraw()`, `.set_cost(cost)`. Show, hide, or re-price it in place; `announce` and `set_cost` raise `Closed` once the broadcast closed. - **`Claim`**. A prefix route on an origin. Hold it: dropping or cancelling it retracts it and rejects the requests still waiting. - - `.instance`. The stream it serves now, kept across `withdraw()` and `announce()`. Unless one is named, it moves to a fresh one each time a job holding it ends. + - `.instance → Instance`. The stream it serves now, kept across `withdraw()` and `announce()`. Unless one is named, it moves to a fresh one each time a job holding it ends. - `.announce()`, `.withdraw()`, `.set_cost(cost)`. Show, hide, or re-price it in place. - `await .requested() → BroadcastRequest | None`, or async iteration. Call `.serve(consumer)` to answer with held content under the claim's continuous sequence space, `.route(hidden=..., ...)` for the path's own route, which continues the claim's instance until the job ends and then moves the claim to a fresh one (unless either names an instance: a named claim lends none, and a named job keeps its own), or `.reject(code)` to fail the requester. - **`OriginConsumer`**. Discover broadcasts. @@ -310,6 +310,7 @@ moq-transport on every relay hop; this API does not add pre-07 capability detect - **`media.Video`**. `.codec`, `.coded: Dimensions`, `.display_aspect`, `.bitrate`, `.enabled`, `.framerate`, `.description`. A false `.enabled` means no frames are coming, so don't select the rendition. - **`Subscription`**. Subscriber delivery preferences: priority, staleness, and optional group range. - **`TrackInfo`**. Publisher track properties: priority, cache window, and timescale. +- **`Instance`**. Names one continuous stream at a path. `Instance.parse(text)` names one (an empty `text` raises `Error.InvalidInstance`); `.text()` and `str()` give its text, and `.is_minted()` tells whether a session minted it for a route whose wire (moq-lite 06 or older, or moq-transport) carries none. Instances compare and hash by text and that flag, so pass back an announced instance as is, not `Instance.parse` of its text, to pin its stream. - **`media.Dimensions`**. `.width: int`, `.height: int`. - **`media.Container`**. The catalog container enum, carried on each `Video`/`Audio` record. diff --git a/py/moq-rs/docs/index.md b/py/moq-rs/docs/index.md index 5bae389fab3..16bd0b3ee6b 100644 --- a/py/moq-rs/docs/index.md +++ b/py/moq-rs/docs/index.md @@ -241,6 +241,7 @@ Rust side ([`moq-ffi`](https://crates.io/crates/moq-ffi)). TrackInfo FetchGroupOptions RouteInfo + Instance ConnectionStats ``` diff --git a/py/moq-rs/moq/__init__.py b/py/moq-rs/moq/__init__.py index f467273ee76..88e2cbac170 100644 --- a/py/moq-rs/moq/__init__.py +++ b/py/moq-rs/moq/__init__.py @@ -73,6 +73,7 @@ FetchGroupOptions, Frame, Grant, + Instance, Order, Position, ProtocolError, @@ -138,6 +139,7 @@ "Fill", "Following", "Grant", + "Instance", "GrantConsumer", "GroupConsumer", "GroupProducer", diff --git a/py/moq-rs/moq/client.py b/py/moq-rs/moq/client.py index c65e489bb0a..940c1f71d84 100644 --- a/py/moq-rs/moq/client.py +++ b/py/moq-rs/moq/client.py @@ -17,6 +17,7 @@ from .origin import AnnounceConsumer, Following, OriginConsumer, OriginProducer, Route from .session import Session from .subscribe import BroadcastConsumer +from .types import Instance CongestionControl = MoqCongestionControl @@ -156,7 +157,7 @@ async def __aexit__(self, *exc) -> None: self._inner = None def route( - self, path: str, *, hidden: bool = False, cost: int = 0, instance: str | None = None, epoch: int = 0 + self, path: str, *, hidden: bool = False, cost: int = 0, instance: Instance | None = None, epoch: int = 0 ) -> Route: """Create a route at ``path`` owning a broadcast to fill; hold it to keep it announced. @@ -171,14 +172,14 @@ def announced(self, prefix: str = "", *, filter: str | None = None, hidden: bool """ return self._require_consumer().announced(prefix, filter=filter, hidden=hidden) - def follow(self, path: str, *, instance: str | None = None) -> Following: + def follow(self, path: str, *, instance: Instance | None = None) -> Following: """Follow the broadcast at exactly ``path`` across its publisher instances. See :meth:`OriginConsumer.follow`. """ return self._require_consumer().follow(path, instance=instance) - async def request(self, path: str, *, instance: str | None = None) -> BroadcastConsumer: + async def request(self, path: str, *, instance: Instance | None = None) -> BroadcastConsumer: """Request the broadcast at ``path``, resolving one publisher instance as soon as it can be served. See :meth:`OriginConsumer.request`. diff --git a/py/moq-rs/moq/origin.py b/py/moq-rs/moq/origin.py index 5cdb90182ff..10e3bf1bb05 100644 --- a/py/moq-rs/moq/origin.py +++ b/py/moq-rs/moq/origin.py @@ -20,6 +20,7 @@ from ._stream import _Stream from .publish import BroadcastProducer from .subscribe import BroadcastConsumer +from .types import Instance Announce = MoqAnnounce """A route over a prefix: the origin-relative ``prefix``, what each filter wildcard @@ -102,7 +103,7 @@ def cancel(self) -> None: self._inner.cancel() -def _options(*, hidden: bool, cost: int, instance: str | None, epoch: int) -> MoqRouteOptions: +def _options(*, hidden: bool, cost: int, instance: Instance | None, epoch: int) -> MoqRouteOptions: return MoqRouteOptions(hidden=hidden, cost=cost, instance=instance, epoch=epoch) @@ -117,7 +118,7 @@ def path(self) -> str: """The requested broadcast path.""" return self._inner.path() - def route(self, *, hidden: bool = False, cost: int = 0, instance: str | None = None, epoch: int = 0) -> Route: + def route(self, *, hidden: bool = False, cost: int = 0, instance: Instance | None = None, epoch: int = 0) -> Route: """Answer with the path's own exact :class:`Route`, owning a new broadcast. Unless ``instance`` names one, the route takes the instance its claim lends when it @@ -164,13 +165,14 @@ def broadcast(self) -> BroadcastProducer: return self._broadcast @property - def instance(self) -> str: + def instance(self) -> Instance | None: """The stream this route serves now. A named instance never changes. A job's route takes its claim's current one when it first announces or when this first reads it. Read first, the job announces under it, and :meth:`announce` fails with ``Unroutable`` once the claim has moved past it, so a - signer built from it stays valid. Withdrawing and announcing keep it.""" + signer built from it stays valid. Withdrawing and announcing keep it. Once cancelled, + it is the last one read or announced, or ``None`` for a hidden route that did neither.""" return self._inner.instance_id() def announce(self) -> None: @@ -216,7 +218,7 @@ def __init__(self, inner: MoqClaim) -> None: self._inner = inner @property - def instance(self) -> str: + def instance(self) -> Instance: """The stream this claim serves now. A named instance never changes. Unnamed, the claim moves to a fresh one each time a @@ -276,7 +278,7 @@ def announced(self, prefix: str = "", *, filter: str | None = None, hidden: bool """ return AnnounceConsumer(self._inner.announced(MoqAnnounceConfig(prefix=prefix, filter=filter, hidden=hidden))) - def follow(self, path: str, *, instance: str | None = None) -> Following: + def follow(self, path: str, *, instance: Instance | None = None) -> Following: """Follow the broadcast at ``path``, resolving each publisher instance that serves it. A broadcast created on this origin resolves once it is announced, like a @@ -286,7 +288,7 @@ def follow(self, path: str, *, instance: str | None = None) -> Following: """ return Following(self._inner.follow(path, instance)) - async def request(self, path: str, *, instance: str | None = None) -> BroadcastConsumer: + async def request(self, path: str, *, instance: Instance | None = None) -> BroadcastConsumer: """Request the broadcast at ``path``, resolving one publisher instance as soon as it can be served. Resolves through the best announced route covering the path: an @@ -322,7 +324,7 @@ def consume(self) -> OriginConsumer: return OriginConsumer(self._inner.consume()) def route( - self, path: str, *, hidden: bool = False, cost: int = 0, instance: str | None = None, epoch: int = 0 + self, path: str, *, hidden: bool = False, cost: int = 0, instance: Instance | None = None, epoch: int = 0 ) -> Route: """Create a :class:`Route` at exactly ``path``, owning a new broadcast to fill. @@ -336,7 +338,7 @@ def route( return Route(self._inner.route(path, _options(hidden=hidden, cost=cost, instance=instance, epoch=epoch))) def claim( - self, prefix: str, *, hidden: bool = False, cost: int = 0, instance: str | None = None, epoch: int = 0 + self, prefix: str, *, hidden: bool = False, cost: int = 0, instance: Instance | None = None, epoch: int = 0 ) -> Claim: """Claim ``prefix`` and yield the requests beneath it as a :class:`Claim`. diff --git a/py/moq-rs/moq/server.py b/py/moq-rs/moq/server.py index ffde0e4740f..1ce537c99c5 100644 --- a/py/moq-rs/moq/server.py +++ b/py/moq-rs/moq/server.py @@ -10,6 +10,7 @@ from ._records import _implementation, _strs from .origin import OriginProducer, Route from .session import Session +from .types import Instance # The network transport carrying an incoming session. Transport = MoqTransport @@ -205,7 +206,7 @@ async def __anext__(self) -> Request: return Request(request) def route( - self, path: str, *, hidden: bool = False, cost: int = 0, instance: str | None = None, epoch: int = 0 + self, path: str, *, hidden: bool = False, cost: int = 0, instance: Instance | None = None, epoch: int = 0 ) -> Route: """Create a route at ``path`` owning a broadcast served to incoming sessions; hold it to keep it announced. diff --git a/py/moq-rs/moq/types.py b/py/moq-rs/moq/types.py index 662e15ad9a7..2628f449bc9 100644 --- a/py/moq-rs/moq/types.py +++ b/py/moq-rs/moq/types.py @@ -29,6 +29,9 @@ from moq_ffi import ( MoqFetchGroupOptions as FetchGroupOptions, ) +from moq_ffi import ( + MoqInstance as Instance, +) from moq_ffi import ( MoqOrder as Order, ) @@ -102,6 +105,7 @@ "Frame", "FetchGroupOptions", "Grant", + "Instance", "Order", "Position", "ProtocolError", diff --git a/py/moq-rs/tests/test_local.py b/py/moq-rs/tests/test_local.py index 838ff528a6b..1c31db4dd0c 100644 --- a/py/moq-rs/tests/test_local.py +++ b/py/moq-rs/tests/test_local.py @@ -432,24 +432,23 @@ async def test_following_restarts_on_a_new_instance(): first = origin.route("instance") present = await next_route(announced) assert isinstance(present, moq.AnnounceEventPresent) - minted = present.announce.route.instance - assert minted == first.instance + fresh = present.announce.route.instance + assert fresh == first.instance + assert not fresh.is_minted() assert await asyncio.wait_for(following.available(), timeout=5.0) is not None # Another route is another stream: followers restart onto it, while the path stays # present, so neither the re-price nor the restart is listed. first.set_cost(1) - second = origin.route("instance", instance="cam", epoch=1) - assert second.instance == "cam" + cam = moq.Instance.parse("cam") + second = origin.route("instance", instance=cam, epoch=1) + assert second.instance == cam assert await asyncio.wait_for(following.available(), timeout=5.0) is not None first.withdraw() second.withdraw() absent = await next_route(announced) assert isinstance(absent, moq.AnnounceEventAbsent) - assert absent.announce.route.instance == "cam" - - with pytest.raises(moq.Error.InvalidInstance): - origin.route("refused", instance="") + assert absent.announce.route.instance == cam following.cancel() first.broadcast.close() second.broadcast.close() @@ -1794,19 +1793,28 @@ async def test_broadcast_is_reachable_only_while_announced(): async def test_an_instance_pins_request_and_follow(): origin = moq.OriginProducer() consumer = origin.consume() - route = origin.route("live", instance="cam") + cam = moq.Instance.parse("cam") + other = moq.Instance.parse("other") + route = origin.route("live", instance=cam) + + # Only a session mints an instance: callers name one, and never an empty one. + assert str(cam) == cam.text() == "cam" and not cam.is_minted() + assert cam == moq.Instance.parse("cam") and hash(cam) == hash(moq.Instance.parse("cam")) + assert cam != other + with pytest.raises(ValueError): + moq.Instance() + with pytest.raises(moq.Error.InvalidInstance): + moq.Instance.parse("") - await asyncio.wait_for(consumer.request("live", instance="cam"), timeout=5.0) + await asyncio.wait_for(consumer.request("live", instance=cam), timeout=5.0) with pytest.raises(moq.Error.Protocol): - await asyncio.wait_for(consumer.request("live", instance="other"), timeout=5.0) - with pytest.raises(moq.Error.InvalidInstance): - consumer.follow("live", instance="") - async with consumer.follow("live", instance="cam") as following: + await asyncio.wait_for(consumer.request("live", instance=other), timeout=5.0) + async with consumer.follow("live", instance=cam) as following: assert await asyncio.wait_for(following.available(), timeout=5.0) is not None # Another instance taking the path over ends the pinned stream. - other = origin.route("live", instance="other") + taken = origin.route("live", instance=other) assert await asyncio.wait_for(following.available(), timeout=5.0) is None - other.broadcast.close() + taken.broadcast.close() route.broadcast.close() diff --git a/py/moq-rs/tests/test_server.py b/py/moq-rs/tests/test_server.py index 0f7aba939b8..4e548ca5450 100644 --- a/py/moq-rs/tests/test_server.py +++ b/py/moq-rs/tests/test_server.py @@ -461,7 +461,7 @@ async def test_broadcast_route_over_wire(): route = announcement.route assert isinstance(route.cost, int) # Every delivered route names the stream it serves. - assert route.instance + assert route.instance.text() break finally: serve_task.cancel() @@ -472,6 +472,44 @@ async def test_broadcast_route_over_wire(): broadcast.close() +async def test_a_minted_instance_pins_request_and_follow(): + """Over a wire that carries no instance, the announced one is minted and pins as is.""" + versions = ["moq-lite-06"] + async with moq.Server("127.0.0.1:0", versions=versions, tls_generate=["localhost"]) as server: + published = server.route("live") + serve_task = asyncio.create_task(server.serve()) + try: + async with moq.Client( + f"https://{server.local_addr}", + versions=versions, + tls=moq.ClientTls(insecure=True), + bind="127.0.0.1:0", + ) as client: + announced = client.announced() + present = await asyncio.wait_for(anext(announced), timeout=10) + assert isinstance(present, moq.AnnounceEventPresent) + minted = present.announce.route.instance + assert minted.is_minted() + + await asyncio.wait_for(client.request("live", instance=minted), timeout=10) + async with client.follow("live", instance=minted) as following: + assert await asyncio.wait_for(following.available(), timeout=10) is not None + + # The same text, named, is another instance, which nothing serves. + named = moq.Instance.parse(minted.text()) + assert named != minted and not named.is_minted() + with pytest.raises(moq.Error.Protocol): + await asyncio.wait_for(client.request("live", instance=named), timeout=10) + announced.cancel() + finally: + serve_task.cancel() + try: + await serve_task + except asyncio.CancelledError: + pass + published.broadcast.close() + + async def test_client_context_keeps_the_body_error(): """An error in the body survives the context manager, and the session is still cancelled. A live subscribed track never drains, so draining on the way out would diff --git a/quest/m2/README.md b/quest/m2/README.md index f571604c77d..ad6f89d47fc 100644 --- a/quest/m2/README.md +++ b/quest/m2/README.md @@ -195,7 +195,6 @@ quests that require them. - [Cluster routing](/quest/m2/cluster-routing/README.md) - prove shared routing contracts, implement per-origin routes, and connect outbound local clusters - [A group a subscriber gives up on is always reported](/quest/m2/group-drops.md) - the wire names every abandoned group and the drop signal is public, so readers count losses instead of stalling - [Stream credit by priority](/quest/m1/stream-credit/README.md) - every stream opens through one priority entry point, and a higher-priority group takes over or resets a lower one when credit runs out (held in m1 by an open claim) -- [Minted instances are a typed flag](/quest/m2/instance-provenance.md) - whether a session minted an instance is a typed flag on `Instance`, carried explicitly on lite-07 instead of a `~` prefix - [Finalize moq-lite-07](/quest/m2/lite07-finalize.md) - when the maintainer cuts it, lite-07 negotiates as `moq-lite-07` and the next release ships it - [Unbounded groups](/quest/m2/unbounded-groups/README.md) - groups and frames lose their size caps; a group keeps a 32 MB tail, and readers behind it replay through paced flights or refresh - [Small API fixes before the cut](/quest/m2/api-shape/README.md) - one path and one spelling per item, names by role, and no dead or wrong-role public surface across net, transport, media, native, apps, JS, and bindings diff --git a/quest/m2/cluster-routing/routes/js.md b/quest/m2/cluster-routing/routes/js.md index 44d06197e34..efc779abf98 100644 --- a/quest/m2/cluster-routing/routes/js.md +++ b/quest/m2/cluster-routing/routes/js.md @@ -18,6 +18,7 @@ Decided 2026-10-27 (maintainer), from the cheapest one; fix it with the route-selection rework here. - Feed the availability API from [Announcement availability](/quest/m2/cluster-routing/routes/availability.md). - Rust/JS interop covers the route messages, the floor marker, and the echo. +- Encode lite-07's Route ID (ANNOUNCE_START) and Node ID (every TRACK, TRACKS, SUBSCRIBE, FETCH, and DATAGRAMS request, 0 when the publisher selects the node) as Rust does, and pin `js/net/src/lite/announce.test.ts`'s goldens to `lite::compress`'s `GOLDEN` and `JS_LITERAL`. - Leave the temporary Lite-06 Chain Setup Parameter unsent: JS translates nothing, so it keeps no chain, and a Rust peer sends the chain only to a peer that offers it. - Restore the native JS lite-07 lanes. Decided 2026-11-06 (maintainer, /quest-complete on [moq-dev/moq#8023](https://github.com/moq-dev/moq/pull/8023)), diff --git a/quest/m2/instance-provenance.md b/quest/m2/instance-provenance.md deleted file mode 100644 index e6a826b05e7..00000000000 --- a/quest/m2/instance-provenance.md +++ /dev/null @@ -1,74 +0,0 @@ -# [L] Minted instances are a typed flag - -## Goal - -Whether a session minted an instance is a typed property of `Instance`, not -an in-band `~` prefix on its text: - -- `moq_net::Instance` carries a minted flag that the public API cannot set. - `FromStr` and `Instance::random()` build only named instances; only a - session mints one, for a route whose wire (moq-lite 06 or older, - moq-transport) carries none. `is_minted()` reads the flag. A named - instance may start with `~` like any other text. -- @moq/net mirrors it: `Instance.isMinted` reads the flag, and - `Instance.parse` / `Instance.random` build only named instances. -- moq-lite-07 (still the opt-in `moq-lite-07-wip`) carries an explicit - minted flag next to the instance wherever it carries one: the route an - announcement serves, and the instance a TRACK, TRACKS, SUBSCRIBE, FETCH, - or DATAGRAMS request pins. The draft drops the `~` convention. -- Minting itself stays, and so does what "minted" means: an older-wire route - gets one id shared across the lite-07 mesh (dedupe, agreed ranking), but no - continuity promise. A minted instance is never re-sourced, a catalog's - instance check against it is best effort, and signing refuses to bind it. - -## Plan - -Decided 2026-11-08 by the maintainer, replacing api-shape/net's "should a -route refuse a minted instance" item: the `~` prefix is a design flaw. It is -an in-band sentinel in a free-form string, so any caller can spoof a minted -instance by naming one `~...`, and a real name starting with `~` is -ambiguous. ✅ Typed flag that only a session sets, carried explicitly on the -lite-07 wire (rejected: refuse a minted instance in `route`, keep accepting -it with docs, refuse in Rust only). Once the API cannot build a minted -instance, `Producer::route` and `Request::route` need no refusal. - -Call sites (`git grep -n 'is_minted\|isMinted\|MINTED'` over `rs/` and -`js/`, plus `Instance::minted` / `Instance.minted`): - -- Type: `rs/moq-net/src/instance.rs` (`MINTED`, `minted()`, `is_minted()`, - `FromStr`, `Display`, tests), `js/net/src/instance.ts` (`MINTED`, - `minted()`, `isMinted()`, `parse`). JS `Instance.Valid` is a branded - string used as a map key and compared with `===` in about 34 files, so the - JS shape (an object with a stable key, or a string keyed form plus a flag) - is the main judgment call; pick one that keeps equality and map keys - correct and say why in the PR. -- Minting: `rs/moq-net/src/lite/subscriber.rs`, - `rs/moq-net/src/ietf/subscriber.rs`, `js/net/src/lite/subscriber.ts`, - `js/net/src/ietf/subscriber.ts`. -- Wire: `rs/moq-net/src/lite/instance.rs` and `js/net/src/lite/instance.ts` - (route and pin encode/decode), `drafts/draft-lcurley-moq-lite.md` (the - `~` paragraph near "A version before this one carries no Instance", and - the lite-07 changelog line on minting), and the lite corpus fixtures. -- Readers of the flag: `rs/moq-net/src/model/origin.rs`, - `rs/moq-crypto/src/sign/broadcast.rs`, - `js/crypto/src/sign/broadcast.ts`, `rs/moq-mux/src/source.rs`, - `rs/moq-mux/src/follow.rs`, `rs/moq-gst/src/source/imp.rs`, - `js/net/src/consume.ts`, `js/net/src/origin.ts`, `js/watch/src/broadcast.ts`. -- Tests that route a minted instance directly - (`moq-mux` `follow::tests::a_named_instance_follows_a_minted_one`, - @moq/watch `broadcast.test.ts` and `include.test.ts`) can no longer build - one through the public API; give them a minted route over a pre-lite-07 - session or a test-only constructor, whichever is simpler. -- Docs: `doc/concept/identity.md` ("A minted instance starts with `~`"), - `doc/concept/moq-lite.md` ("marked with a leading `~`"), - `drafts/draft-lcurley-moq-hang.md` and `drafts/draft-lcurley-moq-sign.md` - where they cite the `~` mark, `rs/moq-net/MIGRATE.md`, and - `js/net/api/index.api.md`. - -This changes lite-07 bytes, so it joins -[Finalize moq-lite-07](/quest/m2/lite07-finalize.md)'s Required. -[moq-dev/moq#8220](https://github.com/moq-dev/moq/pull/8220) also changes -lite-07 (SUBSCRIBE_DROP and SUBSCRIBE_SKIP ranges) and touches -`lite/subscriber.rs`, `lite/subscribe.rs`, their JS mirrors, and the lite -draft, but not the instance encoding; expect textual conflicts only, and -whichever lands second adds its changelog line beside the other's. diff --git a/quest/m2/lite07-finalize.md b/quest/m2/lite07-finalize.md index f8e56bf0143..09fec85f606 100644 --- a/quest/m2/lite07-finalize.md +++ b/quest/m2/lite07-finalize.md @@ -126,7 +126,6 @@ whose lite-07 encoding [lite-07 by start](/quest/m2/time-addressing/lite07.md) c - [Takeover](/quest/m1/stream-credit/takeover.md) - lite-07 lets a group stream take over for another group - [A group a subscriber gives up on is always reported](/quest/m2/group-drops.md) - may add SUBSCRIBE_DROP for a stopped group; leaves this list if it takes the no-wire path - [Delete the old start bookkeeping](/quest/m2/subscribe-ranges/start-bookkeeping.md) - may change START per update on lite-07 -- [Minted instances are a typed flag](/quest/m2/instance-provenance.md) - lite-07 carries an explicit minted flag beside each instance instead of the `~` prefix ## Related diff --git a/rs/moq-c/README.md b/rs/moq-c/README.md index 1bff57a27e5..49e1fd54139 100644 --- a/rs/moq-c/README.md +++ b/rs/moq-c/README.md @@ -69,9 +69,9 @@ int32_t moq_session_request_free(uint32_t request); // Origin int32_t moq_origin_create(void); int32_t moq_origin_close(uint32_t origin); -int32_t moq_origin_request(uint32_t origin, const char *path, uintptr_t path_len, const char *instance, uintptr_t instance_len, moq_status_callback on_broadcast, void *user_data); +int32_t moq_origin_request(uint32_t origin, const char *path, uintptr_t path_len, uint32_t instance, moq_status_callback on_broadcast, void *user_data); int32_t moq_origin_request_cancel(uint32_t task); -int32_t moq_origin_follow(uint32_t origin, const char *path, uintptr_t path_len, const char *instance, uintptr_t instance_len, moq_follow_callback on_follow, void *user_data); +int32_t moq_origin_follow(uint32_t origin, const char *path, uintptr_t path_len, uint32_t instance, moq_follow_callback on_follow, void *user_data); int32_t moq_origin_follow_cancel(uint32_t task); int32_t moq_origin_announced(uint32_t origin, const moq_announce_config *config, moq_status_callback on_announce, void *user_data); int32_t moq_origin_announced_info(uint32_t announced, moq_announce_event *dst); @@ -79,11 +79,18 @@ int32_t moq_origin_announced_free(uint32_t announced); int32_t moq_origin_announced_cancel(uint32_t announced); // config (NULL for everything): filter is relative to the literal prefix, or NULL for **. hidden also lists `.`-named paths. Updates stay relative to the origin. +// Instances (0 means none: unpinned, or a fresh one in route options; a route's or claim's is the caller's to free, an announcement's lives with it) +int32_t moq_instance_parse(const char *text, uintptr_t text_len); +int32_t moq_instance_text(uint32_t instance, moq_string *dst); +int32_t moq_instance_minted(uint32_t instance); +int32_t moq_instance_equal(uint32_t a, uint32_t b); +int32_t moq_instance_free(uint32_t instance); + // Publishing: Routes (freeing one retracts it; moq_publish_close on its broadcast ends both) int32_t moq_origin_route(uint32_t origin, const char *path, uintptr_t path_len, const moq_route_options *options); // options (NULL for the default): hidden until announced, a cost, and an optional stable instance with its epoch. Hops are the origin's to add. int32_t moq_route_broadcast(uint32_t route); -int32_t moq_route_instance(uint32_t route, moq_string *dst); +int32_t moq_route_instance(uint32_t route); int32_t moq_route_announce(uint32_t route); int32_t moq_route_withdraw(uint32_t route); int32_t moq_route_set_cost(uint32_t route, uint64_t cost); @@ -91,7 +98,7 @@ int32_t moq_route_free(uint32_t route); // Publishing: Claims (a prefix answered on demand; freeing one retracts it) int32_t moq_origin_claim(uint32_t origin, const char *prefix, uintptr_t prefix_len, const moq_route_options *options, moq_status_callback on_request, void *user_data); -int32_t moq_claim_instance(uint32_t claim, moq_string *dst); +int32_t moq_claim_instance(uint32_t claim); int32_t moq_claim_announce(uint32_t claim); int32_t moq_claim_withdraw(uint32_t claim); int32_t moq_claim_set_cost(uint32_t claim, uint64_t cost); diff --git a/rs/moq-c/c-tests/audio-decode-output.c b/rs/moq-c/c-tests/audio-decode-output.c index 59e6092f93f..1952443eabb 100644 --- a/rs/moq-c/c-tests/audio-decode-output.c +++ b/rs/moq-c/c-tests/audio-decode-output.c @@ -119,7 +119,7 @@ int main(void) { CHECK(moq_encode_audio_frame((uint32_t)producer, &frame) == 0); CHECK(moq_encode_audio_finish((uint32_t)producer) == 0); - CHECK(moq_origin_request((uint32_t)origin, path, strlen(path), NULL, 0, on_broadcast, &c) > 0); + CHECK(moq_origin_request((uint32_t)origin, path, strlen(path), 0, on_broadcast, &c) > 0); pthread_mutex_lock(&c.mutex); wait_until(&c, &c.broadcast); pthread_mutex_unlock(&c.mutex); diff --git a/rs/moq-c/c-tests/decode-output.c b/rs/moq-c/c-tests/decode-output.c index 18c000d69bb..a3cec57b985 100644 --- a/rs/moq-c/c-tests/decode-output.c +++ b/rs/moq-c/c-tests/decode-output.c @@ -259,7 +259,7 @@ int main(void) { fail("error: moq_encode_video_cut failed (%s)\n", moq_error()); publish_frames(producer, 5); - int32_t request = moq_origin_request((uint32_t)origin, path, strlen(path), NULL, 0, on_broadcast, &c); + int32_t request = moq_origin_request((uint32_t)origin, path, strlen(path), 0, on_broadcast, &c); if (request <= 0) fail("error: moq_origin_request failed: %d\n", request); pthread_mutex_lock(&c.mu); diff --git a/rs/moq-c/c-tests/timestamps.c b/rs/moq-c/c-tests/timestamps.c index 508262e62ea..97d905b3749 100644 --- a/rs/moq-c/c-tests/timestamps.c +++ b/rs/moq-c/c-tests/timestamps.c @@ -107,7 +107,7 @@ int main(void) { // A JSON write is never stamped for you. CHECK(moq_publish_json_stream_append((uint32_t)json, doc, strlen(doc), NULL) == MOQ_ERROR_INVALID_POINTER); - CHECK(moq_origin_request((uint32_t)origin, path, strlen(path), NULL, 0, on_broadcast, &c) > 0); + CHECK(moq_origin_request((uint32_t)origin, path, strlen(path), 0, on_broadcast, &c) > 0); WAIT_FOR(&c, c.broadcast); int32_t frames = moq_consume_track((uint32_t)c.broadcast, "ticks", 5, NULL, on_frame, &c); CHECK(frames > 0); diff --git a/rs/moq-c/src/api.rs b/rs/moq-c/src/api.rs index ea36f933990..afe955621ae 100644 --- a/rs/moq-c/src/api.rs +++ b/rs/moq-c/src/api.rs @@ -989,7 +989,7 @@ pub struct moq_section { /// Pass one to [moq_origin_route], [moq_origin_claim], or [moq_broadcast_request_route]. /// The hop chain is the origin's to build, so a local publisher cannot claim relay hops it /// never took. Zeroed (or NULL) is the default: visible at once, cost 0, under a fresh -/// instance. `instance` is borrowed for the duration of the call. +/// instance. The `instance` handle is borrowed for the duration of the call. #[repr(C)] #[derive(Clone, Copy)] pub struct moq_route_options { @@ -997,17 +997,16 @@ pub struct moq_route_options { /// sets its production cost here: zero for content it is already producing, /// larger for content it would have to start producing on demand. pub cost: u64, - /// A stable instance for the stream, NOT NULL terminated, or NULL (or empty) to mint a - /// fresh one when the route is created. + /// A stable instance for the stream, as an instance handle ([moq_instance_parse], or one a + /// route or announcement reported, to forward that stream as is), or 0 to mint a fresh one + /// when the route is created. /// /// A route keeps its instance for its lifetime, across [moq_route_withdraw] and /// [moq_route_announce], so a new route is a new stream viewers restart onto. Set one only /// for a publisher that continues its group sequences across restarts and raises `epoch` /// on each: readers stitch every route of one instance together, so restarting the /// sequences under an old instance splices unrelated groups into their tracks. - pub instance: *const c_char, - /// The length in bytes of `instance`. - pub instance_len: usize, + pub instance: u32, /// Which run of a stable `instance` this is: only its highest epoch is eligible, cheaper /// or not. Ignored without one. pub epoch: u64, @@ -1024,17 +1023,16 @@ pub struct moq_route_options { pub struct moq_route_info { /// Preference among routes covering the same prefix: lower wins. pub cost: u64, - /// The stream the route serves: routes with one instance are one continuous stream. - pub instance: moq_string, + /// The stream the route serves, as an instance handle the announcement owns: valid until + /// [moq_origin_announced_free], and never passed to [moq_instance_free]. Routes with one + /// instance are one continuous stream. + pub instance: u32, } /// Parse [moq_route_options], treating NULL as the default. /// -/// The instance parses as at every API that takes one: NULL or empty names none. -/// /// # Safety -/// `options` may be NULL, or must point at a readable [moq_route_options] whose `instance` -/// pointer is valid for `instance_len` bytes. +/// `options` may be NULL, or must point at a readable [moq_route_options]. unsafe fn parse_route_options(options: *const moq_route_options) -> Result { let hidden = unsafe { options.as_ref() }.is_some_and(|options| options.hidden); unsafe { route_options(options, hidden) } @@ -1066,7 +1064,7 @@ unsafe fn route_options( return Ok(out); }; out = out.with_cost(options.cost); - if let Some(instance) = unsafe { parse_instance(options.instance, options.instance_len)? } { + if let Some(instance) = lookup_instance(options.instance)? { out = out.with_instance(instance, options.epoch); } Ok(out) @@ -2275,25 +2273,21 @@ pub extern "C" fn moq_route_broadcast(route: u32) -> i32 { }) } -/// The stream a route serves now. +/// The stream a route serves now, as a new instance handle the caller frees with +/// [moq_instance_free]. /// /// Named options keep one instance. A route from [moq_broadcast_request_route] takes its /// claim's current one when it first announces or when this first reads it. Read first, /// the job announces under it, and [moq_route_announce] fails with an unroutable error once the /// claim has moved past it, so a signer built from it stays valid. -/// Withdrawing and announcing again keep it. The destination borrows the route's storage -/// until the next [moq_route_instance] on the route or [moq_route_free]. -/// -/// Returns zero on success, or a negative code on failure. +/// Withdrawing and announcing again keep it. /// -/// # Safety -/// - `dst` must point at a writable [moq_string]. +/// Returns a non-zero instance handle on success, or a negative code on failure. #[unsafe(no_mangle)] -pub unsafe extern "C" fn moq_route_instance(route: u32, dst: *mut moq_string) -> i32 { +pub extern "C" fn moq_route_instance(route: u32) -> i32 { ffi::enter(move || { let route = ffi::parse_id(route)?; - let dst = unsafe { dst.as_mut() }.ok_or(Error::InvalidPointer)?; - State::lock().origin.route_instance(route, dst) + State::lock().origin.route_instance(route) }) } @@ -2356,22 +2350,18 @@ pub extern "C" fn moq_route_free(route: u32) -> i32 { }) } -/// The stream a claim serves now. +/// The stream a claim serves now, as a new instance handle the caller frees with +/// [moq_instance_free]. /// /// Named options keep one instance. Unnamed, the claim moves to a fresh one each time a job -/// holding its current one ends. The destination borrows the claim's storage until the next -/// [moq_claim_instance] on the claim or [moq_claim_free]. +/// holding its current one ends. /// -/// Returns zero on success, or a negative code on failure. -/// -/// # Safety -/// - `dst` must point at a writable [moq_string]. +/// Returns a non-zero instance handle on success, or a negative code on failure. #[unsafe(no_mangle)] -pub unsafe extern "C" fn moq_claim_instance(claim: u32, dst: *mut moq_string) -> i32 { +pub extern "C" fn moq_claim_instance(claim: u32) -> i32 { ffi::enter(move || { let claim = ffi::parse_id(claim)?; - let dst = unsafe { dst.as_mut() }.ok_or(Error::InvalidPointer)?; - State::lock().origin.claim_instance(claim, dst) + State::lock().origin.claim_instance(claim) }) } @@ -2648,9 +2638,10 @@ pub enum moq_follow_event { /// instead, use [moq_origin_request]. A broadcast created on this origin resolves once it is /// announced, like a remote one. /// -/// `instance` is the only stream to follow, or NULL (or empty) to follow every instance serving -/// the path. A pinned follow never delivers another instance taking over: that ends it, and its -/// own instance is delivered again when it returns. +/// `instance` is an instance handle naming the only stream to follow, or 0 to follow every +/// instance serving the path; pass the one an announcement reported to follow that stream. A +/// pinned follow never delivers another instance taking over: that ends it, and its own +/// instance is delivered again when it returns. /// /// `on_follow` receives a [moq_follow_event] and a code. `MOQ_FOLLOW_EVENT_ONLINE` carries a /// broadcast handle once announced, and again a new one each time another publisher instance @@ -2667,22 +2658,20 @@ pub enum moq_follow_event { /// /// # Safety /// - The caller must ensure that path is a valid pointer to path_len bytes of data. -/// - `instance` may be NULL, or must point at `instance_len` readable bytes. /// - The caller must keep `user_data` valid until the `MOQ_FOLLOW_EVENT_END` callback. #[unsafe(no_mangle)] pub unsafe extern "C" fn moq_origin_follow( origin: u32, path: *const c_char, path_len: usize, - instance: *const c_char, - instance_len: usize, + instance: u32, on_follow: ffi::moq_follow_callback, user_data: *mut c_void, ) -> i32 { ffi::enter(move || { let origin = ffi::parse_id(origin)?; let path = unsafe { ffi::parse_str(path, path_len)? }.to_string(); - let instance = unsafe { parse_instance(instance, instance_len)? }; + let instance = lookup_instance(instance)?; let on_follow = unsafe { ffi::OnFollow::new(user_data, on_follow)? }; State::lock().origin.follow(origin, path, instance, on_follow) }) @@ -2711,8 +2700,9 @@ pub extern "C" fn moq_origin_follow_cancel(task: u32) -> i32 { /// hidden route not announced yet. No separate announcement stream is needed. /// Serve on-demand paths with [moq_origin_claim]. /// -/// `instance` is the only stream to accept, or NULL (or empty) for any. A request pinned to an -/// instance that does not serve the path is unroutable. +/// `instance` is an instance handle naming the only stream to accept, or 0 for any; pass the one +/// an announcement reported to pin that stream. A request pinned to an instance that does not +/// serve the path is unroutable. /// /// `on_broadcast` is invoked with a positive broadcast handle once served, then exactly once more /// with a terminal code: `0` (finished, including after [moq_origin_request_cancel]) or a negative @@ -2724,22 +2714,20 @@ pub extern "C" fn moq_origin_follow_cancel(task: u32) -> i32 { /// /// # Safety /// - The caller must ensure that path is a valid pointer to path_len bytes of data. -/// - `instance` may be NULL, or must point at `instance_len` readable bytes. /// - The caller must keep `user_data` valid until the terminal (`<= 0`) `on_broadcast` callback. #[unsafe(no_mangle)] pub unsafe extern "C" fn moq_origin_request( origin: u32, path: *const c_char, path_len: usize, - instance: *const c_char, - instance_len: usize, + instance: u32, on_broadcast: ffi::moq_status_callback, user_data: *mut c_void, ) -> i32 { ffi::enter(move || { let origin = ffi::parse_id(origin)?; let path = unsafe { ffi::parse_str(path, path_len)? }.to_string(); - let instance = unsafe { parse_instance(instance, instance_len)? }; + let instance = lookup_instance(instance)?; let on_broadcast = unsafe { ffi::OnStatus::new(user_data, on_broadcast)? }; State::lock().origin.request(origin, path, instance, on_broadcast) }) @@ -2759,18 +2747,92 @@ pub extern "C" fn moq_origin_request_cancel(task: u32) -> i32 { }) } -/// Parse an optional publisher instance, where NULL or empty means unpinned. +/// The instance a handle names, or none for 0. +fn lookup_instance(instance: u32) -> Result, Error> { + if instance == 0 { + return Ok(None); + } + let instance = ffi::parse_id(instance)?; + Ok(Some(State::lock().origin.instance(instance)?.clone())) +} + +/// Parse a named instance from `text`, refusing an empty one. +/// +/// Only a session mints an instance, for a route whose wire (moq-lite 06 or older, or +/// moq-transport) carries none, so text always names one: pass the handle a route or an +/// announcement reported to pin or forward a minted stream. +/// +/// Returns a non-zero instance handle the caller frees with [moq_instance_free], or a negative +/// code on failure. /// /// # Safety -/// - `instance` may be NULL, or must point at `instance_len` readable bytes. -unsafe fn parse_instance(instance: *const c_char, instance_len: usize) -> Result, Error> { - unsafe { ffi::parse_str_optional(instance, instance_len)? } - .map(|instance| { - instance - .parse() - .map_err(|_| Error::InvalidConfig("an instance is never empty".into())) - }) - .transpose() +/// - `text` must point at `text_len` readable bytes. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn moq_instance_parse(text: *const c_char, text_len: usize) -> i32 { + ffi::enter(move || { + let instance = unsafe { ffi::parse_str(text, text_len)? } + .parse() + .map_err(|_| Error::InvalidConfig("an instance is never empty".into()))?; + State::lock().origin.instance_insert(instance) + }) +} + +/// The instance's text, which alone does not tell a minted instance from a named one. +/// +/// The destination borrows the handle's storage until [moq_instance_free] (or, for an +/// announcement's, [moq_origin_announced_free]). +/// +/// Returns zero on success, or a negative code on failure. +/// +/// # Safety +/// - `dst` must point at a writable [moq_string]. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn moq_instance_text(instance: u32, dst: *mut moq_string) -> i32 { + ffi::enter(move || { + let instance = ffi::parse_id(instance)?; + let dst = unsafe { dst.as_mut() }.ok_or(Error::InvalidPointer)?; + let state = State::lock(); + let text = state.origin.instance(instance)?.as_str(); + *dst = moq_string { + data: text.as_ptr().cast::(), + len: text.len(), + }; + Ok(()) + }) +} + +/// Whether a session minted the instance for a route whose wire carries none: 1 if minted, 0 if +/// named, or a negative code on failure. A minted instance names the announcement, not the +/// publisher's stream, so signing refuses it and a catalog's instance check skips it. +#[unsafe(no_mangle)] +pub extern "C" fn moq_instance_minted(instance: u32) -> i32 { + ffi::enter(move || { + let instance = ffi::parse_id(instance)?; + Ok(i32::from(State::lock().origin.instance(instance)?.is_minted())) + }) +} + +/// Whether two instance handles name the same stream: 1 when their text and whether a session +/// minted them both match, 0 otherwise, or a negative code on failure. +#[unsafe(no_mangle)] +pub extern "C" fn moq_instance_equal(a: u32, b: u32) -> i32 { + ffi::enter(move || { + let (a, b) = (ffi::parse_id(a)?, ffi::parse_id(b)?); + let state = State::lock(); + Ok(i32::from(state.origin.instance(a)? == state.origin.instance(b)?)) + }) +} + +/// Free an instance handle the caller owns: one from [moq_instance_parse], [moq_route_instance], +/// or [moq_claim_instance]. An announcement's instance goes with [moq_origin_announced_free]. +/// +/// Returns zero on success, or a negative code on failure. +#[unsafe(no_mangle)] +pub extern "C" fn moq_instance_free(instance: u32) -> i32 { + ffi::enter(move || { + let instance = ffi::parse_id(instance)?; + State::lock().origin.instance_free(instance) + }) } /// Close an origin and clean up its resources. diff --git a/rs/moq-c/src/origin.rs b/rs/moq-c/src/origin.rs index 2e8db479776..337658b3d0a 100644 --- a/rs/moq-c/src/origin.rs +++ b/rs/moq-c/src/origin.rs @@ -40,6 +40,9 @@ pub struct Origin { /// Broadcast requests delivered to a claim's callback, freed after serve/route/reject. broadcast_request: NonZeroSlab<(moq_net::origin::Request, crate::ffi::Background)>, + /// Instance handles: parsed by the caller, reported by a route or claim, or owned by an + /// announcement record. + instances: NonZeroSlab, } #[derive(Clone)] @@ -85,8 +88,8 @@ struct AnnouncedRecord { capture_views: Vec, kind: moq_announce_kind, cost: u64, - /// The route's instance. - instance: String, + /// The route's instance handle, freed with the record. + instance: Id, } // The raw pointers only borrow immutable String allocations owned by this record. @@ -94,7 +97,7 @@ struct AnnouncedRecord { unsafe impl Send for AnnouncedRecord {} impl AnnouncedRecord { - fn new(event: moq_net::announce::Event) -> Self { + fn new(event: moq_net::announce::Event, instances: &mut NonZeroSlab) -> Result { use moq_net::announce::Event; let (update, kind) = match event { Event::Present(update) => (update, moq_announce_kind::MOQ_ANNOUNCE_KIND_PRESENT), @@ -114,14 +117,14 @@ impl AnnouncedRecord { }) .collect(); - Self { + Ok(Self { prefix: update.prefix.to_string(), _captures: captures, capture_views, kind, cost: update.route.cost().value(), - instance: update.route.instance().to_string(), - } + instance: instances.insert(update.route.instance().clone())?, + }) } } @@ -151,8 +154,6 @@ impl State { struct RouteEntry { inner: moq_net::origin::Route, driver: crate::ffi::Background, - /// The instance last read, borrowed by [Origin::route_instance]. - instance: String, /// The fill handle of the route's broadcast. broadcast: Id, } @@ -160,8 +161,6 @@ struct RouteEntry { struct ClaimEntry { inner: moq_net::origin::Claim, driver: crate::ffi::Background, - /// The instance last read, borrowed by [Origin::claim_instance]. - instance: String, /// The claim's request delivery task. requests: Id, } @@ -223,7 +222,7 @@ impl Origin { }; // Hold the lock only to buffer the event; release it before the callback. - let announced_id = State::lock().origin.announced.insert(AnnouncedRecord::new(event))?; + let announced_id = State::lock().origin.announced_insert(event)?; callback.call(announced_id); } } @@ -238,10 +237,7 @@ impl Origin { kind: announced.kind, route: moq_route_info { cost: announced.cost, - instance: moq_string { - data: announced.instance.as_ptr().cast::(), - len: announced.instance.len(), - }, + instance: announced.instance.into(), }, }; Ok(()) @@ -255,7 +251,33 @@ impl Origin { /// rather than on unannounce: an unannounce is its own delivered record, and auto-freeing /// the prior one would race a caller still reading it. pub fn announced_free(&mut self, announced: Id) -> Result<(), Error> { - self.announced.remove(announced).ok_or(Error::AnnouncementNotFound)?; + let record = self.announced.remove(announced).ok_or(Error::AnnouncementNotFound)?; + self.instances.remove(record.instance); + Ok(()) + } + + /// Buffer a delivered announce event, with a handle for its route's instance. + fn announced_insert(&mut self, event: moq_net::announce::Event) -> Result { + let record = AnnouncedRecord::new(event, &mut self.instances)?; + let instance = record.instance; + self.announced.insert(record).inspect_err(|_| { + self.instances.remove(instance); + }) + } + + /// A new instance handle the caller owns. + pub fn instance_insert(&mut self, instance: moq_net::Instance) -> Result { + self.instances.insert(instance) + } + + /// The instance a handle names. + pub fn instance(&self, instance: Id) -> Result<&moq_net::Instance, Error> { + self.instances.get(instance).ok_or(Error::NotFound) + } + + /// Release an instance handle the caller owns. An announcement's own is released with it. + pub fn instance_free(&mut self, instance: Id) -> Result<(), Error> { + self.instances.remove(instance).ok_or(Error::NotFound)?; Ok(()) } @@ -415,7 +437,6 @@ impl Origin { // Read only on request: reading a hidden job's instance binds it. self.routes.insert(RouteEntry { inner: route, - instance: String::new(), driver, broadcast, }) @@ -435,7 +456,6 @@ impl Origin { let result = origin.producer.claim(prefix, options); driver.check()?; let claim = result?; - let instance = claim.instance().to_string(); // The claim first, so the delivery task finds it once it runs; it cannot lock the // state before this call returns. @@ -444,7 +464,6 @@ impl Origin { let id = match self.claims.insert(ClaimEntry { inner: claim, driver: driver.clone(), - instance, requests, }) { Ok(id) => id, @@ -520,15 +539,12 @@ impl Origin { Ok(self.route_entry(route)?.broadcast) } - pub fn route_instance(&mut self, route: Id, dst: &mut moq_string) -> Result<(), Error> { - let entry = self.routes.get_mut(route).ok_or(Error::NotFound)?; + /// A new handle, the caller's, for the instance the route serves. + pub fn route_instance(&mut self, route: Id) -> Result { + let entry = self.routes.get(route).ok_or(Error::NotFound)?; entry.driver.check()?; - entry.instance = entry.inner.instance().to_string(); - *dst = moq_string { - data: entry.instance.as_ptr().cast::(), - len: entry.instance.len(), - }; - Ok(()) + let instance = entry.inner.instance().clone(); + self.instances.insert(instance) } /// Release a route handle, returning the route for the caller to retract by dropping it. @@ -549,15 +565,12 @@ impl Origin { Ok(result?) } - pub fn claim_instance(&mut self, claim: Id, dst: &mut moq_string) -> Result<(), Error> { - let entry = self.claims.get_mut(claim).ok_or(Error::NotFound)?; + /// A new handle, the caller's, for the instance the claim serves. + pub fn claim_instance(&mut self, claim: Id) -> Result { + let entry = self.claims.get(claim).ok_or(Error::NotFound)?; entry.driver.check()?; - entry.instance = entry.inner.instance().to_string(); - *dst = moq_string { - data: entry.instance.as_ptr().cast::(), - len: entry.instance.len(), - }; - Ok(()) + let instance = entry.inner.instance().clone(); + self.instances.insert(instance) } /// Release a claim handle and stop its request delivery, returning the claim for the diff --git a/rs/moq-c/src/test.rs b/rs/moq-c/src/test.rs index 61296ab8c73..c0a72d6f9cc 100644 --- a/rs/moq-c/src/test.rs +++ b/rs/moq-c/src/test.rs @@ -43,10 +43,15 @@ fn id(raw: i32) -> u32 { raw as u32 } -/// Create a route at `path` on `origin`, returning its route and broadcast fill handles. +/// Create a route at `path` on `origin`, returning its route and broadcast fill handles. Frees +/// the instance handle `options` names, which the call only borrows. fn create_route(origin: u32, path: &[u8], options: Option<&moq_route_options>) -> (u32, u32) { + let instance = options.map_or(0, |options| options.instance); let options = options.map_or(std::ptr::null(), |options| options as *const _); let route = id(unsafe { moq_origin_route(origin, path.as_ptr() as *const c_char, path.len(), options) }); + if instance != 0 { + assert_eq!(moq_instance_free(instance), 0); + } (route, id(moq_route_broadcast(route))) } @@ -57,12 +62,15 @@ fn publish_broadcast(origin: u32, path: &[u8]) -> u32 { create_route(origin, path, None).1 } -/// Route options at `cost` under the stable `instance`, borrowing it. +/// Route options at `cost` under the stable `instance`, or a fresh one when empty. fn route_options(cost: u64, instance: &str, hidden: bool) -> moq_route_options { moq_route_options { cost, - instance: instance.as_ptr().cast(), - instance_len: instance.len(), + instance: if instance.is_empty() { + 0 + } else { + parse_instance(instance) + }, epoch: 0, hidden, } @@ -73,26 +81,39 @@ fn hidden() -> moq_route_options { route_options(0, "", true) } -/// The instance a route serves. -fn instance_of(route: u32) -> String { - read_instance(|dst| unsafe { moq_route_instance(route, dst) }) -} - -fn claim_instance_of(claim: u32) -> String { - read_instance(|dst| unsafe { moq_claim_instance(claim, dst) }) +/// A caller-owned handle naming `text`. +fn parse_instance(text: &str) -> u32 { + id(unsafe { moq_instance_parse(text.as_ptr().cast(), text.len()) }) } -fn read_instance(read: impl FnOnce(&mut moq_string) -> i32) -> String { +/// The text an instance handle names. +fn instance_text(instance: u32) -> String { let mut dst = moq_string { data: std::ptr::null(), len: 0, }; - assert_eq!(read(&mut dst), 0); + assert_eq!(unsafe { moq_instance_text(instance, &mut dst) }, 0); unsafe { std::str::from_utf8(std::slice::from_raw_parts(dst.data.cast::(), dst.len)) } .unwrap() .to_string() } +/// The text of a caller-owned instance handle, freeing it. +fn take_instance(instance: u32) -> String { + let text = instance_text(instance); + assert_eq!(moq_instance_free(instance), 0); + text +} + +/// The instance a route serves. +fn instance_of(route: u32) -> String { + take_instance(id(moq_route_instance(route))) +} + +fn claim_instance_of(claim: u32) -> String { + take_instance(id(moq_claim_instance(claim))) +} + /// Request a published broadcast via `moq_origin_request` and return its handle. fn request_broadcast(origin: u32, path: &[u8]) -> u32 { let code = request_once(origin, path); @@ -109,7 +130,6 @@ fn request_once(origin: u32, path: &[u8]) -> i32 { origin, path.as_ptr() as *const c_char, path.len(), - std::ptr::null(), 0, Some(channel_callback), cb.ptr, @@ -201,13 +221,7 @@ fn announce_info(announced: u32) -> moq_announce_event { captures: std::ptr::null(), captures_len: 0, kind: moq_announce_kind::MOQ_ANNOUNCE_KIND_PRESENT, - route: moq_route_info { - cost: 0, - instance: moq_string { - data: std::ptr::null(), - len: 0, - }, - }, + route: moq_route_info { cost: 0, instance: 0 }, }; assert_eq!(unsafe { moq_origin_announced_info(announced, &mut info) }, 0); info @@ -218,16 +232,9 @@ fn fresh_instance() -> String { moq_net::Instance::random().to_string() } -/// The instance a received route carries. +/// The instance a received route carries, owned by its announcement. fn route_instance(route: &moq_route_info) -> Option { - (route.instance.len > 0).then(|| unsafe { - std::str::from_utf8(std::slice::from_raw_parts( - route.instance.data.cast::(), - route.instance.len, - )) - .unwrap() - .to_string() - }) + (route.instance != 0).then(|| instance_text(route.instance)) } impl Drop for Callback { @@ -4282,7 +4289,6 @@ fn a_new_route_at_a_path_restarts() { origin, path.as_ptr() as *const c_char, path.len(), - std::ptr::null(), 0, Some(follow_channel_callback), follow_cb.ptr, @@ -4343,7 +4349,6 @@ fn dynamic_serves_a_request_under_a_prefix() { origin, path.as_ptr() as *const c_char, path.len(), - std::ptr::null(), 0, Some(channel_callback), req_cb.ptr, @@ -4359,16 +4364,16 @@ fn dynamic_serves_a_request_under_a_prefix() { let got = unsafe { std::str::from_utf8(std::slice::from_raw_parts(info.data.cast::(), info.len)).unwrap() }; assert_eq!(got, "live/cam"); - // Unreadable options leave the request to answer. - let invalid = [0xff]; + // Options naming a freed instance leave the request to answer. + let freed = parse_instance("freed"); + assert_eq!(moq_instance_free(freed), 0); let unreadable = moq_route_options { - instance: invalid.as_ptr().cast(), - instance_len: invalid.len(), + instance: freed, ..hidden() }; assert_eq!( unsafe { moq_broadcast_request_route(request, &unreadable) }, - crate::error::MOQ_ERROR_UTF8 + crate::error::MOQ_ERROR_NOT_FOUND ); // A hidden job route answers nobody until it announces, under the instance its claim @@ -4409,7 +4414,6 @@ fn dynamic_serves_a_held_consumer_without_taking_its_handle() { origin, b"live/cam".as_ptr().cast(), 8, - std::ptr::null(), 0, Some(channel_callback), result.ptr, @@ -5286,7 +5290,6 @@ fn follow_local() { origin, path.as_ptr() as *const c_char, path.len(), - std::ptr::null(), 0, Some(follow_channel_callback), cb.ptr, @@ -5651,7 +5654,6 @@ fn follow_close_cancels() { origin, path.as_ptr() as *const c_char, path.len(), - std::ptr::null(), 0, Some(follow_channel_callback), cb.ptr, @@ -5688,7 +5690,6 @@ fn follow_delivers_a_restart() { origin, path.as_ptr() as *const c_char, path.len(), - std::ptr::null(), 0, Some(follow_channel_callback), cb.ptr, @@ -5733,7 +5734,6 @@ fn follow_delivers_a_takeover_once(dynamic_at: &str) { origin, path.as_ptr() as *const c_char, path.len(), - std::ptr::null(), 0, Some(follow_channel_callback), cb.ptr, @@ -5763,30 +5763,25 @@ fn follow_delivers_a_held_takeover_once() { } /// The signature of `moq_origin_request`. -type Resolve = unsafe extern "C" fn( - u32, - *const c_char, - usize, - *const c_char, - usize, - crate::ffi::moq_status_callback, - *mut c_void, -) -> i32; +type Resolve = + unsafe extern "C" fn(u32, *const c_char, usize, u32, crate::ffi::moq_status_callback, *mut c_void) -> i32; /// Resolve `live` on `origin` through `call`, pinned to `instance`. fn resolve_pinned(call: Resolve, origin: u32, instance: &str, cb: &Callback) -> i32 { let path = b"live"; - unsafe { + let instance = parse_instance(instance); + let code = unsafe { call( origin, path.as_ptr() as *const c_char, path.len(), - instance.as_ptr() as *const c_char, - instance.len(), + instance, Some(channel_callback), cb.ptr, ) - } + }; + assert_eq!(moq_instance_free(instance), 0); + code } /// An instance pins both resolutions to one stream: a request for another one fails, and a @@ -5820,17 +5815,18 @@ fn an_instance_pins_request_and_follow() { let second = publish_run(origin, "live"); let cb = Callback::new(); let path = b"live"; + let pin = parse_instance(&instance); let task = id(unsafe { moq_origin_follow( origin, path.as_ptr() as *const c_char, path.len(), - instance.as_ptr() as *const c_char, - instance.len(), + pin, Some(follow_channel_callback), cb.ptr, ) }); + assert_eq!(moq_instance_free(pin), 0); let delivered = id(cb.recv()); assert_eq!(moq_consume_close(delivered), 0); assert_eq!(moq_origin_follow_cancel(task), 0); @@ -5858,7 +5854,6 @@ fn follow(origin: u32, path: &[u8], cb: &Callback) -> u32 { origin, path.as_ptr() as *const c_char, path.len(), - std::ptr::null(), 0, Some(follow_channel_callback), cb.ptr, @@ -5907,7 +5902,6 @@ fn follow_delivers_the_instance_again_after_an_end() { origin, path.as_ptr() as *const c_char, path.len(), - std::ptr::null(), 0, Some(gated_follow_callback), &*gate as *const Gate as *mut c_void, @@ -8244,6 +8238,115 @@ fn announce_route_crosses_a_relay_session() { } } +/// A route received over moq-lite 06 carries a session-minted instance, and only its handle, +/// not its text, pins a request or a follower to that stream. +#[test] +fn a_minted_instance_pins_request_and_follow() { + let source = id(moq_origin_create()); + let sink = id(moq_origin_create()); + let server_cb = Callback::new(); + let (server, addr, fingerprint) = listen(&server_cb); + let url = format!("https://{addr}/"); + + let fingerprints = [moq_str(&fingerprint)]; + let versions = [moq_str("moq-lite-06")]; + let mut config = client_config(); + config.tls_fingerprints = fingerprints.as_ptr(); + config.tls_fingerprints_len = fingerprints.len(); + config.tls_host_name = "localhost".as_ptr() as *const c_char; + config.tls_host_name_len = "localhost".len(); + config.versions = versions.as_ptr(); + config.versions_len = versions.len(); + let client_cb = States::new(); + let client = id(unsafe { + moq_session_connect( + url.as_ptr() as *const c_char, + url.len(), + &config, + source, + 0, + Some(state_callback), + client_cb.ptr, + ) + }); + let session_cb = States::new(); + let session = + id(unsafe { moq_session_request_accept(id(server_cb.recv()), 0, sink, Some(state_callback), session_cb.ptr) }); + for cb in [&client_cb, &session_cb] { + assert_eq!(cb.recv().phase, moq_connection_phase::MOQ_CONNECTION_PHASE_CONNECTED); + } + + let announced_cb = Callback::new(); + let task = id(unsafe { moq_origin_announced(sink, std::ptr::null(), Some(channel_callback), announced_cb.ptr) }); + let (route, broadcast) = create_route(source, b"live", Some(&route_options(0, "", false))); + let (announced, info) = announced_cb.recv_route(); + let minted = info.route.instance; + assert_eq!(moq_instance_minted(minted), 1, "lite-06 carries no instance"); + let named = parse_instance(&instance_text(minted)); + assert_eq!(moq_instance_minted(named), 0, "text always names an instance"); + assert_eq!(moq_instance_equal(minted, named), 0); + assert_eq!(moq_instance_equal(minted, minted), 1); + + let path = b"live"; + let cb = Callback::new(); + let _task = id(unsafe { + moq_origin_request( + sink, + path.as_ptr().cast(), + path.len(), + minted, + Some(channel_callback), + cb.ptr, + ) + }); + assert_eq!(moq_consume_close(id(cb.recv())), 0); + assert_eq!(cb.recv_terminal(), 0); + + let cb = Callback::new(); + let follow = id(unsafe { + moq_origin_follow( + sink, + path.as_ptr().cast(), + path.len(), + minted, + Some(follow_channel_callback), + cb.ptr, + ) + }); + assert_eq!(moq_consume_close(id(cb.recv())), 0); + assert_eq!(moq_origin_follow_cancel(follow), 0); + assert_eq!(cb.recv_terminal(), 0); + + let cb = Callback::new(); + let _task = id(unsafe { + moq_origin_request( + sink, + path.as_ptr().cast(), + path.len(), + named, + Some(channel_callback), + cb.ptr, + ) + }); + assert!(cb.recv() < 0, "the minted instance's text names another stream"); + + assert_eq!(moq_instance_free(named), 0); + assert_eq!(moq_origin_announced_free(announced), 0); + assert_eq!(moq_origin_announced_cancel(task), 0); + assert_eq!(announced_cb.recv_announce_terminal(), 0); + for (session, cb) in [(session, &session_cb), (client, &client_cb)] { + assert_eq!(moq_session_close(session), 0); + cb.recv_terminal(); + } + assert_eq!(moq_server_close(server), 0); + assert_eq!(server_cb.recv_terminal(), 0); + assert_eq!(moq_route_free(route), 0); + assert_eq!(moq_publish_close(broadcast), 0); + for origin in [source, sink] { + assert_eq!(moq_origin_close(origin), 0); + } +} + #[test] fn server_accepts_a_session() { let served = id(moq_origin_create()); @@ -8308,7 +8411,6 @@ fn server_accepts_a_session() { received, path.as_ptr() as *const c_char, path.len(), - std::ptr::null(), 0, Some(follow_channel_callback), announced_cb.ptr, diff --git a/rs/moq-crypto/src/e2ee/epoch.rs b/rs/moq-crypto/src/e2ee/epoch.rs index f17812f0064..bf934ce8b65 100644 --- a/rs/moq-crypto/src/e2ee/epoch.rs +++ b/rs/moq-crypto/src/e2ee/epoch.rs @@ -35,7 +35,13 @@ impl Epoch { /// The epoch a resolved broadcast's route announced, if its instance is one. pub fn of(broadcast: &moq_net::broadcast::Consumer) -> Option { - broadcast.info().instance.as_ref()?.as_str().parse().ok() + // One a session minted for a wire without instances names no epoch. + let instance = broadcast + .info() + .instance + .clone() + .filter(|instance| !instance.is_minted())?; + instance.as_str().parse().ok() } } diff --git a/rs/moq-crypto/src/sign/broadcast.rs b/rs/moq-crypto/src/sign/broadcast.rs index a8c52bd8458..40f5711f35e 100644 --- a/rs/moq-crypto/src/sign/broadcast.rs +++ b/rs/moq-crypto/src/sign/broadcast.rs @@ -97,7 +97,7 @@ impl Consumer { /// # Errors /// /// [`Error::Instance`] if `inner` was not resolved through a route that carries its - /// publisher's instance: one a relay [minted](moq_net::Instance::is_minted) for a wire without + /// publisher's instance: one a session [minted](moq_net::Instance::is_minted) for a wire without /// instances names the announcement, not the publisher. pub fn new(inner: moq_net::broadcast::Consumer, key: PublicKey, path: moq_net::Path) -> Result { let instance = inner diff --git a/rs/moq-ffi/build.sh b/rs/moq-ffi/build.sh index 08336d42ad7..7d72dac75b8 100755 --- a/rs/moq-ffi/build.sh +++ b/rs/moq-ffi/build.sh @@ -200,7 +200,7 @@ generate_bindings() { # C++ uses a third-party bindgen too: a fork carrying LiveKit's async support # ported to uniffi 0.32, which upstream has not released. Install it with: - # cargo install --locked uniffi-bindgen-cpp --git https://github.com/kixelated/uniffi-bindgen-cpp --tag v0.11.0-kixelated.7+v0.32.2 + # cargo install --locked uniffi-bindgen-cpp --git https://github.com/kixelated/uniffi-bindgen-cpp --tag v0.11.0-kixelated.8+v0.32.2 if command -v uniffi-bindgen-cpp >/dev/null 2>&1; then echo " Generating cpp bindings..." uniffi-bindgen-cpp --library "$lib_path" \ diff --git a/rs/moq-ffi/src/instance.rs b/rs/moq-ffi/src/instance.rs new file mode 100644 index 00000000000..366f2fa6351 --- /dev/null +++ b/rs/moq-ffi/src/instance.rs @@ -0,0 +1,48 @@ +//! Stream instances, mirroring [`moq_net::Instance`]. + +use std::sync::Arc; + +use crate::error::MoqError; + +/// Names one continuous stream at a path, as routes and requests carry it. +/// +/// A session mints one for a route whose wire (moq-lite 06 or older, or moq-transport) carries +/// none; only a session can, so [`MoqInstance::parse`] always names one. Two instances are equal +/// only when their text and whether a session minted them both match, so pass an instance a +/// route reported back as is, rather than its text, to pin that stream. +#[derive(Debug, Clone, PartialEq, Eq, Hash, uniffi::Object)] +#[uniffi::export(Debug, Display, Eq, Hash)] +pub struct MoqInstance(pub(crate) moq_net::Instance); + +impl std::fmt::Display for MoqInstance { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + std::fmt::Display::fmt(&self.0, f) + } +} + +#[uniffi::export] +impl MoqInstance { + /// A named instance with `text`, refusing an empty one. + #[uniffi::constructor] + pub fn parse(text: String) -> Result, MoqError> { + Ok(Arc::new(Self(text.try_into()?))) + } + + /// The instance's text, which alone does not tell a minted instance from a named one. + pub fn text(&self) -> String { + self.0.as_str().to_string() + } + + /// Whether a session minted this instance for a route whose wire carries none. It names the + /// announcement rather than the publisher's stream, so signing refuses it and a catalog's + /// instance check skips it. + pub fn is_minted(&self) -> bool { + self.0.is_minted() + } +} + +impl MoqInstance { + pub(crate) fn wrap(instance: moq_net::Instance) -> Arc { + Arc::new(Self(instance)) + } +} diff --git a/rs/moq-ffi/src/lib.rs b/rs/moq-ffi/src/lib.rs index 0cf6c8649aa..01f391a77a2 100644 --- a/rs/moq-ffi/src/lib.rs +++ b/rs/moq-ffi/src/lib.rs @@ -20,6 +20,7 @@ pub mod datagram; pub mod demand; pub mod error; mod ffi; +pub mod instance; pub mod json; mod log; pub mod media; diff --git a/rs/moq-ffi/src/origin.rs b/rs/moq-ffi/src/origin.rs index 20566ac3ce6..898b424121f 100644 --- a/rs/moq-ffi/src/origin.rs +++ b/rs/moq-ffi/src/origin.rs @@ -6,6 +6,7 @@ use std::task::{Poll, ready}; use crate::consumer::MoqBroadcastConsumer; use crate::error::MoqError; use crate::ffi::{Background, OneShot, Polled}; +use crate::instance::MoqInstance; use crate::producer::MoqBroadcastProducer; /// Config used when creating an origin. @@ -50,11 +51,12 @@ pub struct MoqRouteOptions { /// A stable instance for the stream, or `None` to mint a fresh one. /// /// Set one only for a publisher that continues its group sequences across restarts and - /// raises `epoch` on each: readers stitch every route of one instance together, so - /// restarting the sequences under an old instance splices unrelated groups into their - /// tracks. Naming an instance grants nothing: publishing still needs the path's grant. + /// raises `epoch` on each, or to forward an observed route's stream as is: readers stitch + /// every route of one instance together, so restarting the sequences under an old instance + /// splices unrelated groups into their tracks. Naming an instance grants nothing: + /// publishing still needs the path's grant. #[uniffi(default = None)] - pub instance: Option, + pub instance: Option>, /// Which run of a stable `instance` this is: only its highest epoch is eligible, cheaper /// or not. Ignored without one. #[uniffi(default = 0)] @@ -71,7 +73,7 @@ impl TryFrom for moq_net::origin::RouteOptions { } .with_cost(options.cost); if let Some(instance) = options.instance { - out = out.with_instance(instance.parse()?, options.epoch); + out = out.with_instance(instance.0.clone(), options.epoch); } Ok(out) } @@ -88,14 +90,14 @@ pub struct MoqRouteInfo { /// Preference among routes covering the same prefix: lower wins. pub cost: u64, /// The stream the route serves: routes with one instance are one continuous stream. - pub instance: String, + pub instance: Arc, } impl From for MoqRouteInfo { fn from(route: moq_net::origin::RouteInfo) -> Self { Self { cost: route.cost().value(), - instance: route.instance().to_string(), + instance: MoqInstance::wrap(route.instance().clone()), } } } @@ -144,7 +146,7 @@ pub struct MoqRoute { /// The route's broadcast, wrapped once so every caller shares its catalog. broadcast: Arc, /// The instance last read, reported once the route is cancelled. - instance: std::sync::Mutex, + instance: std::sync::Mutex>, driver: Background, } @@ -158,7 +160,7 @@ pub struct MoqClaim { /// The claim itself, released on cancel. reads: Polled, /// The instance last read, reported once the claim is cancelled. - instance: std::sync::Mutex, + instance: std::sync::Mutex, driver: Background, } @@ -376,10 +378,10 @@ impl MoqOriginConsumer { /// one. Pass an `instance` to follow only that stream: another one taking over ends it, /// and the stream is handed out again if it returns. #[uniffi::method(default(instance = None))] - pub fn follow(&self, path: String, instance: Option) -> Result, MoqError> { + pub fn follow(&self, path: String, instance: Option>) -> Result, MoqError> { let _guard = crate::ffi::enter(); self.driver.check()?; - let instance: Option = instance.as_deref().map(str::parse).transpose()?; + let instance = instance.map(|instance| instance.0.clone()); // Following checks the permission eagerly, so an unreachable path fails here rather // than surfacing later as a `Closed` the caller can't tell from the origin ending. let inner = self.inner.follow(path.as_str(), instance)?; @@ -401,8 +403,12 @@ impl MoqOriginConsumer { /// announcement stream is needed. Pass an `instance` to refuse any other stream. Drop the /// returned future to cancel. #[uniffi::method(default(instance = None))] - pub async fn request(&self, path: String, instance: Option) -> Result, MoqError> { - let instance: Option = instance.as_deref().map(str::parse).transpose()?; + pub async fn request( + &self, + path: String, + instance: Option>, + ) -> Result, MoqError> { + let instance = instance.map(|instance| instance.0.clone()); let broadcast = self .driver .watch(async { Ok(self.inner.request(path.as_str(), instance).await?) }) @@ -439,10 +445,7 @@ impl MoqRoute { } // Reading a hidden job's instance binds it, so one is cached only once it announces or // is read. - let instance = match route.is_announced() { - true => route.instance().to_string(), - false => String::new(), - }; + let instance = route.is_announced().then(|| route.instance().clone()); Ok(Self { instance: std::sync::Mutex::new(instance), reads: Polled::new(route).supervised(driver.clone()), @@ -469,7 +472,7 @@ impl MoqRoute { } /// The stream this route serves now, or once cancelled, the last one it reported or - /// announced under (empty for a hidden route that did neither). + /// announced under (`None` for a hidden route that did neither). /// /// Named options keep one instance. A job's route takes its claim's current one when it /// first announces or when this first reads it. Read first, the job announces under it, @@ -479,12 +482,12 @@ impl MoqRoute { /// Named `instance_id` rather than `instance` because the generated C and C++ /// objects keep their handle in a private field named `instance`; the wrappers /// expose it as `instance`. - pub fn instance_id(&self) -> String { + pub fn instance_id(&self) -> Option> { let mut last = self.instance.lock().unwrap(); - if let Some(current) = self.reads.peek(|route| route.instance().to_string()) { - *last = current; + if let Some(current) = self.reads.peek(|route| route.instance().clone()) { + *last = Some(current); } - last.clone() + last.clone().map(MoqInstance::wrap) } /// Make the route visible, or keep it so. @@ -496,9 +499,9 @@ impl MoqRoute { pub fn announce(&self) -> Result<(), MoqError> { let instance = self.with(|route| { route.announce()?; - Ok(route.instance().to_string()) + Ok(route.instance().clone()) })?; - *self.instance.lock().unwrap() = instance; + *self.instance.lock().unwrap() = Some(instance); Ok(()) } @@ -532,7 +535,7 @@ impl MoqRoute { impl MoqClaim { fn new(claim: moq_net::origin::Claim, driver: Background) -> Self { Self { - instance: std::sync::Mutex::new(claim.instance().to_string()), + instance: std::sync::Mutex::new(claim.instance().clone()), reads: Polled::new(claim).supervised(driver.clone()), driver, } @@ -558,19 +561,19 @@ impl MoqClaim { /// /// Named `instance_id` rather than `instance` for the reason [`MoqRoute::instance_id`] /// gives. - pub fn instance_id(&self) -> String { + pub fn instance_id(&self) -> Arc { let mut last = self.instance.lock().unwrap(); - if let Some(current) = self.reads.peek(|claim| claim.instance().to_string()) { + if let Some(current) = self.reads.peek(|claim| claim.instance().clone()) { *last = current; } - last.clone() + MoqInstance::wrap(last.clone()) } /// Make the claim visible, or keep it so. pub fn announce(&self) -> Result<(), MoqError> { let instance = self.with(|claim| { claim.announce()?; - Ok(claim.instance().to_string()) + Ok(claim.instance().clone()) })?; *self.instance.lock().unwrap() = instance; Ok(()) diff --git a/rs/moq-ffi/src/test.rs b/rs/moq-ffi/src/test.rs index 42e6c706efe..c549c9f959f 100644 --- a/rs/moq-ffi/src/test.rs +++ b/rs/moq-ffi/src/test.rs @@ -9,6 +9,7 @@ use crate::consumer::MoqTrackConsumer; use crate::consumer::{MoqAnchor, MoqDelay, MoqOrder, MoqRange, MoqSubscription, moq_subscription_defaults}; use crate::datagram::*; use crate::error::{MoqError, MoqProtocolError, MoqProtocolKind}; +use crate::instance::MoqInstance; use crate::json::{ MoqJsonSnapshotConfig, MoqJsonSnapshotConsumer, MoqJsonSnapshotProducer, MoqJsonStreamConsumer, MoqJsonStreamProducer, MoqJsonWindowConfig, MoqJsonWindowConsumer, MoqJsonWindowEvent, MoqJsonWindowProducer, @@ -432,7 +433,7 @@ async fn route_options_reach_the_announced_route() { let announced = consumer.announced(MoqAnnounceConfig::default()).unwrap(); let info = next_announced(&announced).await.route; assert_eq!(info.cost, 9); - assert_eq!(info.instance, route.instance_id()); + assert_eq!(Some(info.instance), route.instance_id()); // Re-pricing changes the route in place: the listing never reports it, but a fresh // listing reads the new price under the same instance. @@ -440,7 +441,7 @@ async fn route_options_reach_the_announced_route() { let repriced = consumer.announced(MoqAnnounceConfig::default()).unwrap(); let info = next_announced(&repriced).await.route; assert_eq!(info.cost, 4); - assert_eq!(info.instance, route.instance_id()); + assert_eq!(Some(info.instance), route.instance_id()); route.broadcast().close().unwrap(); } @@ -456,28 +457,23 @@ async fn route_options_name_an_instance_or_refuse() { .route( "instance-route".into(), MoqRouteOptions { - instance: Some("cam".into()), + instance: Some(MoqInstance::parse("cam".into()).unwrap()), epoch: 3, ..Default::default() }, ) .unwrap(); - assert_eq!(route.instance_id(), "cam"); - assert_eq!(next_announced(&announced).await.route.instance, "cam"); + let served = route.instance_id().unwrap(); + assert_eq!(served.text(), "cam"); + assert!(!served.is_minted()); + assert_eq!(next_announced(&announced).await.route.instance, served); - // An instance is never empty, and the refusal creates nothing. - let empty = MoqRouteOptions { - instance: Some(String::new()), - ..Default::default() - }; - assert!(matches!( - origin.route("refused".into(), empty.clone()), - Err(MoqError::InvalidInstance { .. }) - )); + // An instance is never empty, and text never names a minted one. assert!(matches!( - origin.claim("refused".into(), empty), + MoqInstance::parse(String::new()), Err(MoqError::InvalidInstance { .. }) )); + assert!(!MoqInstance::parse("~cam".into()).unwrap().is_minted()); route.cancel(); } @@ -490,7 +486,7 @@ async fn a_new_route_at_a_path_restarts_its_followers() { let following = consumer.follow("instance-route".into(), None).unwrap(); let first = origin.route("instance-route".into(), Default::default()).unwrap(); let announce = next_announced(&announced).await; - assert_eq!(announce.route.instance, first.instance_id()); + assert_eq!(Some(announce.route.instance), first.instance_id()); following.available().await.unwrap().unwrap(); // A new run of the same publisher is a new route with its own instance: followers @@ -508,7 +504,7 @@ async fn a_new_route_at_a_path_restarts_its_followers() { first.withdraw().unwrap(); second.withdraw().unwrap(); match next_event(&announced).await { - MoqAnnounceEvent::Absent { announce } => assert_eq!(announce.route.instance, second.instance_id()), + MoqAnnounceEvent::Absent { announce } => assert_eq!(Some(announce.route.instance), second.instance_id()), other => panic!("expected an absence, got {other:?}"), } @@ -3712,7 +3708,7 @@ async fn resolve_honors_the_reference_instance() { let served = moq_net::Instance::random().to_string(); let other = moq_net::Instance::random().to_string(); let options = MoqRouteOptions { - instance: Some(served.clone()), + instance: Some(MoqInstance::parse(served.clone()).unwrap()), ..Default::default() }; @@ -3784,7 +3780,10 @@ async fn announce_and_withdraw_toggle_discovery() { } } } - assert_eq!(wait_live(&announced, true).await.route.instance, route.instance_id()); + assert_eq!( + Some(wait_live(&announced, true).await.route.instance), + route.instance_id() + ); route.withdraw().unwrap(); wait_live(&announced, false).await; @@ -3797,7 +3796,10 @@ async fn announce_and_withdraw_toggle_discovery() { // Announcing again brings the same stream back. route.announce().unwrap(); - assert_eq!(wait_live(&announced, true).await.route.instance, route.instance_id()); + assert_eq!( + Some(wait_live(&announced, true).await.route.instance), + route.instance_id() + ); kio::clock::timeout(TIMEOUT, consumer.request("live".into(), None)) .await .expect("timed out requesting the reannounced broadcast") @@ -5061,11 +5063,11 @@ async fn dynamic_broadcast_request() { let served = job.broadcast(); let track = served.publish_track("status".into(), Some(timed_info())).unwrap(); // Only announce answers the requester, under the claim's instance and after admission. - let instance = job.instance_id(); + let instance = job.instance_id().unwrap(); job.announce().unwrap(); // A re-price keeps the instance the answer was announced under. job.set_cost(7).unwrap(); - assert_eq!(job.instance_id(), instance); + assert_eq!(job.instance_id(), Some(instance.clone())); assert!(matches!(request.path(), Err(MoqError::AlreadyResponded))); let broadcast = kio::clock::timeout(TIMEOUT, requesting) @@ -6279,7 +6281,7 @@ async fn follow_hands_out_the_instance_again_after_an_end() { let origin = MoqOriginProducer::new(MoqOriginConfig::default()); let followed = origin.consume().follow("live".into(), None).unwrap(); let options = MoqRouteOptions { - instance: Some(moq_net::Instance::random().to_string()), + instance: Some(MoqInstance::wrap(moq_net::Instance::random())), ..Default::default() }; let first_route = origin.route("live".into(), options.clone()).unwrap(); @@ -6323,7 +6325,7 @@ async fn follow_hands_out_the_instance_again_after_an_end() { async fn an_instance_pins_request_and_follow() { let origin = MoqOriginProducer::new(MoqOriginConfig::default()); let consumer = origin.consume(); - let instance = moq_net::Instance::random().to_string(); + let instance = MoqInstance::wrap(moq_net::Instance::random()); let first = origin .route( "live".into(), @@ -6338,13 +6340,10 @@ async fn an_instance_pins_request_and_follow() { .await .expect("timed out requesting the pinned instance") .unwrap(); - assert_eq!( - pinned.inner().info().instance.as_ref().map(ToString::to_string), - Some(instance.clone()) - ); + assert_eq!(pinned.inner().info().instance.as_ref(), Some(&instance.0)); let err = kio::clock::timeout( TIMEOUT, - consumer.request("live".into(), Some(moq_net::Instance::random().to_string())), + consumer.request("live".into(), Some(MoqInstance::wrap(moq_net::Instance::random()))), ) .await .expect("timed out requesting another instance") @@ -6355,11 +6354,6 @@ async fn an_instance_pins_request_and_follow() { crate::error::MoqErrorScope::Stream, crate::error::MoqProtocolKind::Unroutable, ); - assert!(matches!( - consumer.follow("live".into(), Some(String::new())), - Err(MoqError::InvalidInstance { .. }) - )); - let followed = consumer.follow("live".into(), Some(instance.clone())).unwrap(); kio::clock::timeout(TIMEOUT, followed.available()) .await @@ -6373,10 +6367,7 @@ async fn an_instance_pins_request_and_follow() { .await .expect("timed out requesting the takeover") .unwrap(); - assert_ne!( - takeover.inner().info().instance.as_ref().map(ToString::to_string), - Some(instance) - ); + assert_ne!(takeover.inner().info().instance.as_ref(), Some(&instance.0)); // Another instance taking over ends the pinned follow, which hands out nothing else. let ended = kio::clock::timeout(TIMEOUT, followed.available()) .await @@ -6394,6 +6385,80 @@ async fn an_instance_pins_request_and_follow() { second.broadcast().close().unwrap(); } +/// A route that crossed lite-06 carries an instance its session minted. The announcement +/// reports it minted, and passing that instance back pins request and follow to its stream, +/// while its text names another, named instance that nothing serves. +#[tokio::test] +async fn a_minted_instance_pins_request_and_follow() { + let server_origin = MoqOriginProducer::new(MoqOriginConfig::default()); + let versions = vec!["moq-lite-06".to_string()]; + let server = MoqServer::new(MoqServerConfig { + bind: Some("127.0.0.1:0".into()), + tls: localhost_tls(), + versions: versions.clone(), + publish: Some(server_origin.clone()), + ..Default::default() + }) + .unwrap(); + let addr = kio::clock::timeout(TIMEOUT, server.listen()) + .await + .expect("listen timed out") + .expect("listen failed"); + let accepting = server.clone(); + let accept = tokio::spawn(async move { + let request = accepting.accept().await.unwrap().expect("a session"); + request.accept(None, None).await.expect("handshake failed") + }); + let broadcast = create_announced(&server_origin, "live"); + + let viewer = MoqOriginProducer::new(MoqOriginConfig::default()); + let client = MoqClient::new(MoqClientConfig { + tls: insecure_tls(), + versions, + bind: Some("127.0.0.1:0".into()), + consume: Some(viewer.clone()), + ..Default::default() + }) + .unwrap(); + let _session = kio::clock::timeout(TIMEOUT, client.connect(format!("https://{addr}"))) + .await + .expect("connect timed out") + .expect("connect failed"); + let _server_session = kio::clock::timeout(TIMEOUT, accept).await.unwrap().unwrap(); + + let consumer = viewer.consume(); + let announced = consumer.announced(MoqAnnounceConfig::default()).unwrap(); + let minted = next_announced(&announced).await.route.instance; + assert!(minted.is_minted()); + + let pinned = kio::clock::timeout(TIMEOUT, consumer.request("live".into(), Some(minted.clone()))) + .await + .expect("timed out requesting the minted instance") + .unwrap(); + assert_eq!(pinned.inner().info().instance.as_ref(), Some(&minted.0)); + let followed = consumer.follow("live".into(), Some(minted.clone())).unwrap(); + let resolved = kio::clock::timeout(TIMEOUT, followed.available()) + .await + .expect("timed out following the minted instance") + .unwrap() + .expect("the minted instance"); + assert_eq!(resolved.inner().info().instance.as_ref(), Some(&minted.0)); + + let named = MoqInstance::parse(minted.text()).unwrap(); + assert_ne!(named, minted); + let err = kio::clock::timeout(TIMEOUT, consumer.request("live".into(), Some(named))) + .await + .expect("timed out requesting the named text") + .err() + .expect("the same text, named, is another instance"); + assert_protocol( + &err, + crate::error::MoqErrorScope::Stream, + crate::error::MoqProtocolKind::Unroutable, + ); + drop(broadcast); +} + /// Dropping a handed-out handle changes nothing about the path, so the follower hands out /// nothing until another instance takes it over. moq-net's follow tests prove on a simulated /// clock that nothing comes past the origin's update hold either. diff --git a/rs/moq-mux/Cargo.toml b/rs/moq-mux/Cargo.toml index f508b58553c..8c239c3c607 100644 --- a/rs/moq-mux/Cargo.toml +++ b/rs/moq-mux/Cargo.toml @@ -62,6 +62,8 @@ hang = { path = "../hang", features = ["async"] } kio = { workspace = true, features = ["sim"] } # Compile the async API used by the tests, benches, and examples. moq-mux = { path = ".", features = ["async"] } +# Mints an instance for a test, as a session of an older wire does. +moq-net = { path = "../moq-net", features = ["fuzz"] } proptest = { workspace = true } tokio = { workspace = true, features = ["test-util"] } tracing-test = { workspace = true } diff --git a/rs/moq-mux/src/follow.rs b/rs/moq-mux/src/follow.rs index e10d2c0b68d..67dee55acd5 100644 --- a/rs/moq-mux/src/follow.rs +++ b/rs/moq-mux/src/follow.rs @@ -184,8 +184,7 @@ mod tests { async fn a_named_instance_follows_a_minted_one() { let origin = produce_origin(); let _catalog = origin.route("a/pub", Default::default()).unwrap(); - let minted: moq_net::Instance = "~minted".parse().unwrap(); - assert!(minted.is_minted()); + let minted = moq_net::fuzz::minted_instance(); let _sibling = origin.route("a/source", route(&minted)).unwrap(); settle().await; let source = resolved(&origin, "a/pub").await; diff --git a/rs/moq-net/src/fuzz.rs b/rs/moq-net/src/fuzz.rs index ab69ff3392a..6c61cb1013f 100644 --- a/rs/moq-net/src/fuzz.rs +++ b/rs/moq-net/src/fuzz.rs @@ -532,6 +532,12 @@ pub fn excluding(origin: crate::origin::Consumer, peer: u64) -> crate::origin::C origin.excluding(crate::Hop::from_wire(peer)) } +/// A fresh instance as a session mints one for a route an older wire delivered, for tests +/// outside the crate that route one without an older session. +pub fn minted_instance() -> crate::Instance { + crate::Instance::minted() +} + /// A peer's wire route: its hop ids, cost, and instance at an epoch, as a session /// decodes one, for benches and tests that land or update forwarded routes. /// @@ -1574,9 +1580,11 @@ pub fn pattern(data: &[u8]) -> bool { /// Lite seeds whose envelope bytes the uniform sweep never forms, on the newest /// version: a typed empty TRACKS_SYNCED, an AUTH_OK reply, every route message /// (the sweep sets ROUTE_START's type byte but fills its size byte with the body -/// pattern, so none decodes whole), the ROUTE_END echo, and ROUTE_REQUEST. +/// pattern, so none decodes whole), the ROUTE_END echo, ROUTE_REQUEST, and a DATAGRAMS +/// request, whose seven fields outgrow the sweep's longest body. const TYPED_LITE_SEEDS: &[(u8, &[u8])] = &[ (23, &[4, 0]), + (30, &[7, 0, 0, 0, 0, 0, 0, 0]), (27, &[0, 3, 0, 0, 0]), (5, &[6, 4, 1, 0, 1, 0]), (5, &[7, 4, 0, 1, 2, 0]), diff --git a/rs/moq-net/src/instance.rs b/rs/moq-net/src/instance.rs index f61c7e8baa5..df4fcb1b096 100644 --- a/rs/moq-net/src/instance.rs +++ b/rs/moq-net/src/instance.rs @@ -7,11 +7,14 @@ use std::sync::Arc; /// Within an instance, group sequences never restart and never name two different groups, /// so a reader continues across every route that shares it. Different instances are /// independent streams, never stitched together. +/// +/// Two instances are equal only when both their text and whether they were +/// [minted](Instance::is_minted) match, so a named instance never matches a minted one. #[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct Instance(Arc); - -/// What every [minted](Instance::is_minted) instance starts with. -const MINTED: char = '~'; +pub struct Instance { + text: Arc, + minted: bool, +} /// The instance is empty. #[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] @@ -22,26 +25,42 @@ pub struct InvalidInstance; impl Instance { /// A fresh random instance, for a new stream. pub fn random() -> Self { - Self(random().into()) + Self::named(random().into()) + } + + fn named(text: Arc) -> Self { + Self { text, minted: false } } /// A fresh instance a session mints for a route its wire delivered without one. #[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] pub(crate) fn minted() -> Self { - Self(format!("{MINTED}{}", random()).into()) + Self { + text: random().into(), + minted: true, + } + } + + /// An instance a lite-07 peer carried, keeping whether a session minted it. + pub(crate) fn decoded(text: &str, minted: bool) -> Result { + Ok(Self { + minted, + ..text.parse()? + }) } - /// The instance's text. + /// The instance's text, which alone does not tell a minted instance from a named one. pub fn as_str(&self) -> &str { - &self.0 + &self.text } - /// Whether a relay minted this instance, for a route whose wire (an older moq-lite, or - /// moq-transport) carries none: its text starts with `~`. It names the announcement - /// rather than the publisher's stream, so it can neither match nor contradict an instance - /// the publisher named elsewhere, such as in a catalog. Hops that forward it keep the mark. + /// Whether a session minted this instance, for a route whose wire (an older moq-lite, or + /// moq-transport) carries none. It names the announcement rather than the publisher's + /// stream, so it can neither match nor contradict an instance the publisher named + /// elsewhere, such as in a catalog. Hops that forward it keep the flag, and only a + /// session sets it: parsing text never builds a minted instance. pub fn is_minted(&self) -> bool { - self.0.starts_with(MINTED) + self.minted } } @@ -57,7 +76,7 @@ impl FromStr for Instance { fn from_str(text: &str) -> Result { match text.is_empty() { true => Err(InvalidInstance), - false => Ok(Self(text.into())), + false => Ok(Self::named(text.into())), } } } @@ -68,14 +87,19 @@ impl TryFrom for Instance { fn try_from(text: String) -> Result { match text.is_empty() { true => Err(InvalidInstance), - false => Ok(Self(text.into())), + false => Ok(Self::named(text.into())), } } } impl fmt::Debug for Instance { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_tuple("Instance").field(&self.as_str()).finish() + let mut debug = f.debug_tuple("Instance"); + debug.field(&self.as_str()); + if self.minted { + debug.field(&"minted"); + } + debug.finish() } } @@ -102,12 +126,17 @@ mod tests { assert_eq!(instance.as_str().parse::().unwrap(), instance); assert!("".parse::().is_err()); assert!(!instance.is_minted()); + } + + #[test] + fn text_never_builds_a_minted_instance() { let minted = Instance::minted(); assert!(minted.is_minted()); - assert_eq!( - minted.as_str().parse::().unwrap(), - minted, - "a forwarded mark survives" - ); + let named: Instance = minted.as_str().parse().unwrap(); + assert!(!named.is_minted()); + assert_ne!(named, minted, "the same text, named, is another instance"); + assert!(!"~tilde".parse::().unwrap().is_minted()); + assert_eq!(Instance::decoded(minted.as_str(), true).unwrap(), minted); + assert!(Instance::decoded("", true).is_err()); } } diff --git a/rs/moq-net/src/lite/announce.rs b/rs/moq-net/src/lite/announce.rs index 8df68b3e5a8..d23df0e92e9 100644 --- a/rs/moq-net/src/lite/announce.rs +++ b/rs/moq-net/src/lite/announce.rs @@ -1097,8 +1097,8 @@ mod tests { } /// The draft's field order, byte for byte: ANNOUNCE_START is the path base, keep, and - /// rest, the Route ID, the instance and epoch, and the cost; ANNOUNCE_UPDATE is the - /// Announce ID and the cost. + /// rest, the Route ID, the instance, its minted flag, the epoch, and the cost; + /// ANNOUNCE_UPDATE is the Announce ID and the cost. #[test] fn lite07_announce_layout() { let encode = |msg: AnnounceBroadcast| { @@ -1121,7 +1121,7 @@ mod tests { hops: Hops::default(), cost: Cost::new(6), }), - [0, 9, 1, 2, 1, b'c', 3, 1, b'a', 5, 6] + [0, 10, 1, 2, 1, b'c', 3, 1, b'a', 0, 5, 6] ); assert_eq!( encode(AnnounceBroadcast::Update { @@ -1139,7 +1139,7 @@ mod tests { cost: Cost::new(6), hops: Hops::default(), }), - [3, 5, 7, 1, b'a', 5, 6] + [3, 6, 7, 1, b'a', 0, 5, 6] ); } @@ -1179,9 +1179,9 @@ mod tests { }, ]; let layouts: [&[u8]; 3] = [ - &[0, 12, 0, 0, 1, b'c', 3, 1, b'a', 5, 6, 2, 7, 9], + &[0, 13, 0, 0, 1, b'c', 3, 1, b'a', 0, 5, 6, 2, 7, 9], &[2, 5, 7, 6, 2, 7, 9], - &[3, 8, 7, 1, b'a', 5, 6, 2, 7, 9], + &[3, 9, 7, 1, b'a', 0, 5, 6, 2, 7, 9], ]; for (msg, layout) in msgs.iter().zip(layouts) { assert_eq!(encode(msg), layout, "{msg:?}"); diff --git a/rs/moq-net/src/lite/compress.rs b/rs/moq-net/src/lite/compress.rs index 3aeeeda3982..81d6faf2caa 100644 --- a/rs/moq-net/src/lite/compress.rs +++ b/rs/moq-net/src/lite/compress.rs @@ -410,10 +410,10 @@ mod tests { encoder.end(0); } - /// A compressed stream pinned byte for byte: `js/net/src/lite/announce.test.ts` - /// decodes the same hex, so a codec change on either side breaks both. + /// A compressed stream pinned byte for byte. `js/net/src/lite/announce.test.ts` decodes + /// this hex once js/net speaks the route layer (quest/m2/cluster-routing/routes/js.md). const GOLDEN: &str = - "001200000a726f6f6d2f612f63616d0001610000000b0102036d69630001610000020200000101010009020101620001610000"; + "001300000a726f6f6d2f612f63616d000161000000000c0102036d696300016100000002020000010101000a02010162000161000000"; #[cfg(feature = "async")] fn golden() -> Vec { @@ -437,9 +437,9 @@ mod tests { assert_eq!(crate::fuzz::decode_announces(&encoded, true), golden()); } - /// The literal lite-07 stream `js/net` writes for the same announcements, which - /// never picks a base. - const JS_LITERAL: &str = "001200000a726f6f6d2f612f63616d0001610000001200000a726f6f6d2f612f6d6963000161000002020000010101000e000006726f6f6d2f620001610000"; + /// The literal lite-07 stream `js/net` will write for the same announcements once it + /// speaks the route layer, never picking a base. + const JS_LITERAL: &str = "001300000a726f6f6d2f612f63616d000161000000001300000a726f6f6d2f612f6d696300016100000002020000010101000f000006726f6f6d2f62000161000000"; #[test] #[cfg(feature = "async")] diff --git a/rs/moq-net/src/lite/instance.rs b/rs/moq-net/src/lite/instance.rs index 336e2357c73..584fab5a763 100644 --- a/rs/moq-net/src/lite/instance.rs +++ b/rs/moq-net/src/lite/instance.rs @@ -5,8 +5,9 @@ use crate::{ use super::Version; -/// Encode the instance a request pins as its UTF-8 bytes, or empty for none. Older -/// versions carry nothing, so the peer resolves whichever instance wins the path. +/// Encode the instance a request pins as its UTF-8 bytes and minted flag, or empty and +/// unminted for none. Older versions carry nothing, so the peer resolves whichever instance +/// wins the path. pub(super) fn encode_pin( w: &mut Encoder<'_>, version: Version, @@ -15,17 +16,25 @@ pub(super) fn encode_pin( if !version.has_instance() { return Ok(()); } - w.bytes(instance.map_or(&[][..], |instance| instance.as_str().as_bytes())) + match instance { + Some(instance) => encode(w, instance), + None => { + w.bytes(&[])?; + w.bool(false); + Ok(()) + } + } } -/// Decode the instance a request pins: empty is none, and anything but UTF-8 is refused. +/// Decode the instance a request pins: empty is none, and anything but UTF-8, or an empty +/// one flagged minted, is refused. pub(super) fn decode_pin(r: &mut Decoder<'_>, version: Version) -> Result, DecodeError> { if !version.has_instance() { return Ok(None); } - match r.bytes()? { - [] => Ok(None), - bytes => decode(bytes).map(Some), + match (r.bytes()?, r.bool()?) { + ([], false) => Ok(None), + (bytes, minted) => decode(bytes, minted).map(Some), } } @@ -46,9 +55,9 @@ pub(super) fn decode_node(r: &mut Decoder<'_>, version: Version) -> Result, version: Version, @@ -58,8 +67,7 @@ pub(super) fn encode_route( if !version.has_instance() { return Ok(()); } - let instance = instance.ok_or(EncodeError::InvalidState)?; - w.bytes(instance.as_str().as_bytes())?; + encode(w, instance.ok_or(EncodeError::InvalidState)?)?; w.varint(epoch) } @@ -68,15 +76,19 @@ pub(super) fn decode_route(r: &mut Decoder<'_>, version: Version) -> Result<(Opt if !version.has_instance() { return Ok((None, 0)); } - let instance = decode(r.bytes()?)?; + let instance = decode(r.bytes()?, r.bool()?)?; Ok((Some(instance), r.varint()?)) } -fn decode(bytes: &[u8]) -> Result { - std::str::from_utf8(bytes) - .map_err(|_| DecodeError::InvalidValue)? - .parse() - .map_err(|_| DecodeError::InvalidValue) +fn encode(w: &mut Encoder<'_>, instance: &Instance) -> Result<(), EncodeError> { + w.bytes(instance.as_str().as_bytes())?; + w.bool(instance.is_minted()); + Ok(()) +} + +fn decode(bytes: &[u8], minted: bool) -> Result { + let text = std::str::from_utf8(bytes).map_err(|_| DecodeError::InvalidValue)?; + Instance::decoded(text, minted).map_err(|_| DecodeError::InvalidValue) } #[cfg(test)] @@ -99,6 +111,31 @@ mod test { assert_eq!(pin(Version::Lite07, None), None); } + /// The flag, not the text, says a session minted an instance: each round-trips as + /// itself, and a named instance that looks like the old `~` mark stays named. + #[test] + fn lite07_carries_the_minted_flag() { + let minted = Instance::minted(); + let decoded = pin(Version::Lite07, Some(&minted)).unwrap(); + assert!(decoded.is_minted()); + assert_eq!(decoded, minted); + let named: Instance = "~named".parse().unwrap(); + assert!(!pin(Version::Lite07, Some(&named)).unwrap().is_minted()); + } + + #[test] + fn refuses_a_bad_minted_flag() { + let decode = |text: &[u8], flag: u8| { + let mut buf = Vec::new(); + let w = &mut Encoder::new(&mut buf, Version::Lite07.into()); + w.bytes(text).unwrap(); + w.u8(flag); + decode_pin(&mut Decoder::new(&buf, Version::Lite07.into()), Version::Lite07) + }; + assert!(decode(b"a", 2).is_err()); + assert!(decode(b"", 1).is_err(), "no instance is never minted"); + } + #[test] fn older_versions_drop_it() { assert_eq!(pin(Version::Lite06, Some(&Instance::random())), None); @@ -135,6 +172,7 @@ mod test { let mut buf = Vec::new(); let w = &mut Encoder::new(&mut buf, Version::Lite07.into()); w.bytes(&[]).unwrap(); + w.bool(false); w.varint(0).unwrap(); assert!(decode_route(&mut Decoder::new(&buf, Version::Lite07.into()), Version::Lite07).is_err()); } @@ -142,7 +180,9 @@ mod test { #[test] fn refuses_non_utf8() { let mut buf = Vec::new(); - Encoder::new(&mut buf, Version::Lite07.into()).bytes(&[0xff]).unwrap(); + let w = &mut Encoder::new(&mut buf, Version::Lite07.into()); + w.bytes(&[0xff]).unwrap(); + w.bool(false); assert!(decode_pin(&mut Decoder::new(&buf, Version::Lite07.into()), Version::Lite07).is_err()); } } diff --git a/rs/moq-net/src/lite/track.rs b/rs/moq-net/src/lite/track.rs index acd00776a87..b5bb453254c 100644 --- a/rs/moq-net/src/lite/track.rs +++ b/rs/moq-net/src/lite/track.rs @@ -512,8 +512,8 @@ mod test { track: Cow::Borrowed("v"), }; let buf = msg.encode_bytes(Version::Lite07).unwrap(); - // Length, the path, an empty instance, the node, then the track name. - assert_eq!(&buf[..], [9, 4, b'r', b'o', b'o', b'm', 0, 5, 1, b'v']); + // Length, the path, an empty unminted instance, the node, then the track name. + assert_eq!(&buf[..], [10, 4, b'r', b'o', b'o', b'm', 0, 0, 5, 1, b'v']); assert_eq!(Track::decode_slice(&buf, Version::Lite07).unwrap().0.node, 5); let buf = msg.encode_bytes(Version::Lite06).unwrap(); diff --git a/rs/moq-net/src/model/origin.rs b/rs/moq-net/src/model/origin.rs index 29be2af46b6..b8d6e3e402d 100644 --- a/rs/moq-net/src/model/origin.rs +++ b/rs/moq-net/src/model/origin.rs @@ -685,10 +685,6 @@ impl RouteOptions { /// group sequences under an old instance splices unrelated groups into their tracks. /// The highest epoch wins over every older one of the instance, cheaper or not. /// Naming an instance grants nothing: publishing still needs the path's grant. - /// - /// Do not start a named instance with `~`: that marks one a relay minted - /// ([`Instance::is_minted`]), which moq-crypto's signing refuses to bind and a catalog's instance - /// check skips. pub fn with_instance(mut self, instance: Instance, epoch: u64) -> Self { self.instance = Some((instance, epoch)); self diff --git a/rs/moq-net/tests/integration/instance.rs b/rs/moq-net/tests/integration/instance.rs index f8fd526a1d4..854b2a25323 100644 --- a/rs/moq-net/tests/integration/instance.rs +++ b/rs/moq-net/tests/integration/instance.rs @@ -60,7 +60,8 @@ async fn job_reannounced(version: &str) { let version: Version = version.parse().unwrap(); let publisher = produce_origin(1); let relay = produce_origin(2); - let instance: Instance = "job".parse().unwrap(); + // Text like the mark minted instances once carried, which names an instance like any other. + let instance: Instance = "~job".parse().unwrap(); let job = publisher .route( "job", @@ -107,6 +108,21 @@ async fn moq_transport_mints_per_announcement() { } } +/// A session mints an instance for a route an older wire delivered, and lite-07 forwards it +/// still minted, so every hop past it names the same stream. +#[kio::test] +async fn lite07_forwards_a_minted_instance() { + let publisher = produce_origin(1); + let relay = produce_origin(2); + let viewer = produce_origin(3); + let _route = publisher.route("job", Default::default()).unwrap(); + let _older = link("moq-lite-06".parse().unwrap(), &publisher, &relay).await; + let _newer = link("moq-lite-07-wip".parse().unwrap(), &relay, &viewer).await; + let minted = started(&mut relay.consume().routes()).await; + assert!(minted.is_minted()); + assert_eq!(started(&mut viewer.consume().routes()).await, minted); +} + /// One publisher reaches a relay through two neighbors, and the one the relay prefers /// goes away. lite-07 carries the publisher's instance through both, so the relay keeps /// serving the same stream through the other. Every other wire mints one per announcement, so changing diff --git a/swift/Sources/Moq/Aliases.swift b/swift/Sources/Moq/Aliases.swift index cb2e826bcc7..2bd4145e184 100644 --- a/swift/Sources/Moq/Aliases.swift +++ b/swift/Sources/Moq/Aliases.swift @@ -51,7 +51,10 @@ public typealias VideoCodec = MoqFFI.MoqVideoCodec public typealias VideoEncoderKind = MoqFFI.MoqVideoEncoderKind /// A datagram payload with its own sequence and optional timestamp. public typealias Datagram = MoqFFI.MoqDatagram -/// The read-only facts on a received route: the `instance` of the stream it serves, +/// Names one continuous stream at a path: `Instance.parse(text:)` names one, only a session mints +/// one, and `==` also compares `isMinted()`, so pin a reported stream with the instance, not its text. +public typealias Instance = MoqFFI.MoqInstance +/// The read-only facts on a received route: the `Instance` of the stream it serves, /// relay hop ids (oldest first), and its static production and link cost, lower wins. public typealias RouteInfo = MoqFFI.MoqRouteInfo /// A route over a prefix: the origin-relative `prefix`, what each filter diff --git a/swift/Sources/Moq/Origin.swift b/swift/Sources/Moq/Origin.swift index 091804c8885..57bddc98626 100644 --- a/swift/Sources/Moq/Origin.swift +++ b/swift/Sources/Moq/Origin.swift @@ -29,7 +29,7 @@ public final class OriginProducer: Sendable { /// it, lower wins. Hold the route: releasing or cancelling it retracts it, and /// `BroadcastProducer.close()` ends both. public func route( - path: String, hidden: Bool = false, cost: UInt64 = 0, instance: String? = nil, epoch: UInt64 = 0 + path: String, hidden: Bool = false, cost: UInt64 = 0, instance: Instance? = nil, epoch: UInt64 = 0 ) throws -> Route { Route( try ffi.route( @@ -44,7 +44,7 @@ public final class OriginProducer: Sendable { /// requested. Iterate the claim, or call `requested()`, to answer each request; hold /// it while it should stay advertised. public func claim( - prefix: String, hidden: Bool = false, cost: UInt64 = 0, instance: String? = nil, epoch: UInt64 = 0 + prefix: String, hidden: Bool = false, cost: UInt64 = 0, instance: Instance? = nil, epoch: UInt64 = 0 ) throws -> Claim { Claim( try ffi.claim( @@ -76,7 +76,7 @@ public final class BroadcastRequest: Sendable { /// Created visible, it announces now and answers the waiting requesters; created `hidden`, they wait until /// `Route.announce()`, so the job can fill its catalog first. public func route( - hidden: Bool = false, cost: UInt64 = 0, instance: String? = nil, epoch: UInt64 = 0 + hidden: Bool = false, cost: UInt64 = 0, instance: Instance? = nil, epoch: UInt64 = 0 ) throws -> Route { Route( try ffi.route(options: MoqRouteOptions(hidden: hidden, cost: cost, instance: instance, epoch: epoch))) @@ -108,11 +108,12 @@ public final class Route: Sendable { broadcast = BroadcastProducer(ffi.broadcast()) } - /// The stream this route serves now. A named instance never changes. A job's route takes - /// its claim's current one when it first announces or when this first reads it. Read - /// first, the job announces under it, and `announce()` throws `Unroutable` once the claim - /// has moved past it, so a signer built from it stays valid. - public var instance: String { ffi.instanceId() } + /// The stream this route serves now, or once cancelled, the last one it reported or + /// announced under (`nil` for a hidden route that did neither). A named instance never + /// changes. A job's route takes its claim's current one when it first announces or when + /// this first reads it. Read first, the job announces under it, and `announce()` throws + /// `Unroutable` once the claim has moved past it, so a signer built from it stays valid. + public var instance: Instance? { ffi.instanceId() } /// Make the route visible, or keep it so; throws `Closed` once its broadcast closed. public func announce() throws { @@ -153,7 +154,7 @@ public final class Claim: AsyncSequence, Sendable { /// The stream this claim serves now. A named instance never changes. Unnamed, the claim /// moves to a fresh one each time a job holding its current one ends. - public var instance: String { ffi.instanceId() } + public var instance: Instance { ffi.instanceId() } /// Make the claim visible, or keep it so. public func announce() throws { @@ -209,7 +210,7 @@ public final class OriginConsumer: Sendable { /// Follow an exact path, resolving each stream that serves it. /// Pass an `instance` to follow only that stream. - public func follow(path: String, instance: String? = nil) throws -> Following { + public func follow(path: String, instance: Instance? = nil) throws -> Following { Following(try ffi.follow(path: path, instance: instance)) } @@ -218,7 +219,7 @@ public final class OriginConsumer: Sendable { /// origin, a route a session announced (served on demand by that session), or a dynamic /// handler on the origin, or an error if nothing can serve it. Unlike `follow`, this /// does not wait for a future announcement. Pass an `instance` to refuse any other stream. - public func request(path: String, instance: String? = nil) async throws -> BroadcastConsumer { + public func request(path: String, instance: Instance? = nil) async throws -> BroadcastConsumer { BroadcastConsumer(try await ffi.request(path: path, instance: instance)) } } diff --git a/swift/Tests/MoqTests/SmokeTests.swift b/swift/Tests/MoqTests/SmokeTests.swift index cb038541aa1..4c5f936527e 100644 --- a/swift/Tests/MoqTests/SmokeTests.swift +++ b/swift/Tests/MoqTests/SmokeTests.swift @@ -220,6 +220,61 @@ final class SmokeTests: XCTestCase { session.cancel(code: 0) } + /// Over moq-lite-06, whose wire carries no instance, the session mints one: passing it back + /// pins `request` and `follow`, while its text parsed is another, named instance that nothing + /// serves. + func testAnnouncedInstancePinsRequestAndFollow() async throws { + let version = ["moq-lite-06"] + let published = OriginProducer() + let route = try published.route(path: "live") + defer { route.cancel() } + let server = try Server( + bind: "127.0.0.1:0", versions: version, tls: ServerTls(generate: ["localhost"]), publish: published) + defer { server.cancel() } + let addr = try await server.listen() + async let accepted = server.accept() + + let viewer = OriginProducer() + let client = try Client( + bind: "127.0.0.1:0", versions: version, tls: ClientTls(insecure: true), once: true, consume: viewer) + defer { client.cancel() } + let session = try await client.connect(to: "https://\(addr)") + guard let request = try await accepted else { return XCTFail("server closed before accept") } + let peer = try await request.accept() + defer { + peer.cancel(code: 0) + session.cancel(code: 0) + } + + let consumer = viewer.consume() + guard case .present(let present) = try await nextRoute(try consumer.announced()) else { + return XCTFail("expected an announcement") + } + let minted = present.route.instance + XCTAssertTrue(minted.isMinted()) + + _ = try await consumer.request(path: "live", instance: minted) + let following = try consumer.follow(path: "live", instance: minted) + // Cancelling ends the wait below, so an instance that never resolves fails rather than hangs. + let watchdog = Task { + try await Task.sleep(nanoseconds: 10_000_000_000) + following.cancel() + } + defer { watchdog.cancel() } + let followed = try await following.available() + XCTAssertNotNil(followed) + following.cancel() + + let named = try Instance.parse(text: minted.text()) + XCTAssertFalse(named.isMinted()) + XCTAssertEqual(named.description, minted.text()) + XCTAssertNotEqual(named, minted) + do { + _ = try await consumer.request(path: "live", instance: named) + XCTFail("the same text, named, is another instance") + } catch {} + } + /// `cancel()` releases the listening socket before it returns, so the same /// address binds again with no retry. func testServerCloseReleasesPort() async throws { @@ -350,12 +405,13 @@ final class SmokeTests: XCTestCase { guard case .present(let present) = try await nextRoute(announced) else { return XCTFail("expected an announcement") } - XCTAssertFalse(present.route.instance.isEmpty) + XCTAssertFalse(present.route.instance.isMinted()) XCTAssertEqual(present.route.instance, first.instance) let firstBroadcast = try await following.available() XCTAssertNotNil(firstBroadcast) - let second = try origin.route(path: "instance", instance: "camera", epoch: 1) + let camera = try Instance.parse(text: "camera") + let second = try origin.route(path: "instance", instance: camera, epoch: 1) let takeover = try await following.available() XCTAssertNotNil(takeover) @@ -365,9 +421,10 @@ final class SmokeTests: XCTestCase { guard case .absent(let absent) = try await nextRoute(announced) else { return XCTFail("expected the path to go absent") } - XCTAssertEqual(absent.route.instance, "camera") + XCTAssertEqual(absent.route.instance, camera) + XCTAssertEqual(absent.route.instance.description, "camera") - XCTAssertThrowsError(try origin.route(path: "refused", instance: "")) { error in + XCTAssertThrowsError(try Instance.parse(text: "")) { error in guard case MoqError.InvalidInstance = error else { return XCTFail("expected InvalidInstance, got \(error)") } diff --git a/test/interop/clients/c/subscribe.c b/test/interop/clients/c/subscribe.c index 0a814b420cf..62de9d754bb 100644 --- a/test/interop/clients/c/subscribe.c +++ b/test/interop/clients/c/subscribe.c @@ -496,7 +496,7 @@ static void finish(ctx_t *c, const struct timespec *deadline) { char *path = malloc(length); if (!path) fail("error: cannot allocate health path\n"); snprintf(path, length, "%s.health", c->finish.path); - c->finish.health_wait = moq_origin_follow((uint32_t)c->origin, path, strlen(path), NULL, 0, on_finish_health, c); + c->finish.health_wait = moq_origin_follow((uint32_t)c->origin, path, strlen(path), 0, on_finish_health, c); free(path); if (c->finish.health_wait <= 0) fail("error: health registration failed\n"); @@ -672,7 +672,7 @@ int main(int argc, char **argv) { // announced. moq_origin_follow resolves via on_broadcast once it's // available; we block on the condvar until then (or the deadline). c.broadcast_wait = - moq_origin_follow((uint32_t)c.origin, broadcast, strlen(broadcast), NULL, 0, on_broadcast, &c); + moq_origin_follow((uint32_t)c.origin, broadcast, strlen(broadcast), 0, on_broadcast, &c); if (c.broadcast_wait <= 0) { fail("error: moq_origin_follow failed: %d\n", c.broadcast_wait); } diff --git a/test/interop/clients/cpp/main.cpp b/test/interop/clients/cpp/main.cpp index 7ae06bce990..8284a8f7986 100644 --- a/test/interop/clients/cpp/main.cpp +++ b/test/interop/clients/cpp/main.cpp @@ -351,7 +351,7 @@ int subscribe( const auto read_deadline = finish ? Clock::now() + std::chrono::duration_cast(std::chrono::duration(timeout)) : deadline; - auto announced = ok(session->consume()->follow(path, std::nullopt), "follow"); + auto announced = ok(session->consume()->follow(path, nullptr), "follow"); auto consumer = until(announced->available(), read_deadline, "available"); if (!consumer) fail("first available() reported offline"); @@ -408,7 +408,7 @@ int subscribe( const auto *end = std::get_if(&event->get_variant()); if (end && end->announce.prefix == path) break; } - auto watch = ok(session->consume()->follow(path + ".health", std::nullopt), "health watch"); + auto watch = ok(session->consume()->follow(path + ".health", nullptr), "health watch"); auto health = until(watch->available(), read_deadline, "health available"); if (!health) fail("first health available() reported offline"); auto track = until(health->subscribe_track("live", std::nullopt), read_deadline, "health subscribe"); diff --git a/test/interop/clients/js/src/probe.ts b/test/interop/clients/js/src/probe.ts index b2f7c6f92fc..119f55d38d8 100644 --- a/test/interop/clients/js/src/probe.ts +++ b/test/interop/clients/js/src/probe.ts @@ -207,7 +207,8 @@ export function attach(el: MoqWatch, tones: number[] = []): () => void { const state: Sample = { seq: seq++, broadcastActive: el.broadcast.out.active.peek() !== undefined, - broadcastInstance: el.broadcast.out.active.peek()?.instance, + // Its text: a minted instance is an object, which JSON would print as {}. + broadcastInstance: instanceText(el.broadcast.out.active.peek()?.instance), broadcastStatus: el.broadcast.out.status.peek(), userActivated: navigator.userActivation.hasBeenActive, at: performance.now(), @@ -247,3 +248,8 @@ export function attach(el: MoqWatch, tones: number[] = []): () => void { const timer = self.setInterval(sample, SAMPLE_MS); return () => self.clearInterval(timer); } + +// The instance's text: both kinds stringify to it. +function instanceText(instance: { toString(): string } | undefined): string | undefined { + return instance === undefined ? undefined : String(instance); +}