diff --git a/Cargo.lock b/Cargo.lock index 29a0bdce73..4d2368190d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4378,6 +4378,7 @@ version = "0.4.3" dependencies = [ "anyhow", "bytes", + "futures", "gst-plugin-version-helper", "gstreamer", "hang", diff --git a/bun.lock b/bun.lock index b53786bfe9..49a1ec1627 100644 --- a/bun.lock +++ b/bun.lock @@ -99,6 +99,7 @@ "dependencies": { "@moq/flate": "workspace:^", "@moq/net": "workspace:^", + "@moq/signals": "workspace:^", }, "devDependencies": { "@types/bun": "^1.4.2", @@ -655,6 +656,10 @@ "@moq/hang": ["@moq/hang@workspace:js/hang"], + "@moq/interop-browser": ["@moq/interop-browser@workspace:test/interop/clients/js"], + + "@moq/interop-native": ["@moq/interop-native@workspace:test/interop/clients/js-native"], + "@moq/json": ["@moq/json@workspace:js/json"], "@moq/loc": ["@moq/loc@workspace:js/loc"], @@ -673,10 +678,6 @@ "@moq/signals": ["@moq/signals@workspace:js/signals"], - "@moq/interop-browser": ["@moq/interop-browser@workspace:test/interop/clients/js"], - - "@moq/interop-native": ["@moq/interop-native@workspace:test/interop/clients/js-native"], - "@moq/wasm": ["@moq/wasm@workspace:js/wasm"], "@moq/wasm-test": ["@moq/wasm-test@workspace:test/wasm"], diff --git a/dart/moq_ffi/lib/src/moq.dart b/dart/moq_ffi/lib/src/moq.dart index 57a2ac3606..d4635f31c0 100644 --- a/dart/moq_ffi/lib/src/moq.dart +++ b/dart/moq_ffi/lib/src/moq.dart @@ -500,7 +500,13 @@ class MoqAudioInit { final MoqAudioFormat format; final Uint8List data; final String? label; - MoqAudioInit({required this.format, required this.data, this.label = null}); + final String? track; + MoqAudioInit({ + required this.format, + required this.data, + this.label = null, + this.track = null, + }); } class FfiConverterMoqAudioInit { @@ -525,8 +531,13 @@ class FfiConverterMoqAudioInit { ); final label = label_lifted.value; new_offset += label_lifted.bytesRead; + final track_lifted = FfiConverterOptionalString.read( + Uint8List.view(buf.buffer, new_offset), + ); + final track = track_lifted.value; + new_offset += track_lifted.bytesRead; return LiftRetVal( - MoqAudioInit(format: format, data: data, label: label), + MoqAudioInit(format: format, data: data, label: label, track: track), new_offset - buf.offsetInBytes, ); } @@ -536,6 +547,7 @@ class FfiConverterMoqAudioInit { FfiConverterMoqAudioFormat.allocationSize(value.format) + FfiConverterUint8List.allocationSize(value.data) + FfiConverterOptionalString.allocationSize(value.label) + + FfiConverterOptionalString.allocationSize(value.track) + 0; final buf = Uint8List(total_length); write(value, buf); @@ -556,6 +568,10 @@ class FfiConverterMoqAudioInit { value.label, Uint8List.view(buf.buffer, new_offset), ); + new_offset += FfiConverterOptionalString.write( + value.track, + Uint8List.view(buf.buffer, new_offset), + ); return new_offset - buf.offsetInBytes; } @@ -563,6 +579,7 @@ class FfiConverterMoqAudioInit { return FfiConverterMoqAudioFormat.allocationSize(value.format) + FfiConverterUint8List.allocationSize(value.data) + FfiConverterOptionalString.allocationSize(value.label) + + FfiConverterOptionalString.allocationSize(value.track) + 0; } } @@ -1308,11 +1325,13 @@ class MoqVideoInit { final Uint8List data; final String? label; final MoqVideoHint? hint; + final String? track; MoqVideoInit({ required this.format, required this.data, this.label = null, this.hint = null, + this.track = null, }); } @@ -1343,8 +1362,19 @@ class FfiConverterMoqVideoInit { ); final hint = hint_lifted.value; new_offset += hint_lifted.bytesRead; + final track_lifted = FfiConverterOptionalString.read( + Uint8List.view(buf.buffer, new_offset), + ); + final track = track_lifted.value; + new_offset += track_lifted.bytesRead; return LiftRetVal( - MoqVideoInit(format: format, data: data, label: label, hint: hint), + MoqVideoInit( + format: format, + data: data, + label: label, + hint: hint, + track: track, + ), new_offset - buf.offsetInBytes, ); } @@ -1355,6 +1385,7 @@ class FfiConverterMoqVideoInit { FfiConverterUint8List.allocationSize(value.data) + FfiConverterOptionalString.allocationSize(value.label) + FfiConverterOptionalMoqVideoHint.allocationSize(value.hint) + + FfiConverterOptionalString.allocationSize(value.track) + 0; final buf = Uint8List(total_length); write(value, buf); @@ -1379,6 +1410,10 @@ class FfiConverterMoqVideoInit { value.hint, Uint8List.view(buf.buffer, new_offset), ); + new_offset += FfiConverterOptionalString.write( + value.track, + Uint8List.view(buf.buffer, new_offset), + ); return new_offset - buf.offsetInBytes; } @@ -1387,6 +1422,7 @@ class FfiConverterMoqVideoInit { FfiConverterUint8List.allocationSize(value.data) + FfiConverterOptionalString.allocationSize(value.label) + FfiConverterOptionalMoqVideoHint.allocationSize(value.hint) + + FfiConverterOptionalString.allocationSize(value.track) + 0; } } @@ -12373,7 +12409,7 @@ void _checkApiChecksums() { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqbroadcastproducer_publish_audio() != - 47444) { + 31691) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqbroadcastproducer_publish_audio_on_track() != diff --git a/doc/concept/hang.md b/doc/concept/hang.md index a2907e3e0a..22699656f8 100644 --- a/doc/concept/hang.md +++ b/doc/concept/hang.md @@ -112,7 +112,8 @@ In Rust the catalog owns the lifetime: `catalog.json_stream(track, config)` (or `json_snapshot` / `binary_snapshot` / `binary_stream`) writes the entry and retracts it when the producer drops. Read the config from `catalog.json.tracks` or `catalog.binary.tracks`, then pair its name and config with -`moq_mux::catalog::Entry::new` to subscribe. In the browser, read the same map, +`moq_mux::catalog::Entry::new` to subscribe. In C, `moq_publish_json_*` and +`moq_publish_binary_*` do the same, retracting on `_finish`. In the browser, read the same map, subscribe by name, and hand the track to `@moq/json` or `@moq/binary`. ## Container diff --git a/doc/concept/moq-lite.md b/doc/concept/moq-lite.md index 31a50e2992..2d32c0a0ce 100644 --- a/doc/concept/moq-lite.md +++ b/doc/concept/moq-lite.md @@ -47,8 +47,8 @@ A client that publishes a broadcast outside its grant closes the session with `UNAUTHORIZED` and names the path in the close reason, rather than waiting forever for a subscriber the relay will never let through. A grant is advice for the side that holds it; the side that issued it still enforces its own -scope. Older versions and moq-transport have no grant; the token in the URL -keeps working everywhere. +scope. Older versions, and moq-transport peers that do not negotiate the MoQ Auth +extension, have no grant; the token in the URL keeps working everywhere. ## Discovery diff --git a/doc/concept/standard.md b/doc/concept/standard.md index f2c7e9929e..36a91d3be0 100644 --- a/doc/concept/standard.md +++ b/doc/concept/standard.md @@ -44,8 +44,9 @@ tracks waiting for a group that never arrives. Several project drafts extend the IETF wire without breaking it, since `SETUP` ignores unknown parameters: [cluster](/draft/moq-cluster) routing hop lists, [solicit](/draft/moq-solicit) to make announcements opt-in, -[hidden](/draft/moq-hidden) to keep `.`-named namespaces out of discovery, and -[probe](/draft/moq-probe) for bandwidth estimation. +[hidden](/draft/moq-hidden) to keep `.`-named namespaces out of discovery, +[auth](/draft/moq-auth) to tell each peer what it may publish and subscribe to, +and [probe](/draft/moq-probe) for bandwidth estimation. [moq-e2ee](/draft/moq-e2ee) is not a transport extension: it encrypts application payloads so relays still forward named tracks they cannot read. diff --git a/doc/lib/c/index.md b/doc/lib/c/index.md index 15de234894..b77ae8a4bd 100644 --- a/doc/lib/c/index.md +++ b/doc/lib/c/index.md @@ -42,7 +42,7 @@ and `target/include/moq.h`. - **Server.** `moq_server_listen` binds before it returns (a bad address or certificate fails there) and hands each incoming session to `on_request` as a request handle. Read `moq_session_request_path` and `_query` to route and authenticate, then `moq_session_request_accept` (a session handle, with origins like `moq_session_connect`) or `moq_session_request_reject` with an HTTP-style code (401 and 403 become the protocol's unauthorized close). An accepted session reports `1` once SETUP completes and never reconnects. `moq_server_addr` reports an ephemeral port and `moq_server_fingerprints` the hashes a client pins for a `tls_generate` certificate. `moq_server_close` stops listening; its terminal callback fires once the sockets are released. - **Demand.** A watcher on a published track (`moq_publish_track_demand`, `moq_publish_media_demand`, `moq_encode_video_demand`, `moq_encode_audio_demand`) calls `on_demand` with `MOQ_DEMAND_USED` or `MOQ_DEMAND_UNUSED` right away and again on every change, so an encoder on a battery-powered device runs only while someone is watching. The first call is the current state, so a track that went unused before the watcher existed still reports it. `moq_publish_demand_cancel` stops it; the terminal callback still fires. A container has no single demand and is refused. Demand follows the last real subscriber: an origin that served the track drops its source copy on the unused edge and keeps only the finished groups it already cached warm for 30 seconds, so the cache linger does not delay the unused edge. - **Requests.** `moq_publish_dynamic` serves subscriptions to tracks the broadcast never declared: each arrives as a request handle, read its name with `moq_track_request_name`, then `moq_track_request_accept` (a raw track handle), `moq_track_request_video` / `_audio` (the media handle `moq_publish_video` / `_audio` return), or `moq_track_request_abort` with an application code the subscriber sees. Without a live handler an unknown name is refused. `moq_publish_track_dynamic` does the same for fetches of groups a track no longer has cached, delivered as `moq_group_request_*` (`sequence`, `priority`, `frame_start`); `moq_group_request_accept` starts the producer at `frame_start` so written frames keep their group indices. Register it with `moq_track_request_dynamic` before accepting a track that was itself requested by a fetch, so that pending group survives the transition. Both handlers stop with `moq_publish_dynamic_cancel`. -- **Everything the bindings can do** ([list](/lib/#what-every-binding-can-do)): media publish and consume with the catalog managed for you, raw pixels and PCM with the codec inside (`moq_encode_video`, `moq_encode_audio`, and the `moq_decode_*` mirrors), raw tracks with timestamps and datagrams, JSON snapshot and stream tracks, group fetch, catalog sections, shared video properties, and stalled hints. The three advertising operations are `moq_origin_create_broadcast` (unannounced producer, invisible to everyone), `moq_publish_announce` / `moq_publish_unannounce` (exact-path advertisement), and `moq_origin_dynamic` (a claim over a path prefix and everything beneath it; `""` for everything). A route is a capability, not an inventory. `moq_origin_announced` takes a literal prefix and an optional relative pattern filter; `moq_announce_update.prefix` stays relative to the origin, while `captures` reports what each wildcard matched when `has_captures` is true. Paths with a `.`-prefixed segment below the prefix are [hidden](/concept/moq-lite#hidden-broadcasts); name the dot segment in `prefix` to list them. +- **Everything the bindings can do** ([list](/lib/#what-every-binding-can-do)): media publish and consume with the catalog managed for you, raw pixels and PCM with the codec inside (`moq_encode_video`, `moq_encode_audio`, and the `moq_decode_*` mirrors), raw tracks with timestamps and datagrams, JSON and binary data tracks (snapshot or stream, each advertised in the catalog for as long as it lives), group fetch, catalog sections, shared video properties, and stalled hints. The three advertising operations are `moq_origin_create_broadcast` (unannounced producer, invisible to everyone), `moq_publish_announce` / `moq_publish_unannounce` (exact-path advertisement), and `moq_origin_dynamic` (a claim over a path prefix and everything beneath it; `""` for everything). A route is a capability, not an inventory. `moq_origin_announced` takes a literal prefix and an optional relative pattern filter; `moq_announce_update.prefix` stays relative to the origin, while `captures` reports what each wildcard matched when `has_captures` is true. Paths with a `.`-prefixed segment below the prefix are [hidden](/concept/moq-lite#hidden-broadcasts); name the dot segment in `prefix` to list them. ```c moq_client_config config; diff --git a/doc/lib/js/net.md b/doc/lib/js/net.md index 7b4a381565..9275a33810 100644 --- a/doc/lib/js/net.md +++ b/doc/lib/js/net.md @@ -53,7 +53,7 @@ for (;;) { - **Discovery** by any pattern scope (`origin.announced(scope)`, such as `room/*/chat`; default everything). Each event's `prefix` is the covered prefix relative to the origin, `captures` reports what the scope's wildcards matched when the prefix pins them, and `kind` says whether it was announced, updated, or retracted. The consumer is an async iterable. `origin.broadcasts(scope)` is a live `Getter>` of the same covered prefixes for UIs that need the current set. A borrowed `Connection.origin` also exposes `dynamic(prefix, route)` for serving paths on demand. - **Subscriptions** carry a priority, a `Time.Milli` max age, and optional `groups` bounds. Groups arrive out of order and are read frame by frame, with `Error.TooFarBehind` when a reader asks for a frame the group never held and `Error.GroupTooLarge` when a write exceeds the cache budget and aborts the group. - **Datagrams** on moq-lite 05+ and fetch-by-sequence for history. -- **Authorization** on moq-lite 06: an established session's `auth.grant` is a `Getter` with what the relay lets this side publish and subscribe to, learned right after setup. `auth.add(token)` presents another token without reconnecting and resolves with an `Auth.Token` to `close()` later; it rejects with `Auth.Unsupported` when the peer takes no tokens in band. A connection that publishes a broadcast outside its grant closes with `SessionCode.Unauthorized`, naming the path in the reason. +- **Authorization** on moq-lite 06, and on moq-transport draft-17+ when both sides negotiate the [MoQ Auth extension](/draft/moq-auth): an established session's `auth.grant` is a `Getter` with what the relay lets this side publish and subscribe to, learned right after setup. `auth.add(token)` presents another token without reconnecting and resolves with an `Auth.Token` to `close()` later; it rejects with `Auth.Unsupported` when the peer takes no tokens in band. A connection that publishes a broadcast outside its grant closes with `SessionCode.Unauthorized`, naming the path in the reason. - **Errors** live under one namespace: a stream reset throws `Error.Stream` with a `StreamCode`, while a session close gives `Error.Session` with a `SessionCode`. The registries are disjoint, so the same number means different things in each, and 64+ is yours. Named conditions such as `Error.TooFarBehind`, `Error.FrameTooLarge`, and `Error.GroupTooLarge` subclass `Error.Stream`, so one `code` check handles a condition raised here or reported by the peer. IETF streams use their own mapping: cancellation sends CANCELLED, other local failures send INTERNAL\_ERROR, and received codes remain opaque. - **Paths** with `Path.relative` for the cross-broadcast catalog references hang uses. Path patterns (`Path.Pattern`, `Path.Patterns`) are re-exported from [`@moq/pattern`](https://www.npmjs.com/package/@moq/pattern). Literal `Path` stays a coordinate. diff --git a/doc/lib/js/publish.md b/doc/lib/js/publish.md index 4da8ae931a..6c9c1dd0c5 100644 --- a/doc/lib/js/publish.md +++ b/doc/lib/js/publish.md @@ -59,6 +59,16 @@ clock when they flush frames. Catalog jitter is the spread above each rendition's own recent minimum lateness, so a constant encoder delay is not jitter. The advertised value only rises; frame duration alone does not set it. +## Clock + +Every timestamp the publisher writes is `performance.now()` in microseconds, +so camera, microphone, screen, and file sources share one timeline. The catalog +advertises that mapping as its root `clock` from the first snapshot, with PTS +zero at `performance.timeOrigin`, so a viewer or an HLS export can name any +frame's wall time. The mapping is fixed for the page: a system-clock +adjustment never retimes the broadcast. Stamp your own tracks (e.g. text cues) +on the same timeline to stay in sync. + ## Custom tracks `broadcast.net` is the underlying `Moq.Broadcast.Producer`, so an application diff --git a/doc/lib/js/signals.md b/doc/lib/js/signals.md index 515db35a9a..54b59838f6 100644 --- a/doc/lib/js/signals.md +++ b/doc/lib/js/signals.md @@ -38,7 +38,8 @@ The rules that differ from other signal libraries: - **Nothing is tracked implicitly.** `effect.get(signal)` subscribes; `signal.peek()` doesn't. - **Writes coalesce per microtask** and only notify on a real change (deep for plain objects, identity for class instances). -- **Effects own their resources.** `effect.timer`, `interval`, `animate`, `event`, `spawn`, and `run` (a nested effect) all clean up on rerun or close, so never call `setTimeout` or `addEventListener` inside one directly. A rerun waits for the previous run's `spawn` tasks to settle, and `effect.abort`/`effect.cancel` tell them to stop. +- **Effects own their resources.** `effect.timer`, `interval`, `animate`, `event`, `spawn`, and `run` (a nested effect) all clean up on rerun or close, so never call `setTimeout` or `addEventListener` inside one directly. A rerun waits for the previous run's `spawn` tasks to settle, and `effect.abort`/`effect.race` tell them to stop. +- **Race with `race`, not `Promise.race`.** `Promise.race` leaves a listener on every value that loses, so racing a long-lived one (a `closed`, a run's teardown) once per frame grows the heap. `race([...])` accepts promises and `Once` values and drops its listeners when it settles; `effect.race(promise)` also resolves `undefined` once the run is torn down. - **Dev builds warn** about effects that tracked nothing, effects garbage-collected without `close()`, and signals leaking subscribers. Components follow one shape: `in` (wired inputs), `out` (read-only derived diff --git a/doc/lib/js/watch.md b/doc/lib/js/watch.md index 6a5a79fee3..5ee0252dfa 100644 --- a/doc/lib/js/watch.md +++ b/doc/lib/js/watch.md @@ -106,7 +106,7 @@ const dispose = el.signals.run((effect) => { const consumer = new Json.Snapshot.Consumer({ track }); effect.spawn(async () => { for (;;) { - const value = await Promise.race([effect.cancel, consumer.next()]); + const value = await effect.race(consumer.next()); if (value === undefined) break; console.log("metadata", value); } diff --git a/doc/lib/rs/moq-net.md b/doc/lib/rs/moq-net.md index 1185562d59..36bd679526 100644 --- a/doc/lib/rs/moq-net.md +++ b/doc/lib/rs/moq-net.md @@ -72,10 +72,11 @@ Expiry is therefore approximate; a late `gc` extends retention. ## Authorization -`session.auth()` is the session's `auth::Handle`. On moq-lite 06 each side -presents its connection's credential right after setup, and `grant()` watches -the union of every grant this side holds: `None` until the peer answers, -forever on other versions. `add(token)` presents another token and resolves +`session.auth()` is the session's `auth::Handle`. On moq-lite 06, and on +moq-transport draft-17+ when both sides negotiate the +[MoQ Auth extension](/draft/moq-auth), each side presents its connection's +credential right after setup, and `grant()` watches the union of every grant +this side holds: `None` until the peer answers, forever otherwise. `add(token)` presents another token and resolves once the peer answers; drop the returned `auth::Token` to withdraw it. ```rust @@ -91,8 +92,9 @@ origin handles allow and refuses any other token as unsupported. To verify tokens yourself, take `handshake.auth().requests()` on the `server::Handshake` before `ok()` (or `session.auth().requests()` before first polling the driver) and answer every `auth::Request` with `accept(grant)`, which returns an -`auth::Issued` you can `update` or `revoke`, or `reject`. The wire carries -prefix grants for now, so a grant that is not a union of subtrees is refused. +`auth::Issued` you can `update` or `revoke`, or `reject`. Both wires carry +prefix grants for now, so a grant that is not a union of subtrees is refused +and the presenter sees `Unsupported`; after a grant, such an update revokes it. A client whose origin publishes a broadcast outside its grant closes the session with `Unauthorized`, naming the path in the close reason. A grant that diff --git a/drafts/draft-lcurley-moq-auth.md b/drafts/draft-lcurley-moq-auth.md new file mode 100644 index 0000000000..1e285eddd8 --- /dev/null +++ b/drafts/draft-lcurley-moq-auth.md @@ -0,0 +1,228 @@ +--- +title: "MoQ Auth Extension" +abbrev: "moq-auth" +category: info + +docname: draft-lcurley-moq-auth-latest +submissiontype: IETF # also: "independent", "editorial", "IAB", or "IRTF" +number: +date: +v: 3 +area: wit +workgroup: moq + +author: + - + fullname: Luke Curley + email: kixelated@gmail.com + +normative: + moqt: I-D.ietf-moq-transport + +informative: + moq-lite: I-D.lcurley-moq-lite + +--- abstract + +This document defines an extension for MoQ Transport {{moqt}} that tells an endpoint what its peer will let it publish and subscribe to. +Either endpoint presents a token on a request stream of its own, and the peer answers with the namespace prefixes the token grants and when that grant lapses. +Tokens can be added and withdrawn for the life of the session, so a session can outlive the credential it started with. +An endpoint that knows its grant can fail loudly on a publication that will never be accepted, instead of waiting for a subscription that never comes. + +--- note_Note_to_Readers + +This document was generated by an AI model from the implementation at [github.com/moq-dev/moq](https://github.com/moq-dev/moq) and is maintained alongside it. +Submit an [issue](https://github.com/moq-dev/moq/issues) or [PR](https://github.com/moq-dev/moq/pulls) if this spec sucks and you want to fix anything. + +--- middle + +# Conventions and Definitions +{::boilerplate bcp14-tagged} + +The **presenter** of a token is the endpoint that sends it in AUTH, and the **acceptor** is the endpoint that answers. +A **grant** is what the acceptor allows for one token: the namespaces the presenter may publish to it, the namespaces the presenter may subscribe to from it, and an optional expiry. + + +# Introduction +{{moqt}} authorizes each request on its own. +A publisher learns that a namespace is refused only by sending PUBLISH_NAMESPACE and reading the REQUEST_ERROR, and learns nothing at all from a relay that forwards only what it is asked for. +Nothing on the wire says which role an endpoint will ever be allowed to play ([moq-transport issue 1854](https://github.com/moq-wg/moq-transport/issues/1854)), so a client authorized to publish only `alice` that publishes `bob` waits forever, and neither side logs anything. + +This extension answers that question once per credential. +Right after setup each endpoint presents the credential its connection already carried, and learns the grant it earned. +Further tokens are presented on their own request streams, and the endpoint's scope is the union of every grant it holds. + +The exchange is the Auth Stream of {{moq-lite}}, carried as {{moqt}} request streams and negotiated with a Setup Option. + + +# Setup Negotiation + +An endpoint that implements this extension sends the following Setup Option ({{moqt}} Section 9.4.1): + +~~~ +AUTH Setup Option { + Option Key (vi64) = 0x40B60 + Option Value (vi64) = 1 +} +~~~ + +The extension is negotiated when both endpoints sent the option with a value of 1. +An endpoint MUST NOT send AUTH on a session that did not negotiate it, and an endpoint that receives AUTH anyway MUST close the session with PROTOCOL_VIOLATION, as it would for any unknown message type. + +This extension is defined for the versions of {{moqt}} with the unified SETUP message, draft-17 and later. + + +# Auth Requests {#requests} + +A presenter opens a bidirectional request stream for each token and sends a single AUTH message on it. +The stream lives as long as the token, the way a subscription's request stream outlives its SUBSCRIBE_OK. + +Each endpoint SHOULD open one Auth request with an empty token as soon as the extension is negotiated. +An empty token presents the credential the connection already carried: the request URI, a client certificate, an AUTHORIZATION TOKEN Setup Option ({{moqt}} Section 9.4.1), or nothing. +Its grant tells the presenter which role the acceptor will let it play. + +The acceptor answers with AUTH_OK carrying the grant, or AUTH_ERROR refusing the token. +It MAY send further AUTH_OK messages to replace the grant, such as with a lowered expiry, and the latest AUTH_OK is the token's grant. +It MAY send AUTH_ERROR after an AUTH_OK to revoke the grant. +After an AUTH_ERROR the acceptor closes its side of the stream. + +The presenter withdraws a token by closing or resetting its side of the stream, and the acceptor then closes its own. +An acceptor that closes its side without an AUTH_ERROR ends the grant without a reason. + +## Scope {#scope} + +An endpoint's scope is the union of the grants of its open Auth requests. +A request that ends, by withdrawal, revocation, or a stream error, removes its grant from the union. +An empty union grants nothing, and the session stays open for another token. + +A grant tells the presenter what the acceptor will allow; it does not replace the acceptor's own enforcement, which MUST still refuse what the grant does not cover. + +When the union shrinks, the presenter SHOULD withdraw its advertisements and cancel its subscriptions that the union no longer covers, keeping the session and everything still covered. + +A presenter that would advertise a namespace outside the union, once the tokens it presented at setup are answered, SHOULD close the session with UNAUTHORIZED instead of sending PUBLISH_NAMESPACE, since a subscription for it will never come. +A publication that loses coverage because the union shrank is withdrawn, which is no reason to close the session. + +## Prefixes {#prefixes} + +A grant names Track Namespace prefixes, encoded as the Track Namespace Prefix of SUBSCRIBE_NAMESPACE ({{moqt}}) and matched the same way: a prefix covers every namespace whose leading fields equal it. +A prefix with no fields covers every namespace, and a count of zero grants none. + +An acceptor whose grant is not a union of prefixes, such as one exact namespace without its descendants, MUST NOT widen it to a prefix. +It sends AUTH_ERROR with NOT_SUPPORTED instead: as the first reply this refuses the token, and after an AUTH_OK it revokes the earlier grant. +Other tokens on the session are unaffected. + + +# Messages + +Each message is a {{moqt}} Control Message, framed by its Message Type and a 16-bit Message Length. + +## AUTH {#auth} + +AUTH is the first message on an Auth request stream, sent by the presenter. + +~~~ +AUTH Message { + Type (vi64) = 0x40B61, + Length (16), + Request ID (vi64), + Token Length (vi64), + Token (..), +} +~~~ + +**Request ID**: +The request's identifier, allocated like that of any other request ({{moqt}}). + +**Token**: +The credential to verify, opaque to this extension. +An empty token presents the credential the connection already carried ({{requests}}). + +## AUTH_OK {#auth-ok} + +AUTH_OK grants the token, replacing any earlier grant on the same stream. + +~~~ +AUTH_OK Message { + Type (vi64) = 0x40B62, + Length (16), + Publish Count (vi64), + Publish Prefix (Track Namespace) ..., + Subscribe Count (vi64), + Subscribe Prefix (Track Namespace) ..., + Expires (vi64), +} +~~~ + +**Publish Prefix**: +A namespace prefix the presenter may advertise and serve ({{prefixes}}). + +**Subscribe Prefix**: +A namespace prefix the presenter may subscribe to and discover. + +**Expires**: +The number of milliseconds until the grant lapses, or 0 for never. +The acceptor revokes a lapsed grant with AUTH_ERROR, and the presenter uses Expires to present a replacement token in time. + +## AUTH_ERROR {#auth-error} + +AUTH_ERROR refuses the token, or revokes it after an AUTH_OK. + +~~~ +AUTH_ERROR Message { + Type (vi64) = 0x40B63, + Length (16), + Error Code (vi64), + Reason Phrase Length (vi64), + Reason Phrase (..), +} +~~~ + +**Error Code**: +A code from the REQUEST_ERROR registry ({{moqt}}): UNAUTHORIZED, MALFORMED_AUTH_TOKEN, EXPIRED_AUTH_TOKEN, or NOT_SUPPORTED ({{prefixes}}). + +**Reason Phrase**: +A human-readable reason, at most 8,192 bytes. + + +# Security Considerations + +A token presented in AUTH is as sensitive as one in the request URI or an AUTHORIZATION TOKEN, and relies on the same transport confidentiality. + +A grant is advice to the presenter, never authority for the acceptor: the acceptor MUST enforce its own authorization on every request whatever it granted. +A presenter that trusts a grant too far only fails on the acceptor's REQUEST_ERROR, as it would without this extension. + +Auth requests count against the peer's request and stream limits like any other request. + + +# IANA Considerations + +This document requests the following registrations. +High, distinctive values are requested to avoid the low ranges reserved by {{moqt}} and to minimize collisions with provisional registrations by other extensions. + +## MOQT Setup Options + +This document requests one registration in the "MOQT Setup Options" registry ({{moqt}}), whose policy is Specification Required. + +| Value | Name | Reference | +|:--------|:-----|:--------------| +| 0x40B60 | AUTH | This Document | + +AUTH is even, so its value is a bare varint. + +## MOQT Message Types + +This document requests three registrations in the "MOQT Message Types" registry ({{moqt}}). + +| Value | Name | Reference | +|:--------|:-----------|:--------------| +| 0x40B61 | AUTH | This Document | +| 0x40B62 | AUTH_OK | This Document | +| 0x40B63 | AUTH_ERROR | This Document | + + +--- back + +# Acknowledgments +{:numbered="false"} + +This document was drafted with the assistance of Claude, an AI assistant by Anthropic. diff --git a/go/wrapper/README.md b/go/wrapper/README.md index 52ed3ce69d..cf241671b5 100644 --- a/go/wrapper/README.md +++ b/go/wrapper/README.md @@ -77,6 +77,10 @@ for media tracks whose timescale should be selected by the importer. `WithVideoHint(moq.VideoHint{...})` for video catalog fields that are known before the stream reveals them. +`WithAudioTrack(name)` / `WithVideoTrack(name)` name the track instead of +deriving a unique name from the format. A duplicate name fails, and the +`OnTrack` variants refuse it because the request already names the track. + JSON tracks are available in two modes. `PublishJSONSnapshot` / `SubscribeJSONSnapshot` carry lossy latest state, while `PublishJSONStream` / `SubscribeJSONStream` carry every record in order. Producers accept any `encoding/json` value; consumers return diff --git a/go/wrapper/publish.go b/go/wrapper/publish.go index fc1e885372..6eccc6d8f6 100644 --- a/go/wrapper/publish.go +++ b/go/wrapper/publish.go @@ -30,6 +30,22 @@ func WithVideoLabel(label string) VideoOption { } } +// WithAudioTrack names the track instead of deriving a unique name from the +// format. A requested track already has a name, so the OnTrack variant refuses it. +func WithAudioTrack(track string) AudioOption { + return func(init *ffi.MoqAudioInit) { + init.Track = &track + } +} + +// WithVideoTrack names the track instead of deriving a unique name from the +// format. A requested track already has a name, so the OnTrack variant refuses it. +func WithVideoTrack(track string) VideoOption { + return func(init *ffi.MoqVideoInit) { + init.Track = &track + } +} + // WithVideoHint seeds catalog fields that a video stream cannot reveal itself. func WithVideoHint(hint VideoHint) VideoOption { return func(init *ffi.MoqVideoInit) { diff --git a/js/CLAUDE.md b/js/CLAUDE.md index c565e540eb..9e0bf43c2a 100644 --- a/js/CLAUDE.md +++ b/js/CLAUDE.md @@ -13,6 +13,7 @@ The spine of the JS code; read `signals/src/index.ts` before touching reactive c - `Signal` writes coalesce per microtask and notify only on change. Equality is deep for plain data but identity for class instances; `set(v, true)` forces a notify. `peek` reads without subscribing. - `Computed`: derived, `undefined` until first run and after `close()`. Standalone ones must be closed; `effect.computed()` closes with its parent. - `Effect`: reruns when a signal read via `effect.get(signal)` changes. Register teardown with `effect.cleanup(fn)`; it runs before the next run and on `close()`. A rerun waits for every `effect.spawn` task from the previous run to settle, so register teardown unconditionally. +- Never `Promise.race` a value that outlives the call, such as a `closed`; use `race` or `effect.race`, which release their listeners. - Use the scoped helpers (`effect.interval`, `timer`, `timeout`, `animate`, `event`, `subscribe`, `set`, `proxy`, `run`) instead of raw timers or listeners, so cleanup is automatic. Prefer nested `effect.run` over one giant effect. # Producer / consumer diff --git a/js/binary/package.json b/js/binary/package.json index 499de5a656..849e5f012a 100644 --- a/js/binary/package.json +++ b/js/binary/package.json @@ -21,7 +21,8 @@ }, "dependencies": { "@moq/flate": "workspace:^", - "@moq/net": "workspace:^" + "@moq/net": "workspace:^", + "@moq/signals": "workspace:^" }, "devDependencies": { "@types/bun": "^1.4.2", diff --git a/js/binary/src/stream/consumer.ts b/js/binary/src/stream/consumer.ts index 47d9ee635d..f4cc5cf441 100644 --- a/js/binary/src/stream/consumer.ts +++ b/js/binary/src/stream/consumer.ts @@ -1,5 +1,6 @@ import { Decoder as Flate } from "@moq/flate"; import type * as Moq from "@moq/net"; +import { race } from "@moq/signals"; import { isDeflate } from "../compression.ts"; import type { Config as CodecConfig } from "./producer.ts"; @@ -102,7 +103,7 @@ export class Consumer { if (buffered) return buffered; const frame = group.readFrame(); - const winner = await Promise.race([frame.then((frame) => ({ frame }) as const), this.#recvGroup()]); + const winner = await race([frame.then((frame) => ({ frame }) as const), this.#recvGroup()]); if ("frame" in winner) return winner.frame; if (winner.group) { diff --git a/js/binary/src/stream/stream.test.ts b/js/binary/src/stream/stream.test.ts index b1f547c3a3..b8745147fb 100644 --- a/js/binary/src/stream/stream.test.ts +++ b/js/binary/src/stream/stream.test.ts @@ -1,3 +1,4 @@ +import { heapStats } from "bun:jsc"; import { expect, test } from "bun:test"; import { DEFAULT_MAX_FRAME_SIZE } from "@moq/flate"; import { Time, Track } from "@moq/net"; @@ -146,3 +147,32 @@ test("an undecodable payload ends the log for a reader already inside the group" // Surfaces the terminal error rather than hanging on the still-open group. await expect(consumer.next()).rejects.toThrow("limit"); }); + +// A blocked read races the frame against the track's next group, which stays pending for the whole +// log. Racing it per payload must not leave a reaction behind on it each time. +test("blocked reads leave nothing behind on the pending group read", async () => { + const track = new Track.Producer("test"); + const producer = new Producer({ track }); + const subscriber = track.subscribe(); + const consumer = new Consumer({ track: subscriber }); + const promises = () => { + Bun.gc(true); + return heapStats().objectTypeCounts.Promise ?? 0; + }; + + const read = async (from: number, count: number) => { + for (let n = from; n < from + count; n++) { + const next = consumer.next(); + producer.append(new Uint8Array([n & 0xff])); + expect((await next)?.[0]).toBe(n & 0xff); + } + }; + + await read(0, 50); + const before = promises(); + await read(50, 1000); + expect(promises() - before).toBeLessThan(100); + + subscriber.close(); + producer.finish(); +}); diff --git a/js/json/src/stream/consumer.ts b/js/json/src/stream/consumer.ts index 0d93d8dd10..6ca14024be 100644 --- a/js/json/src/stream/consumer.ts +++ b/js/json/src/stream/consumer.ts @@ -1,4 +1,5 @@ import type * as Moq from "@moq/net"; +import { race } from "@moq/signals"; import { Decoder } from "./decoder.ts"; import type { Config as CodecConfig } from "./encoder.ts"; @@ -101,7 +102,7 @@ export class Consumer { if (buffered) return buffered; const frame = group.readFrame(); - const winner = await Promise.race([frame.then((frame) => ({ frame }) as const), this.#recvGroup()]); + const winner = await race([frame.then((frame) => ({ frame }) as const), this.#recvGroup()]); if ("frame" in winner) return winner.frame; if (winner.group) { diff --git a/js/json/src/stream/stream.test.ts b/js/json/src/stream/stream.test.ts index 5e93b54724..35512bac88 100644 --- a/js/json/src/stream/stream.test.ts +++ b/js/json/src/stream/stream.test.ts @@ -1,3 +1,4 @@ +import { heapStats } from "bun:jsc"; import { expect, test } from "bun:test"; import { Time, Track } from "@moq/net"; import { Consumer, Producer, Rolled } from "./index.ts"; @@ -110,3 +111,32 @@ test("a second concurrent read is refused rather than served the first one's gro expect(() => consumer.next()).toThrow("multiple calls to next not supported"); expect(await first).toEqual({ n: 0 }); }); + +// A blocked read races the frame against the track's next group, which stays pending for the whole +// log. Racing it per record must not leave a reaction behind on it each time. +test("blocked reads leave nothing behind on the pending group read", async () => { + const track = new Track.Producer("test"); + const producer = new Producer({ track }); + const subscriber = track.subscribe(); + const consumer = new Consumer({ track: subscriber }); + const promises = () => { + Bun.gc(true); + return heapStats().objectTypeCounts.Promise ?? 0; + }; + + const read = async (from: number, count: number) => { + for (let n = from; n < from + count; n++) { + const next = consumer.next(); + producer.append({ n }); + expect((await next)?.n).toBe(n); + } + }; + + await read(0, 50); + const before = promises(); + await read(50, 1000); + expect(promises() - before).toBeLessThan(100); + + subscriber.close(); + producer.finish(); +}); diff --git a/js/moq-boy/src/element.tsx b/js/moq-boy/src/element.tsx index b776fd8e52..48346b6de1 100644 --- a/js/moq-boy/src/element.tsx +++ b/js/moq-boy/src/element.tsx @@ -135,7 +135,7 @@ export default class MoqBoy extends HTMLElement { effect.spawn(async () => { for (;;) { - const entry = await Promise.race([effect.cancel, announced.next()]); + const entry = await effect.race(announced.next()); if (!entry) break; // A broad route that cannot pin the game id names nothing to open. diff --git a/js/moq-boy/src/game.ts b/js/moq-boy/src/game.ts index c9bec3f4d5..9d2a03d39d 100644 --- a/js/moq-boy/src/game.ts +++ b/js/moq-boy/src/game.ts @@ -266,7 +266,7 @@ export class Game { const consumer = new Json.Snapshot.Consumer({ track: statusTrack, schema: GameStatusSchema }); // Closing the track on cleanup unblocks a pending next() (it returns undefined), so the loop - // ends without racing effect.cancel. + // ends without racing the teardown. effect.spawn(async () => { for (;;) { let status: GameStatus | undefined; diff --git a/js/net/examples/wait.ts b/js/net/examples/wait.ts index 5bf4d8170a..79adf1babf 100644 --- a/js/net/examples/wait.ts +++ b/js/net/examples/wait.ts @@ -28,7 +28,7 @@ async function main() { effect.spawn(async () => { for (;;) { - const group = await Promise.race([effect.cancel, track.recvGroup()]); + const group = await effect.race(track.recvGroup()); if (!group) break; console.log("received:", await group.readString()); } diff --git a/js/net/src/auth.test.ts b/js/net/src/auth.test.ts new file mode 100644 index 0000000000..2c83af8c6f --- /dev/null +++ b/js/net/src/auth.test.ts @@ -0,0 +1,233 @@ +import { describe, expect, test } from "bun:test"; +import type { Getter } from "@moq/signals"; +import { type Grant, type Issued, Unsupported } from "./auth.ts"; +import { accept as acceptSession, connect as connectSession, type Established } from "./connection/index.ts"; +import { SessionCode, SessionError } from "./error.ts"; +import * as Ietf from "./ietf/index.ts"; +import * as Lite from "./lite/index.ts"; +import { createMockTransportPair, type MockTransport } from "./mock.ts"; +import { Producer as OriginProducer } from "./origin.ts"; +import * as Path from "./path.ts"; + +const url = new URL("https://localhost:4443/test"); + +function patterns(...prefixes: string[]): Path.Patterns { + return new Path.Patterns(prefixes.map((prefix) => Path.Pattern.subtree(prefix))); +} + +function grant(publish: string[], subscribe: string[]): Grant { + return { publish: patterns(...publish), subscribe: patterns(...subscribe) }; +} + +async function waitFor(getter: Getter, ready: (value: T) => boolean): Promise { + let value = getter.peek(); + while (!ready(value)) value = await getter.changed(); + return value; +} + +interface Pair { + client: Established; + server: Established; + transport: MockTransport; +} + +async function connect(opts: { publish?: OriginProducer; serverPublish?: OriginProducer; protocol: string }) { + const pair = createMockTransportPair(opts.protocol); + const [client, server] = await Promise.all([ + connectSession({ url, transport: pair.client, publish: opts.publish?.consume() }), + acceptSession({ transport: pair.server, url, publish: opts.serverPublish?.consume() }), + ]); + return { client, server, transport: pair.client } satisfies Pair; +} + +// Every case runs on moq-lite-06 and on moq-transport with the MoQ Auth extension. +describe.each([Lite.ALPN_06, Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_22])("%s", (protocol) => { + test("both sides learn their default grant", async () => { + const { client, server } = await connect({ publish: new OriginProducer(), protocol }); + + // The server consumes anything and publishes nothing. + const clientGrant = await waitFor(client.auth.grant, (g) => g !== undefined); + expect(clientGrant?.publish.equals(patterns(""))).toBe(true); + expect(clientGrant?.subscribe.size).toBe(0); + + // The client publishes, so the server may subscribe to anything. + const serverGrant = await waitFor(server.auth.grant, (g) => g !== undefined); + expect(serverGrant?.publish.equals(patterns(""))).toBe(true); + expect(serverGrant?.subscribe.equals(patterns(""))).toBe(true); + + client.close(); + server.close(); + }); + + test("a token without an acceptor reports unsupported", async () => { + const { client, server } = await connect({ publish: new OriginProducer(), protocol }); + await waitFor(client.auth.grant, (g) => g !== undefined); + await expect(client.auth.add("token")).rejects.toBeInstanceOf(Unsupported); + client.close(); + server.close(); + }); + + test("an out-of-scope broadcast closes the session and names the path", async () => { + const origin = new OriginProducer(); + const { client, server, transport } = await connect({ publish: origin, protocol }); + const requests = server.auth.requests(); + const issued: Issued[] = []; + void (async () => { + for (;;) { + const request = await requests.next(); + if (!request) break; + issued.push(request.accept(grant(["baz"], []))); + } + })(); + + await waitFor(client.auth.grant, (g) => g !== undefined); + origin.createBroadcast(Path.from("baz/ok")).announce(); + origin.createBroadcast(Path.from("foo/bar")).announce(); + + const info = await transport.closed; + expect(info.closeCode).toBe(SessionCode.Unauthorized); + expect(info.reason).toBe("unauthorized: foo/bar"); + server.close(); + }); + + test("a revoked grant withdraws its broadcasts without closing the session", async () => { + const origin = new OriginProducer(); + const { client, server, transport } = await connect({ publish: origin, protocol }); + const requests = server.auth.requests(); + const issued: Issued[] = []; + void (async () => { + for (;;) { + const request = await requests.next(); + if (!request) break; + issued.push(request.accept(grant(["a"], []))); + } + })(); + + await waitFor(client.auth.grant, (g) => g !== undefined); + origin.createBroadcast(Path.from("a/x")).announce(); + + const announced = server.announced(); + const first = await announced.next(); + expect(first?.prefix).toBe(Path.from("a/x")); + expect(first?.kind).toBe("announced"); + + issued[0]?.revoke(SessionCode.Unauthorized, "expired"); + const second = await announced.next(); + expect(second?.prefix).toBe(Path.from("a/x")); + expect(second?.kind).toBe("retracted"); + + // The union is empty but still a grant, and a new token restores it. + const empty = await waitFor(client.auth.grant, (g) => g !== undefined && g.publish.size === 0); + expect(empty?.subscribe.size).toBe(0); + const token = await client.auth.add("again"); + expect(token.grant.peek()?.publish.equals(patterns("a"))).toBe(true); + const third = await announced.next(); + expect(third?.kind).toBe("announced"); + + let closed = false; + void transport.closed.then(() => { + closed = true; + }); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(closed).toBe(false); + + announced.close(); + client.close(); + server.close(); + }); + + test("a refused token surfaces the acceptor's code and reason", async () => { + const { client, server } = await connect({ publish: new OriginProducer(), protocol }); + const requests = server.auth.requests(); + void (async () => { + for (;;) { + const request = await requests.next(); + if (!request) break; + if (request.token.byteLength === 0) request.accept(grant([], [])); + else request.reject(SessionCode.Unauthorized, "bad signature"); + } + })(); + + const err = await client.auth.add("forged").catch((e: unknown) => e); + expect(err).toBeInstanceOf(SessionError); + expect((err as SessionError).code).toBe(SessionCode.Unauthorized); + client.close(); + server.close(); + }); + + test("a grant the wire cannot carry is unsupported and leaves the rest alone", async () => { + const { client, server, transport } = await connect({ publish: new OriginProducer(), protocol }); + const requests = server.auth.requests(); + const issued: Issued[] = []; + void (async () => { + for (;;) { + const request = await requests.next(); + if (!request) break; + const token = new TextDecoder().decode(request.token); + const unions: Record = { + exact: ["room/alice"], + mixed: ["room/**", "lobby"], + wildcard: ["room/*/cam"], + }; + const union = unions[token]; + const granted = union + ? { publish: new Path.Patterns(union.map((p) => Path.Pattern.parse(p))), subscribe: patterns() } + : token === "t1" + ? grant(["b"], []) + : grant(["a"], []); + issued.push(request.accept(granted)); + } + })(); + + await waitFor(client.auth.grant, (g) => g?.publish.equals(patterns("a")) === true); + for (const token of ["exact", "mixed", "wildcard"]) { + await expect(client.auth.add(token)).rejects.toBeInstanceOf(Unsupported); + } + expect(client.auth.grant.peek()?.publish.equals(patterns("a"))).toBe(true); + + // An update the wire cannot carry revokes that token's grant, and only that one. + const t1 = await client.auth.add("t1"); + await waitFor(client.auth.grant, (g) => g?.publish.equals(patterns("a", "b")) === true); + issued[issued.length - 1]?.update({ + publish: new Path.Patterns([Path.Pattern.literal("b/exact")]), + subscribe: patterns(), + }); + await t1.closed; + await waitFor(client.auth.grant, (g) => g?.publish.equals(patterns("a")) === true); + + let closed = false; + void transport.closed.then(() => { + closed = true; + }); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(closed).toBe(false); + client.close(); + server.close(); + }); + + test("a refused setup token grants nothing rather than everything", async () => { + const { client, server } = await connect({ publish: new OriginProducer(), protocol }); + const requests = server.auth.requests(); + void (async () => { + for (;;) { + const request = await requests.next(); + if (!request) break; + request.reject(SessionCode.Unauthorized, "bad credential"); + } + })(); + + const empty = await waitFor(client.auth.grant, (g) => g !== undefined); + expect(empty?.publish.size).toBe(0); + expect(empty?.subscribe.size).toBe(0); + client.close(); + server.close(); + }); +}); + +test.each([Lite.ALPN_05, Ietf.ALPN.DRAFT_16])("%s has no grant", async (protocol) => { + const { client, server } = await connect({ publish: new OriginProducer(), protocol }); + expect(client.auth.grant.peek()).toBeUndefined(); + await expect(client.auth.add("token")).rejects.toBeInstanceOf(Unsupported); + client.close(); + server.close(); +}); diff --git a/js/net/src/auth.ts b/js/net/src/auth.ts index 6efbc3bd6f..164cf5adc4 100644 --- a/js/net/src/auth.ts +++ b/js/net/src/auth.ts @@ -1,10 +1,13 @@ /** * In-band authorization: present tokens to the peer and learn what they grant. * - * Each side of a moq-lite-06 session presents the credential its connection already - * carried (the URL, or nothing) right after setup, and learns the {@link Grant} it earned. - * {@link Auth.grant} is the union of every token this side presented, and {@link Auth.add} - * presents another without reconnecting. Mirrors the Rust `moq_net::auth`. + * Each side of a moq-lite-06 session, and of a moq-transport draft-17+ session when both + * sides negotiate MoQ Auth, presents the credential its connection already carried (the + * URL, or nothing) right after setup, and learns the {@link Grant} it earned. Older + * versions, and peers that do not negotiate it, carry no AUTH exchange: the grant stays + * undefined and {@link Auth.add} rejects with {@link Unsupported}. {@link Auth.grant} is + * the union of every token this side presented, and {@link Auth.add} presents another + * without reconnecting. Mirrors the Rust `moq_net::auth`. * * @module */ diff --git a/js/net/src/auth_session.ts b/js/net/src/auth_session.ts new file mode 100644 index 0000000000..5f5f16705c --- /dev/null +++ b/js/net/src/auth_session.ts @@ -0,0 +1,481 @@ +import { type Dispose, type Getter, Signal } from "@moq/signals"; +import { + type Auth as AuthApi, + type Grant, + grantsEqual, + type Issued, + type Request, + type Requests, + type Token, + Unsupported, +} from "./auth.ts"; +import { closeReason, error, SessionCode, StreamCode, StreamError } from "./error.ts"; +import * as Path from "./path.ts"; +import type { Stream } from "./stream.ts"; + +/** A grant as the wire carries it: the expiry is milliseconds from now. @internal */ +export interface WireGrant { + publish: Path.Patterns; + subscribe: Path.Patterns; + expires?: number; +} + +/** One reply read off a presented token's stream. @internal */ +export type WireReply = { grant: WireGrant } | { refused: Error }; + +/** + * How one wire carries AUTH, AUTH_OK, and AUTH_ERROR, so moq-lite and moq-transport share + * the token lifecycle. + * + * @internal + */ +export interface AuthWire { + /** Open a stream and present the token on it. */ + present(token: Uint8Array): Promise; + /** The next reply on a presented token's stream, or undefined once the acceptor finished it. */ + read(stream: Stream): Promise; + /** The token the peer presents on a stream it opened. */ + accept(stream: Stream): Promise; + /** Tell the presenter a grant. Throws {@link Unsupported} when this wire cannot represent it. */ + grant(stream: Stream, grant: WireGrant): Promise; + /** Refuse or revoke the presenter's token. */ + refuse(stream: Stream, code: SessionCode, reason: string): Promise; + /** End the stream telling the presenter its token or grant cannot be answered here. */ + unsupported(stream: Stream, reason: string): Promise; +} + +function union(grants: Iterable): Grant { + const publish = new Path.Patterns(); + const subscribe = new Path.Patterns(); + let expires: number | undefined; + for (const grant of grants) { + for (const pattern of grant.publish) publish.insert(pattern); + for (const pattern of grant.subscribe) subscribe.insert(pattern); + // The earliest expiry is when the union next shrinks. + if (grant.expires !== undefined) expires = Math.min(expires ?? grant.expires, grant.expires); + } + return { publish, subscribe, expires }; +} + +function cancel(): StreamError { + return new StreamError(StreamCode.Cancel, { message: "cancel" }); +} + +/** One token this side presented. */ +class Presented implements Token { + readonly grant = new Signal(undefined); + readonly closed: Promise; + readonly answered: Promise; + readonly setup: boolean; + readonly token: Uint8Array; + + stream?: Stream; + withdrawn = false; + isAnswered = false; + ended = false; + + #close!: (err: Error | null) => void; + #answer!: () => void; + #refuse!: (err: Error) => void; + + constructor(token: Uint8Array, setup: boolean) { + this.token = token; + this.setup = setup; + this.closed = new Promise((resolve) => { + this.#close = resolve; + }); + this.answered = new Promise((resolve, reject) => { + this.#answer = resolve; + this.#refuse = reject; + }); + // A caller that never awaits the answer must not see an unhandled rejection. + this.answered.catch(() => void 0); + } + + answer() { + if (this.isAnswered) return; + this.isAnswered = true; + this.#answer(); + } + + end(err: Error | null) { + if (this.ended) return; + this.ended = true; + this.grant.set(undefined); + if (!this.isAnswered) { + this.isAnswered = true; + this.#refuse(err ?? new Error("withdrawn")); + } + this.#close(err); + } + + close() { + if (this.withdrawn) return; + this.withdrawn = true; + // The stream's loop notices and ends the token; one still opening checks on arrival. + this.stream?.abort(cancel()); + } +} + +/** The peer's token, answered by the application. */ +class PeerRequest implements Request { + readonly token: Uint8Array; + #issued: IssuedGrant; + #answered = false; + + constructor(token: Uint8Array, issued: IssuedGrant) { + this.token = token; + this.#issued = issued; + } + + accept(grant: Grant): Issued { + if (this.#answered) throw new Error("already answered"); + this.#answered = true; + this.#issued.update(grant); + return this.#issued; + } + + reject(code: SessionCode, reason: string): void { + if (this.#answered) throw new Error("already answered"); + this.#answered = true; + this.#issued.revoke(code, reason); + } +} + +/** Our side of one of the peer's tokens: the grant we issued and its stream. */ +class IssuedGrant implements Issued { + readonly closed: Promise; + #stream: Stream; + #wire: AuthWire; + #writes = Promise.resolve(); + #done = false; + + constructor(stream: Stream, wire: AuthWire) { + this.#stream = stream; + this.#wire = wire; + // The presenter withdraws by closing or cancelling its side. + this.closed = stream.reader.closed.then( + () => null, + (err: unknown) => (err instanceof StreamError && err.code === StreamCode.Cancel ? null : error(err)), + ); + } + + #write(write: () => Promise) { + this.#writes = this.#writes.then(write).catch((err: unknown) => { + // The peer already closed the stream: nothing left to tell it. + if (err instanceof StreamError) return; + // A grant the wire cannot carry is withheld, never widened, and so is any other + // reply that fails to encode. + if (!(err instanceof Unsupported)) console.warn("auth reply not sent", err); + this.#done = true; + return this.#wire.unsupported(this.#stream, error(err).message).catch(() => void 0); + }); + } + + update(grant: Grant): void { + if (this.#done) return; + const expires = grant.expires === undefined ? undefined : grant.expires - Date.now(); + this.#write(() => + this.#wire.grant(this.#stream, { publish: grant.publish, subscribe: grant.subscribe, expires }), + ); + } + + revoke(code: SessionCode, reason: string): void { + if (this.#done) return; + this.#write(() => this.#wire.refuse(this.#stream, code, reason)); + this.close(); + } + + close(): void { + if (this.#done) return; + this.#done = true; + this.#writes = this.#writes.then(() => this.#stream.writer.close()); + } +} + +/** The peer's tokens, queued for the application. */ +class RequestQueue implements Requests { + #queue: PeerRequest[] = []; + #waiters: ((request: PeerRequest | undefined) => void)[] = []; + #closed = false; + + push(request: PeerRequest): boolean { + if (this.#closed) return false; + const waiter = this.#waiters.shift(); + if (waiter) waiter(request); + else this.#queue.push(request); + return true; + } + + next(): Promise { + const next = this.#queue.shift(); + if (next || this.#closed) return Promise.resolve(next); + return new Promise((resolve) => this.#waiters.push(resolve)); + } + + close(): void { + this.#closed = true; + for (const request of this.#queue.splice(0)) { + request.reject(SessionCode.Unauthorized, "not accepting tokens"); + } + for (const waiter of this.#waiters.splice(0)) waiter(undefined); + } +} + +/** Constructor options for {@link AuthSession}. @internal */ +export interface AuthSessionProps { + /** How this session's wire carries the exchange, or undefined when it carries none. */ + wire?: AuthWire; + /** What the default acceptor grants the peer's connection credential. */ + peerGrant: Grant; +} + +/** + * A session's tokens and grants: presents ours, one stream each, and answers the peer's. + * The wire decides only the encoding; see {@link AuthWire}. + * + * @internal + */ +export class AuthSession implements AuthApi { + #wire?: AuthWire; + #peerGrant: Grant; + + #union = new Signal(undefined); + // The peer replied to some token, so the union is known even when empty. + #replied = false; + #tokens = new Set(); + #setupPending = new Signal(0); + #acceptor: "undecided" | "default" | RequestQueue = "undecided"; + #closed = false; + + // Whoever answers the peer's tokens is decided once, after the task that established + // the session: an app that calls requests() as soon as connect/accept resolves always + // wins, however quickly the peer's first token arrives. + #decided = new Promise((resolve) => setTimeout(resolve, 0)); + + constructor({ wire, peerGrant }: AuthSessionProps) { + this.#wire = wire; + this.#peerGrant = peerGrant; + + // Present the connection's own credential right away, so both sides learn their + // grant without waiting on the app. + if (wire) this.#present(wire, new Uint8Array(), true); + } + + get grant(): Getter { + return this.#union; + } + + /** Whether this session exchanges tokens at all. */ + get negotiated(): boolean { + return this.#wire !== undefined; + } + + async add(token: string | Uint8Array): Promise { + if (!this.#wire || this.#closed) throw new Unsupported(); + const bytes = typeof token === "string" ? new TextEncoder().encode(token) : token; + const presented = this.#present(this.#wire, bytes, false); + await presented.answered; + return presented; + } + + requests(): Requests { + if (this.#acceptor !== "undecided") throw new Error("auth requests already taken or answered by default"); + const queue = new RequestQueue(); + if (!this.#wire) queue.close(); + this.#acceptor = queue; + return queue; + } + + /** Resolves once every token the session presented at setup has its first reply. */ + async setupAnswered(): Promise { + while (this.#setupPending.peek() > 0) await this.#setupPending.changed(); + } + + /** Answer one of the peer's token streams, for the life of its token. */ + async serve(stream: Stream): Promise { + const wire = this.#wire; + if (!wire) throw new Error("auth not negotiated"); + + const token = await wire.accept(stream); + await this.#decided; + if (this.#acceptor === "undecided") this.#acceptor = "default"; + + const issued = new IssuedGrant(stream, wire); + if (this.#acceptor instanceof RequestQueue) { + const request = new PeerRequest(token, issued); + if (!this.#acceptor.push(request)) request.reject(SessionCode.Unauthorized, "not accepting tokens"); + } else if (token.byteLength > 0) { + // Only the connection's own credential has a default answer; a token needs + // someone to verify it. + await wire.unsupported(stream, "no acceptor for tokens"); + return; + } else { + issued.update(this.#peerGrant); + } + + await issued.closed; + issued.close(); + } + + /** End the session: fail every pending token and close the requests. */ + close() { + if (this.#closed) return; + this.#closed = true; + for (const token of this.#tokens) token.end(new Error("session closed")); + this.#tokens.clear(); + if (this.#acceptor instanceof RequestQueue) this.#acceptor.close(); + } + + #present(wire: AuthWire, token: Uint8Array, setup: boolean): Presented { + const presented = new Presented(token, setup); + this.#tokens.add(presented); + if (setup) this.#setupPending.update((n) => n + 1); + void this.#run(wire, presented); + return presented; + } + + async #run(wire: AuthWire, token: Presented) { + let result: Error | null = null; + try { + const stream = await wire.present(token.token); + token.stream = stream; + if (token.withdrawn) throw cancel(); + + for (;;) { + const reply = await wire.read(stream); + if (!reply) { + // The peer ended the grant without revoking it, or closed without ever + // answering. + result = token.isAnswered ? null : new Unsupported(); + break; + } + if ("grant" in reply) { + const expires = reply.grant.expires === undefined ? undefined : Date.now() + reply.grant.expires; + token.grant.set({ publish: reply.grant.publish, subscribe: reply.grant.subscribe, expires }); + this.#replied = true; + this.#answered(token); + this.#recompute(); + continue; + } + console.warn("auth token refused", reply.refused); + // A refused setup token leaves an empty union, not an unknown (unrestricted) + // one. A grant the acceptor could not tell is unknown, not empty. + if (!(reply.refused instanceof Unsupported)) this.#replied = true; + result = reply.refused; + stream.close(); + break; + } + } catch (err: unknown) { + if (token.withdrawn) { + result = null; + } else if (!token.isAnswered && err instanceof StreamError) { + // A peer that predates AUTH resets a stream type it does not know. + result = new Unsupported(); + } else { + result = error(err); + if (!this.#closed) console.warn("auth token ended", result); + } + } + + // A token that ends unanswered counts as answered for enforcement: its refusal is + // the reply. + const unanswered = !token.isAnswered; + token.end(result); + if (unanswered && token.setup) this.#setupPending.update((n) => n - 1); + this.#tokens.delete(token); + this.#recompute(); + } + + #answered(token: Presented) { + if (token.isAnswered) return; + token.answer(); + if (token.setup) this.#setupPending.update((n) => n - 1); + } + + #recompute() { + const granted: Grant[] = []; + for (const token of this.#tokens) { + const grant = token.grant.peek(); + if (grant) granted.push(grant); + } + // Undefined until the first reply; an empty union afterwards grants nothing. + if (granted.length === 0 && !this.#replied) return; + const next = union(granted); + if (!grantsEqual(next, this.#union.peek())) this.#union.set(next); + } +} + +/** + * Close the session when our origin publishes a broadcast our grant does not cover, + * instead of leaving it to wait for a subscription that never comes. + * + * Starts once the tokens the session presented at setup are answered, then checks each + * broadcast when it first appears. A grant that later shrinks withdraws what it no longer + * covers without closing anything: the grant is read before the table, so a revocation is + * never mistaken for a new unauthorized publication. Only the dialing side enforces: a + * server's publish origin is everything the peer may read, not what it intends to push. + * + * @internal + */ +export async function enforceGrant({ + quic, + advertised, + grant, + setupAnswered, +}: { + quic: WebTransport; + /** The broadcasts this side publishes, undefined once the origin ends. */ + advertised: Getter | undefined>; + grant: Getter; + setupAnswered: Promise; +}): Promise { + const closed = quic.closed.then( + () => "closed" as const, + () => "closed" as const, + ); + if ((await Promise.race([setupAnswered.then(() => "ready" as const), closed])) === "closed") return; + + // Every broadcast admitted so far that is still published. + const live = new Set(); + for (;;) { + let dispose: Dispose = () => {}; + const woke = new Promise<"changed">((resolve) => { + const table = advertised.changed(() => resolve("changed")); + const granted = grant.changed(() => resolve("changed")); + dispose = () => { + table(); + granted(); + }; + }); + + const current = grant.peek(); + const table = advertised.peek(); + if (!table) { + dispose(); + return; + } + if (current) { + for (const path of live) { + if (!table.has(path)) live.delete(path); + } + for (const path of table.keys()) { + if (live.has(path)) continue; + if (!current.publish.matches(path)) { + console.error(`publishing outside our grant; closing the session: broadcast=${path}`); + quic.close({ + closeCode: SessionCode.Unauthorized, + reason: closeReason(`unauthorized: ${path}`), + }); + dispose(); + return; + } + live.add(path); + } + } + + const why = await Promise.race([woke, closed]); + dispose(); + if (why === "closed") return; + } +} diff --git a/js/net/src/connection/accept.ts b/js/net/src/connection/accept.ts index 8e1dce4ee8..c109deb2b6 100644 --- a/js/net/src/connection/accept.ts +++ b/js/net/src/connection/accept.ts @@ -117,7 +117,7 @@ async function acceptAlpn( version: Ietf.IetfVersion, wiring: SessionProps, ): Promise { - const { control, solicit, hidden, cluster } = await exchangeSetup(transport, version, "moq-lite-js"); + const { control, solicit, hidden, cluster, auth } = await exchangeSetup(transport, version, "moq-lite-js"); return new Ietf.Connection({ ...wiring, @@ -128,6 +128,7 @@ async function acceptAlpn( solicit, hidden, cluster, + auth, // v17+ uses NativeSession which manages its own request IDs; maxRequestId is unused. maxRequestId: 0n, version, diff --git a/js/net/src/connection/connect.ts b/js/net/src/connection/connect.ts index c44124423c..c537c4d4a2 100644 --- a/js/net/src/connection/connect.ts +++ b/js/net/src/connection/connect.ts @@ -365,7 +365,7 @@ async function handshakeAlpn( version: Ietf.IetfVersion, wiring: SessionProps, ): Promise { - const { control, solicit, hidden, cluster } = await exchangeSetup(session, version, "moq-lite-js"); + const { control, solicit, hidden, cluster, auth } = await exchangeSetup(session, version, "moq-lite-js"); return new Ietf.Connection({ ...wiring, @@ -376,6 +376,7 @@ async function handshakeAlpn( solicit, hidden, cluster, + auth, // v17+ uses NativeSession which manages its own request IDs; maxRequestId is unused. maxRequestId: 0n, version, diff --git a/js/net/src/connection/established.ts b/js/net/src/connection/established.ts index 6130e322a5..bde995cb76 100644 --- a/js/net/src/connection/established.ts +++ b/js/net/src/connection/established.ts @@ -29,8 +29,9 @@ export interface Established { /** * The tokens this side presented and the grant they earned, plus the tokens the peer - * presents. On moq-lite-06 each side presents its connection's credential right after - * setup; elsewhere the grant stays undefined. + * presents. On moq-lite-06, and on moq-transport draft-17+ when both sides negotiate + * MoQ Auth, each side presents its connection's credential right after setup. + * Otherwise the grant stays undefined. */ readonly auth: Auth.Auth; diff --git a/js/net/src/connection/forward.ts b/js/net/src/connection/forward.ts index a2c50a1d26..66745398a9 100644 --- a/js/net/src/connection/forward.ts +++ b/js/net/src/connection/forward.ts @@ -3,7 +3,7 @@ * * @module */ -import type { Dispose } from "@moq/signals"; +import { type Dispose, race } from "@moq/signals"; import { isActive } from "../announced.ts"; import type { Dynamic, Producer as OriginProducer, RequestSlot } from "../origin.ts"; import type * as Path from "../path.ts"; @@ -160,7 +160,7 @@ async function serveRequests(conn: Established, origin: OriginProducer): Promise // Woken by the table too, not just the requests: a path that stops being routed needs // the blind answer this loop skipped while it was. - await Promise.race([table.changed(), closed]); + await race([table.changed(), closed]); } // Session gone: withdraw our answers, waking a standby session to provide fresh ones. diff --git a/js/net/src/connection/handshake.ts b/js/net/src/connection/handshake.ts index 05cd214c8b..9433eafd7e 100644 --- a/js/net/src/connection/handshake.ts +++ b/js/net/src/connection/handshake.ts @@ -10,7 +10,8 @@ import { Reader, Stream, Writer } from "../stream.ts"; * * Returns the control stream plus what the peer's SETUP declared: whether it requires * solicitation, which decides whether we announce namespaces unprompted (see the MoQ Solicit - * extension), and its Hop ID (see the MoQ Cluster extension). We declare both ourselves on + * extension), its Hop ID (see the MoQ Cluster extension), and whether it offered MoQ Auth. + * We declare all three ourselves on * every session: we send SUBSCRIBE_NAMESPACE for each prefix we want, so an unsolicited * advertisement can tell us nothing we won't have asked for, and a peer that knows our Hop * ID can withhold the advertisements that already flowed through us. @@ -19,12 +20,19 @@ export async function exchangeSetup( transport: WebTransport, version: Ietf.IetfVersion, implementation: string, -): Promise<{ control: Stream; solicit: boolean | undefined; hidden: boolean; cluster: Ietf.Cluster.Hops }> { +): Promise<{ + control: Stream; + solicit: boolean | undefined; + hidden: boolean; + cluster: Ietf.Cluster.Hops; + auth: boolean; +}> { const encoder = new TextEncoder(); const params = new Ietf.SetupOptions(); params.setBytes(Ietf.SetupOption.Implementation, encoder.encode(implementation)); Ietf.solicitIntoSetup(params); Ietf.hiddenIntoSetup(params); + Ietf.Auth.intoSetup(params, version); // One id per session, like the moq-lite connection: nothing in this process forwards // between sessions, so there is nothing for a shared id to detect. @@ -43,6 +51,7 @@ export async function exchangeSetup( solicit: received.solicit, hidden: received.hidden, cluster: { self, peer: received.cluster }, + auth: received.auth, }; } @@ -58,7 +67,7 @@ async function sendSetup(transport: WebTransport, version: Ietf.IetfVersion, set async function receiveSetup( transport: WebTransport, version: Ietf.IetfVersion, -): Promise<{ reader: Reader; solicit: boolean | undefined; hidden: boolean; cluster: Hop | undefined }> { +): Promise<{ reader: Reader; solicit: boolean | undefined; hidden: boolean; cluster: Hop | undefined; auth: boolean }> { const uniReader = transport.incomingUnidirectionalStreams.getReader() as ReadableStreamDefaultReader< ReadableStream >; @@ -79,5 +88,6 @@ async function receiveSetup( solicit: Ietf.solicitFromSetup(setup.parameters), hidden: Ietf.hiddenFromSetup(setup.parameters), cluster: Ietf.Cluster.fromSetup(setup.parameters, version), + auth: Ietf.Auth.fromSetup(setup.parameters, version) === true, }; } diff --git a/js/net/src/connection/pool.ts b/js/net/src/connection/pool.ts index 1957d4dc1a..959697ca09 100644 --- a/js/net/src/connection/pool.ts +++ b/js/net/src/connection/pool.ts @@ -293,7 +293,7 @@ export class Connection { effect.spawn(async () => { try { for (;;) { - const entry = await Promise.race([effect.cancel, upstream.next()]); + const entry = await effect.race(upstream.next()); if (!entry) break; if (Announce.isActive(entry.kind)) active.set(entry.prefix, entry); else active.delete(entry.prefix); diff --git a/js/net/src/connection/reload.ts b/js/net/src/connection/reload.ts index df47c29a18..b7b77bcaef 100644 --- a/js/net/src/connection/reload.ts +++ b/js/net/src/connection/reload.ts @@ -255,7 +255,7 @@ export class Reload { if (pending) return; pending = true; try { - const stats = await Promise.race([effect.cancel, connection.stats()]); + const stats = await effect.race(connection.stats()); if (stats) this.#estimate.set(stats.estimatedSendRate); } finally { pending = false; @@ -341,7 +341,7 @@ export class Reload { // A cancelled effect resolves undefined, so the sentinel tells the session // closing (null for clean, an Error otherwise) apart from this run being // torn down. - const closed = await Promise.race([effect.cancel, connection.closed]); + const closed = await effect.race(connection.closed); if (closed === undefined) return; console.warn("connection closed, reconnecting"); @@ -470,7 +470,7 @@ export class Reload { effect.spawn(async () => { try { for (;;) { - const entry = await Promise.race([effect.cancel, upstream.next()]); + const entry = await effect.race(upstream.next()); if (!entry) break; if (Announce.isActive(entry.kind)) active.set(entry.prefix, entry); else active.delete(entry.prefix); diff --git a/js/net/src/ietf/auth.test.ts b/js/net/src/ietf/auth.test.ts new file mode 100644 index 0000000000..2ebcd217c7 --- /dev/null +++ b/js/net/src/ietf/auth.test.ts @@ -0,0 +1,135 @@ +import { expect, test } from "bun:test"; +import { Unsupported } from "../auth.ts"; +import { SessionError } from "../error.ts"; +import * as Path from "../path.ts"; +import { Reader, Writer } from "../stream.ts"; +import { AuthError, AuthMessage, AuthOk, fromSetup, intoSetup } from "./auth.ts"; +import { SetupOption, SetupOptions } from "./parameters.ts"; +import { Version } from "./version.ts"; + +function patterns(...prefixes: string[]): Path.Patterns { + return new Path.Patterns(prefixes.map((prefix) => Path.Pattern.subtree(prefix))); +} + +/** The bytes a write produces. */ +async function bytes(write: (w: Writer) => Promise): Promise { + const chunks: Uint8Array[] = []; + const writer = new Writer( + new WritableStream({ + write(chunk) { + chunks.push(new Uint8Array(chunk)); + }, + }), + Version.DRAFT_17, + ); + await write(writer); + writer.close(); + await writer.closed; + const out = new Uint8Array(chunks.reduce((n, c) => n + c.byteLength, 0)); + let offset = 0; + for (const chunk of chunks) { + out.set(chunk, offset); + offset += chunk.byteLength; + } + return out; +} + +/** A reader past the message type, the way the dispatcher leaves one. */ +async function afterType(write: (w: Writer) => Promise): Promise { + const reader = new Reader(undefined, await bytes(write), Version.DRAFT_17); + await reader.u53(); + return reader; +} + +test("the setup option negotiates on draft-17+ only", () => { + for (const version of [Version.DRAFT_17, Version.DRAFT_22]) { + const params = new SetupOptions(); + expect(fromSetup(params, version)).toBeUndefined(); + intoSetup(params, version); + expect(fromSetup(params, version)).toBe(true); + } + const legacy = new SetupOptions(); + intoSetup(legacy, Version.DRAFT_16); + expect(legacy.getVarint(SetupOption.Auth)).toBeUndefined(); + + // An explicit value other than 1 is an implementation that declined. + const declined = new SetupOptions(); + declined.setVarint(SetupOption.Auth, 0n); + expect(fromSetup(declined, Version.DRAFT_17)).toBe(false); +}); + +test("AUTH round-trips its request id and token", async () => { + const r = await afterType((w) => new AuthMessage(4n, new TextEncoder().encode("jwt")).encode(w, Version.DRAFT_17)); + const msg = await AuthMessage.decode(r, Version.DRAFT_17); + expect(msg.requestId).toBe(4n); + expect(new TextDecoder().decode(msg.token)).toBe("jwt"); +}); + +test("AUTH_OK carries prefixes as namespace tuples", async () => { + const ok = new AuthOk(patterns("room/alice"), patterns(), undefined); + const wire = await bytes((w) => ok.encode(w, Version.DRAFT_17)); + // Type, 16-bit length, then: one prefix of two fields, no subscribe prefixes, never expires. + expect([...wire.slice(-15)]).toEqual([ + 0x01, + 0x02, + 0x04, + ...new TextEncoder().encode("room"), + 0x05, + ...new TextEncoder().encode("alice"), + 0x00, + 0x00, + ]); + + const r = await afterType((w) => new AuthOk(patterns(""), patterns("room"), 60_000).encode(w, Version.DRAFT_17)); + const got = await AuthOk.decode(r, Version.DRAFT_17); + // The empty prefix grants everything; the empty list grants nothing. + expect(got.publish.equals(new Path.Patterns([Path.Pattern.all()]))).toBe(true); + expect(got.subscribe.equals(patterns("room"))).toBe(true); + expect(got.expires).toBe(60_000); +}); + +test("a grant the prefix wire cannot express is refused before anything is written", async () => { + for (const union of [["room/alice"], ["room/*/cam"], ["room/**", "lobby"]]) { + const narrow = new AuthOk(new Path.Patterns(union.map((p) => Path.Pattern.parse(p))), patterns()); + let written = 0; + const writer = new Writer( + new WritableStream({ + write(chunk) { + written += chunk.byteLength; + }, + }), + Version.DRAFT_17, + ); + await expect(narrow.encode(writer, Version.DRAFT_17)).rejects.toBeInstanceOf(Unsupported); + writer.close(); + await writer.closed; + expect(written).toBe(0); + } +}); + +test("a grant too large for one message is refused before anything is written", async () => { + const huge = new AuthOk(patterns(...Array.from({ length: 20 }, (_, i) => `${i}${"x".repeat(4000)}`)), patterns()); + let written = 0; + const writer = new Writer( + new WritableStream({ + write(chunk) { + written += chunk.byteLength; + }, + }), + Version.DRAFT_17, + ); + await expect(huge.encode(writer, Version.DRAFT_17)).rejects.toThrow("Message too large"); + writer.close(); + await writer.closed; + expect(written).toBe(0); +}); + +test("NOT_SUPPORTED is unsupported, every other code a refusal", async () => { + const r = await afterType((w) => new AuthError(0x3, "prefixes only").encode(w, Version.DRAFT_17)); + expect((await AuthError.decode(r, Version.DRAFT_17)).toError()).toBeInstanceOf(Unsupported); + expect(new AuthError(0x1, "bad").toError()).toBeInstanceOf(SessionError); +}); + +test("drafts before 17 carry no AUTH", async () => { + await expect(bytes((w) => new AuthMessage(0n, new Uint8Array()).encode(w, Version.DRAFT_16))).rejects.toThrow(); +}); diff --git a/js/net/src/ietf/auth.ts b/js/net/src/ietf/auth.ts new file mode 100644 index 0000000000..672b19db5e --- /dev/null +++ b/js/net/src/ietf/auth.ts @@ -0,0 +1,274 @@ +import { Unsupported } from "../auth.ts"; +import type { AuthWire, WireGrant, WireReply } from "../auth_session.ts"; +import { SessionCode, SessionError } from "../error.ts"; +import * as Path from "../path.ts"; +import type { Reader, Stream, Writer } from "../stream.ts"; +import * as Message from "./message.ts"; +import * as Namespace from "./namespace.ts"; +import { SetupOption, type SetupOptions } from "./parameters.ts"; +import { type IetfVersion, Version } from "./version.ts"; + +/** + * The MoQ Auth extension (draft-lcurley-moq-auth-00): the moq-lite Auth Stream as + * moq-transport request streams, negotiated with the AUTH Setup Option on draft-17+. + * + * The wire carries namespace prefixes, so a grant is told only when it is a union of + * subtrees; anything narrower is refused with NOT_SUPPORTED rather than widened. + * + * @module + * @internal + */ + +/** Whether a version negotiates the extension. @internal */ +export function supported(version: IetfVersion): boolean { + return version >= Version.DRAFT_17; +} + +/** + * What the peer declared: undefined for no option, otherwise whether it offered the + * extension. Only an explicit 1 negotiates it. + * + * @internal + */ +export function fromSetup(params: SetupOptions, version: IetfVersion): boolean | undefined { + if (!supported(version)) return undefined; + const value = params.getVarint(SetupOption.Auth); + return value === undefined ? undefined : value === 1n; +} + +/** Offer the extension, on the versions that negotiate it. @internal */ +export function intoSetup(params: SetupOptions, version: IetfVersion) { + if (supported(version)) params.setVarint(SetupOption.Auth, 1n); +} + +/** The REQUEST_ERROR codes AUTH_ERROR carries. */ +const UNAUTHORIZED = 0x1; +const NOT_SUPPORTED = 0x3; + +/** Longest AUTH_ERROR reason, in bytes, matching the Rust decoder. */ +const MAX_REASON = 8192; + +function guard(version: IetfVersion) { + if (!supported(version)) throw new Error("auth not supported for this version"); +} + +/** + * AUTH: the first message on an Auth request stream, presenting a token. Each message + * here encodes its own type, which the dispatcher consumes before decoding. + * + * @internal + */ +export class AuthMessage { + static id = 0x40b61; + + requestId: bigint; + token: Uint8Array; + + constructor(requestId: bigint, token: Uint8Array) { + this.requestId = requestId; + this.token = token; + } + + async encode(w: Writer, version: IetfVersion): Promise { + guard(version); + return Message.encode( + w, + async (wr) => { + await wr.u62(this.requestId); + await wr.u53(this.token.byteLength); + if (this.token.byteLength > 0) await wr.write(this.token); + }, + AuthMessage.id, + ); + } + + static async decode(r: Reader, version: IetfVersion): Promise { + guard(version); + return Message.decode(r, async (rd) => { + const requestId = await rd.u62(); + const size = await rd.u53(); + return new AuthMessage(requestId, await rd.read(size)); + }); + } +} + +/** Each pattern as a namespace prefix, or throw {@link Unsupported} for one that is not a subtree. */ +function prefixes(patterns: Path.Patterns): Path.Valid[] { + return patterns.toArray().map((pattern) => { + const prefix = pattern.asPrefix(); + if (prefix === undefined) throw new Unsupported(`grant not representable as namespace prefixes: ${pattern}`); + return prefix as Path.Valid; + }); +} + +async function encodePrefixes(w: Writer, list: Path.Valid[]) { + await w.u53(list.length); + for (const prefix of list) await Namespace.encode(w, prefix); +} + +async function decodePrefixes(r: Reader): Promise { + const count = await r.u53(); + const patterns = new Path.Patterns(); + for (let i = 0; i < count; i++) { + patterns.insert(Path.Pattern.subtree(await Namespace.decode(r))); + } + return patterns; +} + +/** AUTH_OK: the grant a token earns, replacing any earlier one on the stream. @internal */ +export class AuthOk { + static id = 0x40b62; + + publish: Path.Patterns; + subscribe: Path.Patterns; + /** Milliseconds until the grant lapses, or undefined for never. */ + expires?: number; + + constructor(publish: Path.Patterns, subscribe: Path.Patterns, expires?: number) { + this.publish = publish; + this.subscribe = subscribe; + this.expires = expires; + } + + async encode(w: Writer, version: IetfVersion): Promise { + guard(version); + // Resolved before anything is written, so an unrepresentable grant leaves nothing + // half-sent. + const publish = prefixes(this.publish); + const subscribe = prefixes(this.subscribe); + return Message.encode( + w, + async (wr) => { + await encodePrefixes(wr, publish); + await encodePrefixes(wr, subscribe); + // 0 means never, so a lapsed grant rounds up to the smallest real expiry. + const expires = + this.expires === undefined + ? 0 + : Math.min(Math.max(Math.ceil(this.expires), 1), Number.MAX_SAFE_INTEGER); + await wr.u53(expires); + }, + AuthOk.id, + ); + } + + static async decode(r: Reader, version: IetfVersion): Promise { + guard(version); + return Message.decode(r, async (rd) => { + const publish = await decodePrefixes(rd); + const subscribe = await decodePrefixes(rd); + const expires = await rd.u53(); + return new AuthOk(publish, subscribe, expires === 0 ? undefined : expires); + }); + } +} + +/** AUTH_ERROR: the acceptor refusing a token, or revoking it after an AUTH_OK. @internal */ +export class AuthError { + static id = 0x40b63; + + /** A code from the REQUEST_ERROR registry. */ + code: number; + reason: string; + + constructor(code: number, reason: string) { + this.code = code; + this.reason = reason; + } + + async encode(w: Writer, version: IetfVersion): Promise { + guard(version); + if (new TextEncoder().encode(this.reason).byteLength > MAX_REASON) { + throw new Error("AUTH_ERROR reason exceeds 8,192 bytes"); + } + return Message.encode( + w, + async (wr) => { + await wr.u53(this.code); + await wr.string(this.reason); + }, + AuthError.id, + ); + } + + static async decode(r: Reader, version: IetfVersion): Promise { + guard(version); + return Message.decode(r, async (rd) => { + const code = await rd.u53(); + const reason = await rd.string(); + if (new TextEncoder().encode(reason).byteLength > MAX_REASON) { + throw new Error("AUTH_ERROR reason exceeds 8,192 bytes"); + } + return new AuthError(code, reason); + }); + } + + /** What the refusal means to the presenter: NOT_SUPPORTED is a grant it could not tell. */ + toError(): Error { + if (this.code === NOT_SUPPORTED) return new Unsupported(this.reason); + return new SessionError(SessionCode.Unauthorized, { reason: this.reason }); + } +} + +/** The moq-transport binding of the token lifecycle. @internal */ +export class IetfAuthWire implements AuthWire { + #openBi: () => Promise; + #nextRequestId: () => Promise; + #version: IetfVersion; + + constructor(props: { + openBi: () => Promise; + nextRequestId: () => Promise; + version: IetfVersion; + }) { + this.#openBi = props.openBi; + this.#nextRequestId = props.nextRequestId; + this.#version = props.version; + } + + async present(token: Uint8Array): Promise { + const requestId = await this.#nextRequestId(); + if (requestId === undefined) throw new Error("no request id available"); + const stream = await this.#openBi(); + await new AuthMessage(requestId, token).encode(stream.writer, this.#version); + return stream; + } + + async read(stream: Stream): Promise { + if (await stream.reader.done()) return undefined; + const id = await stream.reader.u53(); + switch (id) { + case AuthOk.id: { + const ok = await AuthOk.decode(stream.reader, this.#version); + return { grant: { publish: ok.publish, subscribe: ok.subscribe, expires: ok.expires } }; + } + case AuthError.id: { + const err = await AuthError.decode(stream.reader, this.#version); + return { refused: err.toError() }; + } + default: + throw new Error(`unexpected message on an auth request: 0x${id.toString(16)}`); + } + } + + /** The type id is already consumed by the dispatcher. */ + async accept(stream: Stream): Promise { + const msg = await AuthMessage.decode(stream.reader, this.#version); + return msg.token; + } + + async grant(stream: Stream, grant: WireGrant): Promise { + await new AuthOk(grant.publish, grant.subscribe, grant.expires).encode(stream.writer, this.#version); + } + + async refuse(stream: Stream, code: SessionCode, reason: string): Promise { + // The public API speaks session codes; this registry distinguishes only a version mismatch. + const wire = code === SessionCode.Version ? NOT_SUPPORTED : UNAUTHORIZED; + await new AuthError(wire, reason).encode(stream.writer, this.#version); + } + + async unsupported(stream: Stream, reason: string): Promise { + await new AuthError(NOT_SUPPORTED, reason).encode(stream.writer, this.#version); + await stream.writer.close(); + } +} diff --git a/js/net/src/ietf/connection.ts b/js/net/src/ietf/connection.ts index 7ceb9f99dc..989dd762f9 100644 --- a/js/net/src/ietf/connection.ts +++ b/js/net/src/ietf/connection.ts @@ -1,15 +1,17 @@ import { type Getter, Signal } from "@moq/signals"; import type * as announce from "../announced.ts"; -import * as Auth from "../auth.ts"; +import type * as Auth from "../auth.ts"; +import { AuthSession } from "../auth_session.ts"; import type { Established } from "../connection/established.ts"; import { type Probe, type Stats, transportStats } from "../connection/stats.ts"; import { type Transport, transportOf } from "../connection/transport.ts"; import { error, fromClose, ProtocolViolation, StreamCode, StreamError } from "../error.ts"; import type { Consumer as OriginConsumer } from "../origin.ts"; -import type * as Path from "../path.ts"; +import * as Path from "../path.ts"; import { type Reader, Readers, type Stream } from "../stream.ts"; import { registerWire } from "../wire.ts"; import { ControlStreamAdapter, NativeSession, type Session } from "./adapter.ts"; +import { AuthMessage, supported as authSupported, IetfAuthWire } from "./auth.ts"; import * as Cluster from "./cluster.ts"; import { GoAway } from "./goaway.ts"; import { Group } from "./object.ts"; @@ -43,8 +45,17 @@ export class Connection implements Established { /** moq-transport has no PROBE, so this stays empty; see {@link Established.probe}. */ readonly probe: Getter = new Signal({}); - /** moq-transport carries no AUTH exchange yet; see {@link Established.auth}. */ - readonly auth: Auth.Auth = new Auth.None(); + /** Our tokens and grants, when the peer negotiated MoQ Auth; see {@link Established.auth}. */ + get auth(): Auth.Auth { + return this.#auth; + } + + // Our tokens and grants, and the answers to the peer's (MoQ Auth). + #auth: AuthSession; + + // Whether this peer initiated the session; only the dialing side fails loud on a + // publication its grant does not cover. + #client: boolean; // The established WebTransport session. #quic: WebTransport; @@ -91,6 +102,7 @@ export class Connection implements Established { solicit, hidden = false, cluster, + auth = false, }: { url: URL; quic: WebTransport; @@ -114,6 +126,8 @@ export class Connection implements Established { * cannot negotiate the extension, as is a `peer` the peer never declared. */ cluster?: Cluster.Hops; + /** Whether the peer's SETUP offered MoQ Auth (draft-17+). */ + auth?: boolean; }) { this.url = url; this.discovery = discovery; @@ -136,16 +150,37 @@ export class Connection implements Established { }); } + // What the peer's connection credential earns by default: publishing anything to us, + // since we consume on demand, and subscribing to whatever we publish. + const session = this.#session; + this.#auth = new AuthSession({ + wire: + auth && authSupported(version) + ? new IetfAuthWire({ + openBi: async () => session.openBi(), + nextRequestId: () => session.nextRequestId(), + version, + }) + : undefined, + peerGrant: { + publish: new Path.Patterns([Path.Pattern.all()]), + subscribe: new Path.Patterns(publish ? [Path.Pattern.all()] : []), + }, + }); + this.#client = client; + this.#publisher = new Publisher({ quic: this.#quic, session: this.#session, publish, requiresSolicitation: solicit ?? false, cluster, + grant: this.#auth.grant, + ready: this.#auth.setupAnswered(), }); this.#solicit = solicit; this.#cluster = cluster; - this.#subscriber = new Subscriber({ session: this.#session, cluster, hidden }); + this.#subscriber = new Subscriber({ session: this.#session, cluster, hidden, grant: this.#auth.grant }); registerWire(this, { consume: (path) => this.#subscriber.consume(path) }); void this.#run(); @@ -164,6 +199,7 @@ export class Connection implements Established { this.#closed = true; + this.#auth.close(); this.#session.close(); try { @@ -175,7 +211,11 @@ export class Connection implements Established { async #run(): Promise { try { - await Promise.all([this.#runBidis(), this.#runUnis(), this.#publisher.runPublishNamespaces()]); + const tasks = [this.#runBidis(), this.#runUnis(), this.#publisher.runPublishNamespaces()]; + // Fail loud on a publication our grant never covers, once the peer has answered the + // credential we presented at setup. + if (this.#client) tasks.push(this.#publisher.runEnforce(this.#auth.setupAnswered())); + await Promise.all(tasks); } catch (err) { if (!this.#closed) { console.error("fatal error running connection", err); @@ -281,6 +321,12 @@ export class Connection implements Established { await this.#subscriber.runPublishNamespace(msg, stream); break; } + case AuthMessage.id: { + // Only a peer that negotiated MoQ Auth may send one. + if (!this.#auth.negotiated) throw new ProtocolViolation("AUTH without MoQ Auth"); + await this.#auth.serve(stream); + break; + } case Publish.id: { const msg = await Publish.decode(stream.reader, this.#session.version); await this.#subscriber.runPublish(msg, stream); diff --git a/js/net/src/ietf/index.ts b/js/net/src/ietf/index.ts index e1ebb8199d..f430591246 100644 --- a/js/net/src/ietf/index.ts +++ b/js/net/src/ietf/index.ts @@ -1,4 +1,5 @@ export * from "./adapter.ts"; +export * as Auth from "./auth.ts"; export * as Cluster from "./cluster.ts"; export * from "./connection.ts"; export * from "./control.ts"; diff --git a/js/net/src/ietf/message.ts b/js/net/src/ietf/message.ts index a767ddd413..06be4faacd 100644 --- a/js/net/src/ietf/message.ts +++ b/js/net/src/ietf/message.ts @@ -1,7 +1,8 @@ import { Reader, Writer } from "../stream.ts"; -// Encodes a message with a u16 (16-bit) size prefix as per draft-14. -export async function encode(writer: Writer, f: (w: Writer) => Promise) { +// Encodes a message with a u16 (16-bit) size prefix as per draft-14. A type `id` is written +// only once the body fits, so an oversized message leaves nothing on the stream. +export async function encode(writer: Writer, f: (w: Writer) => Promise, id?: number) { let scratch = new Uint8Array(); const temp = new Writer( @@ -44,6 +45,8 @@ export async function encode(writer: Writer, f: (w: Writer) => Promise) { throw new Error(`Message too large: ${scratch.byteLength} bytes (max 65535)`); } + if (id !== undefined) await writer.u53(id); + // Write u16 size (2 bytes, big-endian) await writer.u16(scratch.byteLength); await writer.write(scratch); diff --git a/js/net/src/ietf/parameters.ts b/js/net/src/ietf/parameters.ts index f2a6a3120b..65b1c65047 100644 --- a/js/net/src/ietf/parameters.ts +++ b/js/net/src/ietf/parameters.ts @@ -18,6 +18,8 @@ export const SetupOption = { Solicit: 0x40b5an, /** HIDDEN, from the MoQ Hidden extension. See `hidden.ts`. */ Hidden: 0x40b5cn, + /** AUTH, from the MoQ Auth extension. See `auth.ts`. */ + Auth: 0x40b60n, } as const; /// Setup Options — used in SETUP messages. diff --git a/js/net/src/ietf/publisher.ts b/js/net/src/ietf/publisher.ts index 2cdd94bf57..6413cc0d0d 100644 --- a/js/net/src/ietf/publisher.ts +++ b/js/net/src/ietf/publisher.ts @@ -1,4 +1,6 @@ -import { type Dispose, type Getter, Signal } from "@moq/signals"; +import { type Dispose, type Getter, race, Signal } from "@moq/signals"; +import type { Grant } from "../auth.ts"; +import { enforceGrant } from "../auth_session.ts"; import type * as broadcast from "../broadcast.ts"; import { controlTimeout, error, reason, StreamCode, StreamError } from "../error.ts"; import type * as group from "../group.ts"; @@ -55,6 +57,7 @@ function sameAdvert(a: Advertised | undefined, b: Advertised | undefined): boole /** PUBLISH_DONE statuses this implementation emits. Stable across drafts 14 through 19. */ const PUBLISH_DONE_STATUS = { INTERNAL_ERROR: 0x0, + UNAUTHORIZED: 0x1, TRACK_ENDED: 0x2, } as const; @@ -166,6 +169,15 @@ export class Publisher { // back came from us. `undefined` when nothing negotiated it. #advert?: Cluster.Advert; + // Our grant (MoQ Auth): only what it lets us publish is advertised and served, and a + // shrink withdraws what it no longer covers. Undefined until the peer answers, which + // allows everything. + #grant: Getter; + + // Resolves once the tokens this session presented at setup are answered, so nothing is + // advertised before the grant it would be checked against. + #ready: Promise; + /** * Creates a new Publisher instance. * @@ -177,6 +189,8 @@ export class Publisher { publish, requiresSolicitation, cluster, + grant = new Signal(undefined), + ready = Promise.resolve(), }: { /** The WebTransport session, for uni streams. */ quic: WebTransport; @@ -188,8 +202,14 @@ export class Publisher { requiresSolicitation: boolean; /** The Hop IDs the SETUP exchange settled (MoQ Cluster). */ cluster?: Cluster.Hops; + /** The union of our tokens' grants (MoQ Auth), which bounds what we publish. */ + grant?: Getter; + /** Resolves once the setup tokens are answered (MoQ Auth). */ + ready?: Promise; }) { this.#quic = quic; + this.#grant = grant; + this.#ready = ready; this.#session = session; const origin = publish && wireOf(publish); this.#advertised = origin?.advertised ?? new Signal(new Map()); @@ -210,9 +230,19 @@ export class Publisher { let broadcast: broadcast.Consumer | undefined; let refusal: { errorCode: number; reasonPhrase: string } | undefined; try { + // Serve only what our grant lets us publish. Checked before resolving, so a denied + // request never reaches the origin. + if (this.#denied(name)) { + refusal = { + errorCode: toRequestCode("unauthorized", "subscribe", version), + reasonPhrase: "not granted", + }; + } broadcast = - this.#publish && (wireOf(this.#publish).local(name) ?? (await wireOf(this.#publish).demand(name))); - if (!broadcast) { + !refusal && this.#publish + ? (wireOf(this.#publish).local(name) ?? (await wireOf(this.#publish).demand(name))) + : undefined; + if (!broadcast && !refusal) { refusal = { errorCode: toRequestCode("does_not_exist", "subscribe", version), reasonPhrase: "broadcast not found", @@ -392,11 +422,30 @@ export class Publisher { }) : Promise.resolve(); + // Losing the grant ends the subscription, leaving the session alone. + let revoke!: () => void; + const revoked = new Promise<"revoked">((resolve) => { + revoke = () => resolve("revoked"); + }); + const disposeGrant = this.#grant.subscribe(() => { + if (this.#denied(name)) revoke(); + }); + // The grant may have shrunk during setup, before this watcher existed. + if (this.#denied(name)) revoke(); + let publishError: Error | undefined; + let unauthorized = false; try { - await Promise.race([Promise.all([serving, filling]), stream.reader.closed]); + const end = await race([Promise.all([serving, filling]), stream.reader.closed, revoked]); + if (end === "revoked") { + console.info(`subscription no longer authorized: broadcast=${name} track=${track.name}`); + unauthorized = true; + unsubscribe(); + } } catch (err: unknown) { publishError = error(err); + } finally { + disposeGrant(); } console.debug(`publish done: broadcast=${name} track=${track.name}`); @@ -413,8 +462,12 @@ export class Publisher { version === Version.DRAFT_14 || version === Version.DRAFT_15 || version === Version.DRAFT_16 ? msg.requestId : undefined, - statusCode: publishError ? PUBLISH_DONE_STATUS.INTERNAL_ERROR : PUBLISH_DONE_STATUS.TRACK_ENDED, - reasonPhrase: publishError ? "internal error" : "track ended", + statusCode: unauthorized + ? PUBLISH_DONE_STATUS.UNAUTHORIZED + : publishError + ? PUBLISH_DONE_STATUS.INTERNAL_ERROR + : PUBLISH_DONE_STATUS.TRACK_ENDED, + reasonPhrase: unauthorized ? "not granted" : publishError ? "internal error" : "track ended", }); await done.encode(stream.writer, version); } catch { @@ -495,7 +548,7 @@ export class Publisher { // Reading from the filter's start drops the objects below it: they are outside // the requested range, so skipping them is not a gap. - const read = await Promise.race([hooks.readGroupFrame(group, slice.skip), stream.closed]); + const read = await race([hooks.readGroupFrame(group, slice.skip), stream.closed]); if (!read) break; next = read.sequence + 1; if (slice.until !== undefined && read.sequence >= slice.until) { @@ -608,11 +661,7 @@ export class Publisher { if (fill.until !== undefined && next >= fill.until) break; // Reading from the fill's start drops everything below it; see the same read in #runGroup. - const frame = await Promise.race([ - group.readFrameSequence({ from: Number(fill.skip) }), - stream.closed, - cancelled, - ]); + const frame = await race([group.readFrameSequence({ from: Number(fill.skip) }), stream.closed, cancelled]); if (left) throw new Error("unsubscribed before the fill finished"); if (!frame) break; next = BigInt(frame.sequence) + 1n; @@ -629,6 +678,22 @@ export class Publisher { } } + // Whether our grant excludes publishing this broadcast. No grant yet allows it. + #denied(broadcast: Path.Valid): boolean { + const grant = this.#grant.peek(); + return grant !== undefined && !grant.publish.matches(broadcast); + } + + /** + * Close the session when our origin publishes a broadcast our grant does not cover; + * see {@link enforceGrant}. + * + * @internal + */ + async runEnforce(setupAnswered: Promise): Promise { + await enforceGrant({ quic: this.#quic, advertised: this.#advertised, grant: this.#grant, setupAnswered }); + } + /** * Handles an incoming SUBSCRIBE_NAMESPACE on a bidi stream. * @@ -667,7 +732,11 @@ export class Publisher { // from the empty prefix unasked, so this stream carries only what that hid. const carries = (covered: Path.Valid) => (msg.hidden || !hiddenBelow(prefix, covered)) && - (this.#requiresSolicitation || hiddenBelow(Path.empty(), covered)); + (this.#requiresSolicitation || hiddenBelow(Path.empty(), covered)) && + !this.#denied(covered); + + // Nothing is advertised before the grant it would be checked against. + await this.#ready; // Reports whether the peer now holds the namespace: an inline entry always // lands, but a PUBLISH_NAMESPACE request can be declined. @@ -705,8 +774,15 @@ export class Publisher { // waits for its reply only notifies listeners already registered. // TODO Make a better helper within Signals. let dispose!: Dispose; - const changed = new Promise | undefined>((resolve) => { - dispose = this.#advertised.changed(resolve); + const changed = new Promise<"changed">((resolve) => { + const table = this.#advertised.changed(() => resolve("changed")); + // A grant change re-diffs the same way, withdrawing what it no longer covers. + // The loop top re-reads the table, so an ended origin still stops it there. + const grant = this.#grant.changed(() => resolve("changed")); + dispose = () => { + table(); + grant(); + }; }); const advertised = this.#advertised.peek(); @@ -767,8 +843,8 @@ export class Publisher { // Wait for the next change, or for the peer to unsubscribe. const next = await (retry - ? Promise.race([changed, stream.reader.closed, retryAfter(retry).then(() => advertised)]) - : Promise.race([changed, stream.reader.closed])); + ? race([changed, stream.reader.closed, retryAfter(retry).then(() => advertised)]) + : race([changed, stream.reader.closed])); dispose(); if (!next) break; } @@ -819,6 +895,9 @@ export class Publisher { let dispose: Dispose | undefined; try { + // Nothing is advertised before the grant it would be checked against. + if ((await Promise.race([this.#ready.then(() => "ready" as const), closed])) !== "ready") return; + // What the peer holds: keyed by path, valued by identity plus route, so a // republish diffs as withdraw-then-advertise rather than nothing. let active = new Map(); @@ -835,8 +914,15 @@ export class Publisher { // through it and leave the namespace unadvertised until something unrelated // changed. // TODO Make a better helper within Signals. - const changed = new Promise | undefined>((resolve) => { - dispose = this.#advertised.changed(resolve); + const changed = new Promise<"changed">((resolve) => { + const table = this.#advertised.changed(() => resolve("changed")); + // A grant change re-diffs the same way, withdrawing what it no longer covers. + // The loop top re-reads the table, so an ended origin still stops it there. + const grant = this.#grant.changed(() => resolve("changed")); + dispose = () => { + table(); + grant(); + }; }); const advertised = this.#advertised.peek(); @@ -847,8 +933,9 @@ export class Publisher { const updated = new Map(); for (const [covered, snap] of advertised) { - // Unasked, a hidden namespace stays off the wire (MoQ Hidden). - if (hiddenBelow(Path.empty(), covered)) continue; + // Unasked, a hidden namespace stays off the wire (MoQ Hidden), and nothing + // our grant does not cover reaches it at all (MoQ Auth). + if (hiddenBelow(Path.empty(), covered) || this.#denied(covered)) continue; updated.set(covered, snap); } @@ -895,8 +982,8 @@ export class Publisher { // Wait for the next change, which has already fired if one landed above. const next = await (retry - ? Promise.race([changed, closed, retryAfter(retry).then(() => advertised)]) - : Promise.race([changed, closed])); + ? race([changed, closed, retryAfter(retry).then(() => advertised)]) + : race([changed, closed])); dispose?.(); if (!next) break; } diff --git a/js/net/src/ietf/subscriber.ts b/js/net/src/ietf/subscriber.ts index e95246d2f3..754ec3a6ca 100644 --- a/js/net/src/ietf/subscriber.ts +++ b/js/net/src/ietf/subscriber.ts @@ -1,8 +1,9 @@ -import { Signal } from "@moq/signals"; +import { type Dispose, type Getter, race, Signal } from "@moq/signals"; import * as announce from "../announced.ts"; +import type { Grant } from "../auth.ts"; import * as broadcast from "../broadcast.ts"; import { BroadcastCache } from "../consume.ts"; -import { controlTimeout, error, ProtocolViolation, reason } from "../error.ts"; +import { controlTimeout, error, ProtocolViolation, reason, SessionCode, SessionError } from "../error.ts"; import * as netGroup from "../group.ts"; import { Cost, type Route, routesEqual, UNKNOWN_HOP } from "../hop.ts"; import { hiddenBelow, hooks, scopeCaptures, scopeHead, scopeOverlaps } from "../internal.ts"; @@ -123,6 +124,10 @@ export class Subscriber { // Whether the peer understands the HIDDEN parameter (MoQ Hidden). #hidden: boolean; + // Our grant (MoQ Auth): a subscription it stops covering is cancelled. Undefined until + // the peer answers, which allows everything. + #grant?: Getter; + /** * Creates a new Subscriber instance. * @@ -132,6 +137,7 @@ export class Subscriber { session, cluster, hidden = false, + grant, }: { /** The session abstraction for bidi streams and request IDs. */ session: Session; @@ -139,10 +145,19 @@ export class Subscriber { cluster?: Cluster.Hops; /** Whether the peer understands the HIDDEN parameter (MoQ Hidden). */ hidden?: boolean; + /** The union of our tokens' grants (MoQ Auth), which bounds what we subscribe to. */ + grant?: Getter; }) { this.#session = session; this.#cluster = cluster; this.#hidden = hidden; + this.#grant = grant; + } + + // Whether our grant no longer lets us subscribe to `broadcast`. No grant yet allows it. + #denied(broadcast: Path.Valid): boolean { + const grant = this.#grant?.peek(); + return grant !== undefined && !grant.subscribe.matches(broadcast); } /** @@ -396,7 +411,7 @@ export class Subscriber { }); // Wait for either the read loop or the announced to close - await Promise.race([readLoop, announced.closed]); + await race([readLoop, announced.closed]); // For v14/v15: send UnsubscribeNamespace before closing if (version === Version.DRAFT_14 || version === Version.DRAFT_15) { @@ -470,6 +485,12 @@ export class Subscriber { } async #runSubscribe(broadcast: Path.Valid, request: track.Request) { + const unauthorized = new SessionError(SessionCode.Unauthorized, { reason: broadcast }); + if (this.#denied(broadcast)) { + request.reject(unauthorized); + return; + } + const requestId = await this.#session.nextRequestId(); if (requestId === undefined) { request.reject(new Error("session closed")); @@ -506,7 +527,7 @@ export class Subscriber { let stream: Stream; let trackAlias: bigint; try { - const result = await Promise.race([ + const result = await race([ withTimeout( setup, SUBSCRIBE_OK_TIMEOUT_MS, @@ -568,18 +589,33 @@ export class Subscriber { return; } + let disposeGrant: Dispose | undefined; try { // Which terminal fired decides whether we owe the publisher a cancellation, so // tag them rather than racing bare promises. const publisherEnded = Symbol("publisher"); const localEnded = Symbol("local"); + const revokedEnded = Symbol("revoked"); const idle = Symbol("idle"); + // Losing the grant ends the subscription, leaving the session alone. + let revoke!: () => void; + const revoked = new Promise((resolve) => { + revoke = () => resolve(revokedEnded); + }); + disposeGrant = this.#grant?.subscribe(() => { + if (this.#denied(broadcast)) revoke(); + }); + // The grant may have shrunk during setup, before this watcher existed. + if (this.#denied(broadcast)) revoke(); + // Terminal conditions settle at most once (stream close = PublishDone, track close = - // local unsubscribe); race them once so the demand loop doesn't re-subscribe each pass. - const done = Promise.race([ + // local unsubscribe, a revoked grant); race them once so the demand loop doesn't + // re-subscribe each pass. + const done = race([ stream.reader.closed.then(() => publisherEnded), producer.closed.then(() => localEnded), + revoked, ]); // Serve until a terminal condition fires or the last local subscriber leaves. The unused @@ -587,7 +623,7 @@ export class Subscriber { // down resumes on the same stream. let terminal = localEnded; for (;;) { - const reason = await Promise.race([done, producer.unused().then(() => idle)]); + const reason = await race([done, producer.unused().then(() => idle)]); if (reason === idle && producer.closed.peek() === undefined && producer.used.peek()) continue; terminal = reason; break; @@ -597,7 +633,12 @@ export class Subscriber { // reopens the window the demand re-check above just closed, and a subscriber that // returned during it would be closed by this line. The lite subscriber closes // straight out of its loop for the same reason. - producer.close(); + if (terminal === revokedEnded) { + console.info(`subscription no longer authorized: broadcast=${broadcast} track=${request.name}`); + producer.close(unauthorized); + } else { + producer.close(); + } // The publisher already ended the request, so there is nothing to cancel. Sending // UNSUBSCRIBE here would name a request it has already torn down. @@ -613,6 +654,7 @@ export class Subscriber { `subscribe error: id=${requestId} broadcast=${broadcast} track=${request.name} error=${reason(e)}`, ); } finally { + disposeGrant?.(); // Only the owner tears down the alias metadata: a later subscription may have // reclaimed the alias and installed its own timescale. if (this.#aliases.retire(trackAlias, producer)) this.#timescales.delete(trackAlias); @@ -963,7 +1005,7 @@ export class Subscriber { track.writeGroup(producer); for (;;) { - const done = await Promise.race([stream.done(), producer.closed, track.closed]); + const done = await race([stream.done(), producer.closed, track.closed]); if (done !== false) break; const frame = await Frame.decode( diff --git a/js/net/src/lite/auth.test.ts b/js/net/src/lite/auth.test.ts index f1fb805ab1..77006495a3 100644 --- a/js/net/src/lite/auth.test.ts +++ b/js/net/src/lite/auth.test.ts @@ -1,25 +1,15 @@ import { expect, test } from "bun:test"; -import type { Getter } from "@moq/signals"; -import { type Grant, type Issued, Unsupported } from "../auth.ts"; -import { accept as acceptSession, connect as connectSession, type Established } from "../connection/index.ts"; -import { SessionCode, SessionError } from "../error.ts"; -import { createMockTransportPair, type MockTransport } from "../mock.ts"; -import { Producer as OriginProducer } from "../origin.ts"; +import { Unsupported } from "../auth.ts"; +import { SessionCode } from "../error.ts"; import * as Path from "../path.ts"; import { Reader, Writer } from "../stream.ts"; import { AuthError, AuthMessage, AuthOk, decodeAuthReplyMaybe, encodeAuthReply } from "./auth.ts"; import * as Lite from "./index.ts"; -const url = new URL("https://localhost:4443/test"); - function patterns(...prefixes: string[]): Path.Patterns { return new Path.Patterns(prefixes.map((prefix) => Path.Pattern.subtree(prefix))); } -function grant(publish: string[], subscribe: string[]): Grant { - return { publish: patterns(...publish), subscribe: patterns(...subscribe) }; -} - /** Round-trip bytes through a writer and back out of a reader. */ async function roundTrip(write: (w: Writer) => Promise): Promise { const chunks: Uint8Array[] = []; @@ -79,163 +69,3 @@ test("lite-05 carries no AUTH", async () => { roundTrip((w) => new AuthMessage(new Uint8Array()).encode(w, Lite.Version.DRAFT_05)), ).rejects.toThrow(); }); - -async function waitFor(getter: Getter, ready: (value: T) => boolean): Promise { - let value = getter.peek(); - while (!ready(value)) value = await getter.changed(); - return value; -} - -interface Pair { - client: Established; - server: Established; - transport: MockTransport; -} - -async function connect(opts: { publish?: OriginProducer; serverPublish?: OriginProducer; protocol?: string }) { - const pair = createMockTransportPair(opts.protocol ?? Lite.ALPN_06); - const [client, server] = await Promise.all([ - connectSession({ url, transport: pair.client, publish: opts.publish?.consume() }), - acceptSession({ transport: pair.server, url, publish: opts.serverPublish?.consume() }), - ]); - return { client, server, transport: pair.client } satisfies Pair; -} - -test("both sides learn their default grant", async () => { - const { client, server } = await connect({ publish: new OriginProducer() }); - - // The server consumes anything and publishes nothing. - const clientGrant = await waitFor(client.auth.grant, (g) => g !== undefined); - expect(clientGrant?.publish.equals(patterns(""))).toBe(true); - expect(clientGrant?.subscribe.size).toBe(0); - - // The client publishes, so the server may subscribe to anything. - const serverGrant = await waitFor(server.auth.grant, (g) => g !== undefined); - expect(serverGrant?.publish.equals(patterns(""))).toBe(true); - expect(serverGrant?.subscribe.equals(patterns(""))).toBe(true); - - client.close(); - server.close(); -}); - -test("a token without an acceptor reports unsupported", async () => { - const { client, server } = await connect({ publish: new OriginProducer() }); - await waitFor(client.auth.grant, (g) => g !== undefined); - await expect(client.auth.add("token")).rejects.toBeInstanceOf(Unsupported); - client.close(); - server.close(); -}); - -test("an out-of-scope broadcast closes the session and names the path", async () => { - const origin = new OriginProducer(); - const { client, server, transport } = await connect({ publish: origin }); - const requests = server.auth.requests(); - const issued: Issued[] = []; - void (async () => { - for (;;) { - const request = await requests.next(); - if (!request) break; - issued.push(request.accept(grant(["baz"], []))); - } - })(); - - await waitFor(client.auth.grant, (g) => g !== undefined); - origin.createBroadcast(Path.from("baz/ok")).announce(); - origin.createBroadcast(Path.from("foo/bar")).announce(); - - const info = await transport.closed; - expect(info.closeCode).toBe(SessionCode.Unauthorized); - expect(info.reason).toBe("unauthorized: foo/bar"); - server.close(); -}); - -test("a revoked grant withdraws its broadcasts without closing the session", async () => { - const origin = new OriginProducer(); - const { client, server, transport } = await connect({ publish: origin }); - const requests = server.auth.requests(); - const issued: Issued[] = []; - void (async () => { - for (;;) { - const request = await requests.next(); - if (!request) break; - issued.push(request.accept(grant(["a"], []))); - } - })(); - - await waitFor(client.auth.grant, (g) => g !== undefined); - origin.createBroadcast(Path.from("a/x")).announce(); - - const announced = server.announced(); - const first = await announced.next(); - expect(first?.prefix).toBe(Path.from("a/x")); - expect(first?.kind).toBe("announced"); - - issued[0]?.revoke(SessionCode.Unauthorized, "expired"); - const second = await announced.next(); - expect(second?.prefix).toBe(Path.from("a/x")); - expect(second?.kind).toBe("retracted"); - - // The union is empty but still a grant, and a new token restores it. - const empty = await waitFor(client.auth.grant, (g) => g !== undefined && g.publish.size === 0); - expect(empty?.subscribe.size).toBe(0); - const token = await client.auth.add("again"); - expect(token.grant.peek()?.publish.equals(patterns("a"))).toBe(true); - const third = await announced.next(); - expect(third?.kind).toBe("announced"); - - let closed = false; - void transport.closed.then(() => { - closed = true; - }); - await new Promise((resolve) => setTimeout(resolve, 10)); - expect(closed).toBe(false); - - announced.close(); - client.close(); - server.close(); -}); - -test("a refused token surfaces the acceptor's code and reason", async () => { - const { client, server } = await connect({ publish: new OriginProducer() }); - const requests = server.auth.requests(); - void (async () => { - for (;;) { - const request = await requests.next(); - if (!request) break; - if (request.token.byteLength === 0) request.accept(grant([], [])); - else request.reject(SessionCode.Unauthorized, "bad signature"); - } - })(); - - const err = await client.auth.add("forged").catch((e: unknown) => e); - expect(err).toBeInstanceOf(SessionError); - expect((err as SessionError).code).toBe(SessionCode.Unauthorized); - client.close(); - server.close(); -}); - -test("a refused setup token grants nothing rather than everything", async () => { - const { client, server } = await connect({ publish: new OriginProducer() }); - const requests = server.auth.requests(); - void (async () => { - for (;;) { - const request = await requests.next(); - if (!request) break; - request.reject(SessionCode.Unauthorized, "bad credential"); - } - })(); - - const empty = await waitFor(client.auth.grant, (g) => g !== undefined); - expect(empty?.publish.size).toBe(0); - expect(empty?.subscribe.size).toBe(0); - client.close(); - server.close(); -}); - -test("older versions have no grant", async () => { - const { client, server } = await connect({ publish: new OriginProducer(), protocol: Lite.ALPN_05 }); - expect(client.auth.grant.peek()).toBeUndefined(); - await expect(client.auth.add("token")).rejects.toBeInstanceOf(Unsupported); - client.close(); - server.close(); -}); diff --git a/js/net/src/lite/auth.ts b/js/net/src/lite/auth.ts index 747c9c3530..8a6bf2bd08 100644 --- a/js/net/src/lite/auth.ts +++ b/js/net/src/lite/auth.ts @@ -1,15 +1,6 @@ -import { type Getter, Signal } from "@moq/signals"; -import { - type Auth as AuthApi, - type Grant, - grantsEqual, - type Issued, - type Request, - type Requests, - type Token, - Unsupported, -} from "../auth.ts"; -import { error, SessionCode, SessionError, StreamCode, StreamError } from "../error.ts"; +import { Unsupported } from "../auth.ts"; +import type { AuthWire, WireGrant, WireReply } from "../auth_session.ts"; +import { type SessionCode, SessionError } from "../error.ts"; import * as Path from "../path.ts"; import { type Reader, Stream, type Writer } from "../stream.ts"; import * as Message from "./message.ts"; @@ -179,361 +170,48 @@ export async function decodeAuthReplyMaybe(r: Reader, version: Version): Promise } } -function union(grants: Iterable): Grant { - const publish = new Path.Patterns(); - const subscribe = new Path.Patterns(); - let expires: number | undefined; - for (const grant of grants) { - for (const pattern of grant.publish) publish.insert(pattern); - for (const pattern of grant.subscribe) subscribe.insert(pattern); - // The earliest expiry is when the union next shrinks. - if (grant.expires !== undefined) expires = Math.min(expires ?? grant.expires, grant.expires); - } - return { publish, subscribe, expires }; -} - -function cancel(): StreamError { - return new StreamError(StreamCode.Cancel, { message: "cancel" }); -} - -/** One token this side presented. */ -class Presented implements Token { - readonly grant = new Signal(undefined); - readonly closed: Promise; - readonly answered: Promise; - readonly setup: boolean; - readonly token: Uint8Array; - - stream?: Stream; - withdrawn = false; - isAnswered = false; - ended = false; - - #close!: (err: Error | null) => void; - #answer!: () => void; - #refuse!: (err: Error) => void; - - constructor(token: Uint8Array, setup: boolean) { - this.token = token; - this.setup = setup; - this.closed = new Promise((resolve) => { - this.#close = resolve; - }); - this.answered = new Promise((resolve, reject) => { - this.#answer = resolve; - this.#refuse = reject; - }); - // A caller that never awaits the answer must not see an unhandled rejection. - this.answered.catch(() => void 0); - } - - answer() { - if (this.isAnswered) return; - this.isAnswered = true; - this.#answer(); - } - - end(err: Error | null) { - if (this.ended) return; - this.ended = true; - this.grant.set(undefined); - if (!this.isAnswered) { - this.isAnswered = true; - this.#refuse(err ?? new Error("withdrawn")); - } - this.#close(err); - } - - close() { - if (this.withdrawn) return; - this.withdrawn = true; - // The stream's loop notices and ends the token; one still opening checks on arrival. - this.stream?.abort(cancel()); - } -} - -/** The peer's token, answered by the application. */ -class PeerRequest implements Request { - readonly token: Uint8Array; - #issued: IssuedGrant; - #answered = false; - - constructor(token: Uint8Array, issued: IssuedGrant) { - this.token = token; - this.#issued = issued; - } - - accept(grant: Grant): Issued { - if (this.#answered) throw new Error("already answered"); - this.#answered = true; - this.#issued.update(grant); - return this.#issued; - } - - reject(code: SessionCode, reason: string): void { - if (this.#answered) throw new Error("already answered"); - this.#answered = true; - this.#issued.revoke(code, reason); - } -} - -/** Our side of one of the peer's tokens: the grant we issued and its stream. */ -class IssuedGrant implements Issued { - readonly closed: Promise; - #stream: Stream; - #version: Version; - #writes = Promise.resolve(); - #done = false; - - constructor(stream: Stream, version: Version) { - this.#stream = stream; - this.#version = version; - // The presenter withdraws by closing or cancelling its side. - this.closed = stream.reader.closed.then( - () => null, - (err: unknown) => (err instanceof StreamError && err.code === StreamCode.Cancel ? null : error(err)), - ); - } - - #write(reply: AuthReply) { - this.#writes = this.#writes - .then(() => encodeAuthReply(this.#stream.writer, reply, this.#version)) - .catch((err: unknown) => { - // The peer already closed the stream: nothing left to tell it. - if (err instanceof StreamError) return; - // This wire carries prefixes only, so a pattern grant cannot be told, only - // withheld: reset the stream, which the presenter reads as unsupported rather - // than refused. Never widen it. Any other reply that fails to encode resets - // the same way. - if (!(err instanceof Unsupported)) console.warn("auth reply not sent", err); - this.#done = true; - this.#stream.writer.reset(err); - }); - } - - update(grant: Grant): void { - if (this.#done) return; - const expires = grant.expires === undefined ? undefined : grant.expires - Date.now(); - this.#write(new AuthOk(grant.publish, grant.subscribe, expires)); - } - - revoke(code: SessionCode, reason: string): void { - if (this.#done) return; - this.#write(new AuthError(code, reason)); - this.close(); - } - - close(): void { - if (this.#done) return; - this.#done = true; - this.#writes = this.#writes.then(() => this.#stream.writer.close()); - } -} - -/** The peer's tokens, queued for the application. */ -class RequestQueue implements Requests { - #queue: PeerRequest[] = []; - #waiters: ((request: PeerRequest | undefined) => void)[] = []; - #closed = false; - - push(request: PeerRequest): boolean { - if (this.#closed) return false; - const waiter = this.#waiters.shift(); - if (waiter) waiter(request); - else this.#queue.push(request); - return true; - } - - next(): Promise { - const next = this.#queue.shift(); - if (next || this.#closed) return Promise.resolve(next); - return new Promise((resolve) => this.#waiters.push(resolve)); - } - - close(): void { - this.#closed = true; - for (const request of this.#queue.splice(0)) { - request.reject(SessionCode.Unauthorized, "not accepting tokens"); - } - for (const waiter of this.#waiters.splice(0)) waiter(undefined); - } -} - -/** Constructor options for {@link AuthSession}. @internal */ -export interface AuthSessionProps { - quic: WebTransport; - version: Version; - /** What the default acceptor grants the peer's connection credential. */ - peerGrant: Grant; -} - -/** - * A lite session's tokens and grants: presents ours, one AUTH stream each, and answers - * the peer's. - * - * @internal - */ -export class AuthSession implements AuthApi { +/** The moq-lite binding of the token lifecycle: one Auth Stream per token. @internal */ +export class LiteAuthWire implements AuthWire { #quic: WebTransport; #version: Version; - #peerGrant: Grant; - - #union = new Signal(undefined); - // The peer replied to some token, so the union is known even when empty. - #replied = false; - #tokens = new Set(); - #setupPending = new Signal(0); - #acceptor: "undecided" | "default" | RequestQueue = "undecided"; - #closed = false; - - // Whoever answers the peer's tokens is decided once, after the task that established - // the session: an app that calls requests() as soon as connect/accept resolves always - // wins, however quickly the peer's first token arrives. - #decided = new Promise((resolve) => setTimeout(resolve, 0)); - - constructor({ quic, version, peerGrant }: AuthSessionProps) { + + constructor(quic: WebTransport, version: Version) { this.#quic = quic; this.#version = version; - this.#peerGrant = peerGrant; - - // Present the connection's own credential right away, so both sides learn their - // grant without waiting on the app. - if (hasAuth(version)) this.#present(new Uint8Array(), true); - } - - get grant(): Getter { - return this.#union; - } - - async add(token: string | Uint8Array): Promise { - if (!hasAuth(this.#version) || this.#closed) throw new Unsupported(); - const bytes = typeof token === "string" ? new TextEncoder().encode(token) : token; - const presented = this.#present(bytes, false); - await presented.answered; - return presented; - } - - requests(): Requests { - if (this.#acceptor !== "undecided") throw new Error("auth requests already taken or answered by default"); - const queue = new RequestQueue(); - if (!hasAuth(this.#version)) queue.close(); - this.#acceptor = queue; - return queue; } - /** Resolves once every token the session presented at setup has its first reply. */ - async setupAnswered(): Promise { - while (this.#setupPending.peek() > 0) await this.#setupPending.changed(); + async present(token: Uint8Array): Promise { + const stream = await Stream.open(this.#quic); + await stream.writer.u53(StreamId.Auth); + await new AuthMessage(token).encode(stream.writer, this.#version); + return stream; } - /** Answer one of the peer's AUTH streams, for the life of its token. */ - async serve(stream: Stream): Promise { - const msg = await AuthMessage.decode(stream.reader, this.#version); - await this.#decided; - if (this.#acceptor === "undecided") this.#acceptor = "default"; - - const issued = new IssuedGrant(stream, this.#version); - if (this.#acceptor instanceof RequestQueue) { - const request = new PeerRequest(msg.token, issued); - if (!this.#acceptor.push(request)) request.reject(SessionCode.Unauthorized, "not accepting tokens"); - } else if (msg.token.byteLength > 0) { - // Only the connection's own credential has a default answer. Resetting reads as - // unsupported to the presenter, the same as a peer that predates AUTH. - throw new Unsupported("no acceptor for tokens"); - } else { - issued.update(this.#peerGrant); + async read(stream: Stream): Promise { + const reply = await decodeAuthReplyMaybe(stream.reader, this.#version); + if (!reply) return undefined; + if (reply instanceof AuthOk) { + return { grant: { publish: reply.publish, subscribe: reply.subscribe, expires: reply.expires } }; } - - await issued.closed; - issued.close(); - } - - /** End the session: fail every pending token and close the requests. */ - close() { - if (this.#closed) return; - this.#closed = true; - for (const token of this.#tokens) token.end(new Error("session closed")); - this.#tokens.clear(); - if (this.#acceptor instanceof RequestQueue) this.#acceptor.close(); + return { refused: new SessionError(reply.code as SessionCode, { reason: reply.reason }) }; } - #present(token: Uint8Array, setup: boolean): Presented { - const presented = new Presented(token, setup); - this.#tokens.add(presented); - if (setup) this.#setupPending.update((n) => n + 1); - void this.#run(presented); - return presented; + /** The stream type is already consumed by the dispatcher. */ + async accept(stream: Stream): Promise { + const msg = await AuthMessage.decode(stream.reader, this.#version); + return msg.token; } - async #run(token: Presented) { - let result: Error | null = null; - try { - const stream = await Stream.open(this.#quic); - token.stream = stream; - if (token.withdrawn) throw cancel(); - - await stream.writer.u53(StreamId.Auth); - await new AuthMessage(token.token).encode(stream.writer, this.#version); - - for (;;) { - const reply = await decodeAuthReplyMaybe(stream.reader, this.#version); - if (!reply) { - // The peer ended the grant without revoking it, or closed without ever - // answering. - result = token.isAnswered ? null : new Unsupported(); - break; - } - if (reply instanceof AuthOk) { - const expires = reply.expires === undefined ? undefined : Date.now() + reply.expires; - token.grant.set({ publish: reply.publish, subscribe: reply.subscribe, expires }); - this.#replied = true; - this.#answered(token); - this.#recompute(); - continue; - } - console.warn(`auth token refused: code=${reply.code} reason=${reply.reason}`); - // A refused setup token leaves an empty union, not an unknown (unrestricted) one. - this.#replied = true; - result = new SessionError(reply.code as SessionCode, { reason: reply.reason }); - stream.close(); - break; - } - } catch (err: unknown) { - if (token.withdrawn) { - result = null; - } else if (!token.isAnswered && err instanceof StreamError) { - // A peer that predates AUTH resets a stream type it does not know. - result = new Unsupported(); - } else { - result = error(err); - if (!this.#closed) console.warn("auth token ended", result); - } - } - - // A token that ends unanswered counts as answered for enforcement: its refusal is - // the reply. - const unanswered = !token.isAnswered; - token.end(result); - if (unanswered && token.setup) this.#setupPending.update((n) => n - 1); - this.#tokens.delete(token); - this.#recompute(); + async grant(stream: Stream, grant: WireGrant): Promise { + await encodeAuthReply(stream.writer, new AuthOk(grant.publish, grant.subscribe, grant.expires), this.#version); } - #answered(token: Presented) { - if (token.isAnswered) return; - token.answer(); - if (token.setup) this.#setupPending.update((n) => n - 1); + async refuse(stream: Stream, code: SessionCode, reason: string): Promise { + await encodeAuthReply(stream.writer, new AuthError(code, reason), this.#version); } - #recompute() { - const granted: Grant[] = []; - for (const token of this.#tokens) { - const grant = token.grant.peek(); - if (grant) granted.push(grant); - } - // Undefined until the first reply; an empty union afterwards grants nothing. - if (granted.length === 0 && !this.#replied) return; - const next = union(granted); - if (!grantsEqual(next, this.#union.peek())) this.#union.set(next); + /** Resetting reads as unsupported to the presenter, the same as a peer that predates AUTH. */ + async unsupported(stream: Stream, reason: string): Promise { + stream.writer.reset(new Unsupported(reason)); } } diff --git a/js/net/src/lite/connection.ts b/js/net/src/lite/connection.ts index 38964015ed..be9a90c508 100644 --- a/js/net/src/lite/connection.ts +++ b/js/net/src/lite/connection.ts @@ -1,6 +1,7 @@ import { type Getter, Signal } from "@moq/signals"; import type * as announce from "../announced.ts"; import type * as Auth from "../auth.ts"; +import { AuthSession } from "../auth_session.ts"; import type { Established } from "../connection/established.ts"; import { type Probe, type Stats, transportStats } from "../connection/stats.ts"; import { type Transport, transportOf } from "../connection/transport.ts"; @@ -11,7 +12,7 @@ import * as Path from "../path.ts"; import { type Reader, Readers, Stream, Writer } from "../stream.ts"; import { registerWire } from "../wire.ts"; import { AnnounceRequest } from "./announce.ts"; -import { AuthSession } from "./auth.ts"; +import { LiteAuthWire } from "./auth.ts"; import { Fetch } from "./fetch.ts"; import { Goaway } from "./goaway.ts"; import { Group } from "./group.ts"; @@ -141,8 +142,7 @@ export class Connection implements Established { // What the peer's connection credential earns by default: publishing anything to us, // since we consume on demand, and subscribing to whatever we publish. this.#auth = new AuthSession({ - quic, - version, + wire: hasAuth(version) ? new LiteAuthWire(quic, version) : undefined, peerGrant: { publish: new Path.Patterns([Path.Pattern.all()]), subscribe: new Path.Patterns(publish ? [Path.Pattern.all()] : []), diff --git a/js/net/src/lite/publisher.ts b/js/net/src/lite/publisher.ts index 9c453cddd9..ed7a7a6d4e 100644 --- a/js/net/src/lite/publisher.ts +++ b/js/net/src/lite/publisher.ts @@ -1,7 +1,8 @@ -import { type Dispose, type Getter, Signal } from "@moq/signals"; +import { type Dispose, type Getter, race, Signal } from "@moq/signals"; import type { Grant } from "../auth.ts"; +import { enforceGrant } from "../auth_session.ts"; import type * as broadcast from "../broadcast.ts"; -import { closeReason, error, NotFound, reason, SessionCode, SessionError, StreamCode, StreamError } from "../error.ts"; +import { error, NotFound, reason, SessionCode, SessionError, StreamCode, StreamError } from "../error.ts"; import type * as group from "../group.ts"; import { type Hop, type Route, routesEqual } from "../hop.ts"; import { hiddenBelow, hooks } from "../internal.ts"; @@ -226,19 +227,23 @@ class SubscriptionControls { /** Returns false when peer departure supersedes a blocked response write. */ async response(pending: Promise): Promise { - const result = await Promise.race([ + // `#ended` lives as long as the stream, so it is raced as-is rather than mapped per call. + const result = await race([ pending.then( () => ({ kind: "sent" }) as const, (err: unknown) => ({ kind: "error", error: error(err) }) as const, ), - this.#ended.then((end) => ({ kind: "ended", end }) as const), + this.#ended, ]); - if (result.kind === "sent") return true; - if (result.kind === "error") throw result.error; - // Promise.race leaves the blocked encode running, so reset the writable half too. - this.#writer.reset(result.end ?? new StreamError(StreamCode.Cancel, { message: "cancel" })); - if (result.end) throw result.end; + if (result !== null && !(result instanceof Error)) { + if (result.kind === "sent") return true; + throw result.error; + } + + // The race leaves the blocked encode running, so reset the writable half too. + this.#writer.reset(result ?? new StreamError(StreamCode.Cancel, { message: "cancel" })); + if (result) throw result; return false; } @@ -533,9 +538,9 @@ export class Publisher { } for (;;) { - const woke = await Promise.race([changed, stream.reader.closed.then(() => "closed" as const)]); + const woke = await race([changed, stream.reader.closed]); dispose(); - if (woke === "closed") break; + if (woke !== "changed") break; // Re-arm before reading, so an advertise that lands while we write is not lost. changed = arm(); @@ -1023,14 +1028,14 @@ export class Publisher { // as `startFrame`. Skipping the head here is the only thing keeping those numbers // honest; a group that ends before we reach it can't be served at all. for (let i = 0; i < startFrame; i++) { - if (!(await Promise.race([group.readFrame(), stream.closed]))) { + if (!(await race([group.readFrame(), stream.closed]))) { throw new Error(`fetch group ended at frame ${i}, before the requested start ${startFrame}`); } } let prevTs = 0n; for (let index = startFrame; endFrame === undefined || index <= endFrame; index++) { - const frame = await Promise.race([group.readFrame(), stream.closed]); + const frame = await race([group.readFrame(), stream.closed]); if (!frame) break; const ts = BigInt(Math.round(frame.timestamp.as(timescale))); @@ -1094,7 +1099,7 @@ export class Publisher { let reached = startFrame === 0; for (;;) { - const read = await Promise.race([hooks.readGroupFrame(group), stream.closed]); + const read = await race([hooks.readGroupFrame(group), stream.closed]); if (!read) { // The group ended before the frame the subscriber asked to start // at, so this publisher can't serve the range at all. FINning here @@ -1176,7 +1181,7 @@ export class Publisher { const timeout = new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), PROBE_INTERVAL), ); - const result = await Promise.race([timeout, stream.reader.closed]); + const result = await race([timeout, stream.reader.closed]); if (result !== "timeout") break; // The two fields are independent on the wire, each using 0 for @@ -1231,65 +1236,14 @@ export class Publisher { } /** - * Close the session when our origin publishes a broadcast our grant does not cover, - * instead of leaving it to wait for a subscription that never comes. - * - * Starts once the tokens the session presented at setup are answered, then checks each - * broadcast when it first appears. A grant that later shrinks withdraws what it no longer - * covers (see {@link runAnnounce}) without closing anything: the grant is read before the - * table, so a revocation is never mistaken for a new unauthorized publication. + * Close the session when our origin publishes a broadcast our grant does not cover; + * see {@link enforceGrant}. * * @internal */ async runEnforce(setupAnswered: Promise): Promise { - const closed = this.#quic.closed.then( - () => "closed" as const, - () => "closed" as const, - ); - if ((await Promise.race([setupAnswered.then(() => "ready" as const), closed])) === "closed") return; - - // Every broadcast admitted so far that is still published. - const live = new Set(); - for (;;) { - let dispose: Dispose = () => {}; - const woke = new Promise<"changed">((resolve) => { - const table = this.#advertised.changed(() => resolve("changed")); - const grant = this.#grant?.changed(() => resolve("changed")); - dispose = () => { - table(); - grant?.(); - }; - }); - - const grant = this.#grant?.peek(); - const table = this.#advertised.peek(); - if (!table) { - dispose(); - return; - } - if (grant) { - for (const path of live) { - if (!table.has(path)) live.delete(path); - } - for (const path of table.keys()) { - if (live.has(path)) continue; - if (!grant.publish.matches(path)) { - console.error(`publishing outside our grant; closing the session: broadcast=${path}`); - this.#quic.close({ - closeCode: SessionCode.Unauthorized, - reason: closeReason(`unauthorized: ${path}`), - }); - dispose(); - return; - } - live.add(path); - } - } - - const why = await Promise.race([woke, closed]); - dispose(); - if (why === "closed") return; - } + if (!this.#grant) return; + await enforceGrant({ quic: this.#quic, advertised: this.#advertised, grant: this.#grant, setupAnswered }); } close() { diff --git a/js/net/src/lite/subscriber.ts b/js/net/src/lite/subscriber.ts index cecd054ec3..f16480c707 100644 --- a/js/net/src/lite/subscriber.ts +++ b/js/net/src/lite/subscriber.ts @@ -1,4 +1,4 @@ -import { type Getter, Signal } from "@moq/signals"; +import { type Getter, race, Signal } from "@moq/signals"; import * as announce from "../announced.ts"; import type { Grant } from "../auth.ts"; import * as broadcast from "../broadcast.ts"; @@ -283,7 +283,7 @@ export class Subscriber { // Receive announce updates (for Draft03, this includes initial state) for (;;) { - const announce = await Promise.race([ + const announce = await race([ decodeAnnounceBroadcastMaybe(stream.reader, this.version), announced.closed, ]); @@ -598,14 +598,14 @@ export class Subscriber { // once; race them into one stable promise so the demand loop doesn't re-subscribe each pass. const terminal: PromiseLike[] = [closed, producer.closed]; if (subscriptionUpdates !== undefined) terminal.push(subscriptionUpdates); - const done = Promise.race(terminal); + const done = race(terminal); // Serve until a terminal condition fires or the last local subscriber leaves. The unused // wake is level-triggered: re-check demand so a subscriber that returns before we tear // down (e.g. a quickly unmuted tile) resumes on the same subscription. const idle = Symbol("idle"); for (;;) { - const reason = await Promise.race([done, producer.unused().then(() => idle)]); + const reason = await race([done, producer.unused().then(() => idle)]); if (reason === idle && producer.closed.peek() === undefined && producer.used.peek()) continue; break; } @@ -783,14 +783,13 @@ export class Subscriber { // Serve until the stream FINs, the group closes, or every reader leaves. A group can // stay open indefinitely (a catalog or JSON stream), so an abandoned fetch is stopped by - // demand, not by the stream ending. `closed` and `unused` are watched across frames as - // stable promises (not re-subscribed to the signals each frame); the unused check is - // level-triggered, so a coalesced fetch that arrives before we cancel re-arms and resumes. + // demand, not by the stream ending. `unused` is watched across frames as one stable + // promise; the check is level-triggered, so a coalesced fetch that arrives before we + // cancel re-arms and resumes. const idle = Symbol("idle"); - const closed = Promise.resolve(group.closed); let unused = group.unused().then(() => idle); for (;;) { - const done = await Promise.race([stream.reader.done(), closed, unused]); + const done = await race([stream.reader.done(), group.closed, unused]); if (done === idle) { if (!group.isClosed && group.used.peek()) { unused = group.unused().then(() => idle); @@ -820,7 +819,7 @@ export class Subscriber { // Drains SUBSCRIBE_START/END/DROP on the subscribe stream until FIN (lite-05+). // The resolved range is informational here; the producer already orders groups. // Resolves (never rejects) on FIN or on the stream being reset out from under it, - // so it's safe to drop from a Promise.race without an unhandled rejection. + // so it's safe to drop from a race without an unhandled rejection. async #drainResponses(stream: Stream): Promise { try { for (;;) { @@ -836,7 +835,7 @@ export class Subscriber { * Send SUBSCRIBE_UPDATE messages whenever the track's aggregate subscription changes. * * Resolves cleanly when the stream or track closes, so the caller can include - * this in Promise.race without leaving a dangling pending write that would + * this in a race without leaving a dangling pending write that would * become an unhandled rejection if the user calls update after close. * * Peeks the signal at the top of every iteration so that updates which landed @@ -850,7 +849,7 @@ export class Subscriber { msg: Subscribe, stream: Stream, ): Promise { - const stopped: Promise = Promise.race([track.closed, stream.reader.closed]).then(() => null); + const stopped: Promise = race([track.closed, stream.reader.closed]).then(() => null); let lastSent: track.Subscription = { priority: msg.priority, maxAge: Time.Milli(msg.maxAge), @@ -864,7 +863,7 @@ export class Subscriber { const current = track.subscription.peek(); if (current === undefined || this.#sameSubscription(current, lastSent)) { // Nothing new to send; wait for a change or termination. - const next = await Promise.race([track.subscription.changed(), stopped]); + const next = await race([track.subscription.changed(), stopped]); if (next === null) return; continue; } @@ -941,7 +940,7 @@ export class Subscriber { let prevTs = 0n; for (;;) { - const done = await Promise.race([stream.done(), track.closed, producer.closed]); + const done = await race([stream.done(), track.closed, producer.closed]); if (done !== false) break; let timestamp: Time.Timestamp; diff --git a/js/net/src/retention.test.ts b/js/net/src/retention.test.ts new file mode 100644 index 0000000000..3325f9489d --- /dev/null +++ b/js/net/src/retention.test.ts @@ -0,0 +1,157 @@ +import { afterAll, afterEach, expect, spyOn, test } from "bun:test"; +import { type Dispose, Once } from "@moq/signals"; +import { accept, connect } from "./connection/index.ts"; +import type * as Group from "./group.ts"; +import * as Ietf from "./ietf/index.ts"; +import * as Lite from "./lite/index.ts"; +import { createMockTransportPair } from "./mock.ts"; +import { Producer as OriginProducer } from "./origin.ts"; +import * as Path from "./path.ts"; +import { Timestamp } from "./time.ts"; +import { wireOf } from "./wire.ts"; + +// A subscription races its track's `closed` once per frame. These count the listeners left on +// every Once that is still pending, so a per-frame leak shows up as a slope in frames. + +const url = new URL("https://localhost:4443/test"); + +// Listeners attached to each Once. A `then` on a pending Once holds its listener until it +// settles, so it is never released here. +const attached = new Map, Set>(); + +function attach(once: Once): Dispose { + const token = {}; + let set = attached.get(once); + if (!set) { + set = new Set(); + attached.set(once, set); + } + set.add(token); + const owner = set; + return () => owner.delete(token); +} + +function pendingListeners(): number { + let count = 0; + for (const [once, set] of attached) { + if (once.peek() === undefined) count += set.size; + } + return count; +} + +const proto = Once.prototype as Once; +const { subscribe, changed, then } = proto; +const spies = [ + spyOn(proto, "subscribe").mockImplementation(function (this: Once, fn) { + const release = attach(this); + const dispose = subscribe.call(this, fn); + return () => { + release(); + dispose(); + }; + }), + spyOn(proto, "changed").mockImplementation(function (this: Once, fn?: (value: unknown) => void) { + if (!fn) { + attach(this); + return (changed as () => Promise).call(this); + } + const release = attach(this); + const dispose = changed.call(this, fn); + return () => { + release(); + dispose(); + }; + } as typeof proto.changed), + spyOn(proto, "then").mockImplementation(function (this: Once, onFulfilled, onRejected) { + if (this.peek() === undefined) attach(this); + return then.call(this, onFulfilled, onRejected); + } as typeof proto.then), +]; + +afterEach(() => attached.clear()); +afterAll(() => { + for (const spy of spies) spy.mockRestore(); +}); + +async function session(alpn: string) { + const pair = createMockTransportPair(alpn); + const origin = new OriginProducer(); + const [client, server] = await Promise.all([ + connect({ url, transport: pair.client }), + accept({ transport: pair.server, url, publish: origin.consume() }), + ]); + + const broadcast = origin.createBroadcast(Path.from("test")); + broadcast.announce(); + const producer = broadcast.createTrack("video"); + + const remote = wireOf(client).consume(Path.from("test")); + const track = remote.track("video").subscribe().ordered(); + + const close = () => { + track.close(); + remote.close(); + broadcast.close(); + client.close(); + server.close(); + }; + return { producer, track, close }; +} + +function frame(i: number): Group.Frame { + return { payload: new Uint8Array([i & 0xff]), timestamp: Timestamp.fromMillis(i) }; +} + +async function readFrames(group: Group.Consumer, count: number): Promise { + for (let i = 0; i < count; i++) { + if (!(await group.readFrame())) throw new Error(`group ended after ${i} frames`); + } +} + +for (const [name, alpn] of [ + ["lite-03", Lite.ALPN_03], + ["lite-05", Lite.ALPN_05], + ["ietf-19", Ietf.ALPN.DRAFT_19], +]) { + test(`${name}: the frames of one long group leave no listener behind`, async () => { + const { producer, track, close } = await session(alpn); + const group = producer.appendGroup(); + + group.writeFrame(frame(0)); + const consumer = await track.nextGroup(); + if (!consumer) throw new Error("no group"); + await readFrames(consumer, 1); + + for (let i = 1; i < 50; i++) group.writeFrame(frame(i)); + await readFrames(consumer, 49); + const before = pendingListeners(); + + for (let i = 50; i < 1050; i++) group.writeFrame(frame(i)); + await readFrames(consumer, 1000); + expect(pendingListeners() - before).toBeLessThan(10); + + close(); + }); + + test(`${name}: single-frame groups leave no listener behind`, async () => { + const { producer, track, close } = await session(alpn); + + const send = async (count: number, offset: number) => { + for (let i = 0; i < count; i++) { + const group = producer.appendGroup(); + group.writeFrame(frame(offset + i)); + group.close(); + const consumer = await track.nextGroup(); + if (!consumer) throw new Error("no group"); + await readFrames(consumer, 1); + } + }; + + await send(50, 0); + const before = pendingListeners(); + await send(200, 50); + expect(pendingListeners() - before).toBeLessThan(10); + + close(); + }); +} diff --git a/js/net/src/stream.test.ts b/js/net/src/stream.test.ts index 7d6bf130c9..8a4dc2773f 100644 --- a/js/net/src/stream.test.ts +++ b/js/net/src/stream.test.ts @@ -1,4 +1,4 @@ -import { expect, test } from "bun:test"; +import { expect, spyOn, test } from "bun:test"; import { FrameTooLarge, GroupTooLarge, @@ -561,6 +561,22 @@ test("tryOpen gives up when cancelled, resetting a stream that opens afterwards" await aborted; }); +test("tryOpen rethrows a rejected cancel, resetting a stream that opens afterwards", async () => { + const { quic, freeSlot, aborted } = stalledTransport(); + + let fail!: (err: Error) => void; + const cancelled = new Promise((_, reject) => { + fail = reject; + }); + + const opening = Writer.tryOpen(quic, { cancel: cancelled }); + fail(new Error("stop sending")); + await expect(opening).rejects.toThrow("stop sending"); + + freeSlot(); + await aborted; +}); + // Without a deadline a peer that withholds stream credit while keeping the subscription // open queues work forever, since waitUntilAvailable never rejects. test("tryOpen gives up when the peer never frees a slot", async () => { @@ -581,6 +597,21 @@ test("tryOpen returns the stream when a slot is available", async () => { expect(await opening).toBeInstanceOf(Writer); }); +// A subscription hands the same `cancel` to every group it opens, so each open must not leave a +// reaction behind on it for the life of the subscription. +test("tryOpen shares one reaction on a cancel reused across opens", async () => { + const quic = { + createUnidirectionalStream: async () => new WritableStream(), + } as unknown as WebTransport; + + const cancel = new Promise(() => {}); + const reactions = spyOn(cancel, "then"); + for (let i = 0; i < 100; i++) { + expect(await Writer.tryOpen(quic, { cancel })).toBeInstanceOf(Writer); + } + expect(reactions).toHaveBeenCalledTimes(1); +}); + test("open waits for a stream slot instead of rejecting once the peer's limit is full", async () => { const options: unknown[] = []; const quic = { diff --git a/js/net/src/stream.ts b/js/net/src/stream.ts index d043014158..eaf3e8ab6d 100644 --- a/js/net/src/stream.ts +++ b/js/net/src/stream.ts @@ -1,3 +1,4 @@ +import { race } from "@moq/signals"; import { fromTransport, StreamCode, StreamError, toStreamCode, toTransport } from "./error.ts"; import type { IetfVersion } from "./ietf/version.ts"; import { Version } from "./ietf/version.ts"; @@ -100,8 +101,8 @@ export interface OpenOptions { /** Options for {@link Writer.tryOpen}. */ export interface TryOpenOptions extends OpenOptions { - /** Give up once this settles, however it settles. */ - cancel: Promise; + /** Give up once this resolves. */ + cancel: Promise; } export class Stream { @@ -523,27 +524,29 @@ export class Writer { * an over-limit open instead of rejecting it. */ static async tryOpen(quic: WebTransport, options: TryOpenOptions): Promise { - // A rejected `cancel` (STOP_SENDING) means the peer is gone too, so both settle - // paths mean "give up" and neither is left unhandled. Built before the open, and - // raced ahead of it, so an already-cancelled caller wins even against a slot that - // is free right now. - const cancelled = options.cancel.then( - () => undefined, - () => undefined, - ); + // Raced ahead of the open, so an already-cancelled caller wins even against a slot + // that is free right now. `race` rather than `Promise.race`: the caller shares one + // `cancel` across every group of a subscription, which must not gain a reaction per call. const open = Writer.open(quic, options); + // Resets a stream that opens after we gave up; a no-op if the open itself failed. + const abandon = () => { + const abandoned = new Error("abandoned waiting for a stream slot"); + open.then((w) => w.reset(abandoned)).catch(() => void 0); + }; + try { - const stream = await Promise.race([cancelled, open]); + const stream = await race([options.cancel, open]); if (stream) return stream; } catch (err: unknown) { // open already discarded the late stream on its way out. - if (!(err instanceof TimeoutError)) throw err; - return undefined; + if (err instanceof TimeoutError) return undefined; + // A rejected `cancel` still leaves the open pending. + abandon(); + throw err; } - const abandoned = new Error("abandoned waiting for a stream slot"); - open.then((w) => w.reset(abandoned)).catch(() => void 0); + abandon(); return undefined; } } diff --git a/js/publish/src/audio/capture.test.ts b/js/publish/src/audio/capture.test.ts index 53526249ec..dbdfce5929 100644 --- a/js/publish/src/audio/capture.test.ts +++ b/js/publish/src/audio/capture.test.ts @@ -109,7 +109,7 @@ test("does not construct an AudioWorkletNode when torn down mid worklet load", a await settle(); // Tear the run down before the module finishes loading. cleanup() calls context.close(), which on - // Firefox/Safari leaves .state === "suspended", then effect.cancel wins the race. + // Firefox/Safari leaves .state === "suspended", then the teardown wins the race. capture.close(); await settle(); diff --git a/js/publish/src/audio/capture.ts b/js/publish/src/audio/capture.ts index 15b0114645..5484de9297 100644 --- a/js/publish/src/audio/capture.ts +++ b/js/publish/src/audio/capture.ts @@ -163,10 +163,7 @@ export class Capture { // module registration was abandoned, so building against its name would throw. Gate on the race // result, not `context.state`, because `AudioContext.close()` only flips `.state` to "closed" // synchronously on Chrome (Firefox/Safari report "suspended"). - const ok = await Promise.race([ - context.audioWorklet.addModule(CaptureWorklet).then(() => true), - effect.cancel, - ]); + const ok = await effect.race(context.audioWorklet.addModule(CaptureWorklet).then(() => true)); if (ok) loaded.set(true); }); diff --git a/js/publish/src/audio/encoder.ts b/js/publish/src/audio/encoder.ts index 8499c80d31..0cab7ea977 100644 --- a/js/publish/src/audio/encoder.ts +++ b/js/publish/src/audio/encoder.ts @@ -224,7 +224,7 @@ export class Encoder { effect.spawn(async () => { for (;;) { - const next = await Promise.race([reader.read(), effect.cancel]); + const next = await effect.race(reader.read()); if (!next?.value) break; const format = capture.out.format.peek(); diff --git a/js/publish/src/broadcast.ts b/js/publish/src/broadcast.ts index 1ed9f1fc66..465e221dd7 100644 --- a/js/publish/src/broadcast.ts +++ b/js/publish/src/broadcast.ts @@ -111,7 +111,8 @@ export class Broadcast { * * Set the returned rendition's `config` to a {@link Catalog.TextConfig}, then write one cue per * group into its `track` with `Hang.Container.Legacy.Producer` (each cue is a keyframe, so it opens - * its own group). See the module docs for the cue framing. + * its own group). See the module docs for the cue framing. Stamp cues with `performance.now()` in + * microseconds, the broadcast clock the catalog advertises. */ text(name: string): Rendition { return this.#register(name, "text"); diff --git a/js/publish/src/catalog.test.ts b/js/publish/src/catalog.test.ts index 9a0294afc4..58873544ec 100644 --- a/js/publish/src/catalog.test.ts +++ b/js/publish/src/catalog.test.ts @@ -45,7 +45,7 @@ test("catalog producer publishes every update as a snapshot group", async () => const first = await subscriber.nextGroup(); expect(first?.sequence).toBe(0); - expect(await first?.readJson()).toEqual({ video: { renditions: {} } }); + expect(await first?.readJson()).toEqual({ clock: expect.anything(), video: { renditions: {} } }); expect(first?.done).toBe(true); catalog.mutate((c) => { @@ -54,12 +54,46 @@ test("catalog producer publishes every update as a snapshot group", async () => const second = await subscriber.nextGroup(); expect(second?.sequence).toBe(1); - expect(await second?.readJson()).toEqual({ video: { renditions: {} }, scte35: { splices: [] } }); + expect(await second?.readJson()).toEqual({ + clock: expect.anything(), + video: { renditions: {} }, + scte35: { splices: [] }, + }); expect(second?.done).toBe(true); effect.close(); }); +test("catalog producer advertises the page clock from the first snapshot", async () => { + const catalog = new CatalogProducer(); + + const effect = new Effect(); + const track = new Track.Producer("catalog.json"); + catalog.serve(track, effect); + const consumer = new Json.Snapshot.Consumer({ track: track.subscribe() }); + + // Before any rendition: a live-only publisher exposes its clock without an archive. + const first = Catalog.RootSchema.parse(await consumer.next()); + if (!first.clock) throw new Error("expected a root clock"); + expect(first.archive).toBeUndefined(); + expect(first.clock.timescale).toBe(1_000_000); + + // A timestamp stamped the way capture does (performance.now() in microseconds) maps onto the + // page's own wall timeline, not Date.now(), which a system-clock adjustment can move. + const now = performance.now(); + const wall = Catalog.wallClockTime(first.clock, Math.round(now * 1000), 1_000_000).getTime(); + expect(Math.abs(wall - (performance.timeOrigin + now))).toBeLessThanOrEqual(1); + + // Later edits keep the mapping: it is fixed for the broadcast. + catalog.mutate((c) => { + c.video = { renditions: {} }; + }); + const second = Catalog.RootSchema.parse(await consumer.next()); + expect(second.clock).toEqual(first.clock); + + effect.close(); +}); + test("a reconnecting subscriber is seeded with the full current catalog", async () => { const catalog = new CatalogProducer(); catalog.mutate((c) => { @@ -105,7 +139,8 @@ test("catalog producer refuses zero jitter before retaining an edit", () => { ).toThrow("omit jitter"); } catalog.mutate((value) => { - expect(value).toEqual({}); + expect(value.audio).toBeUndefined(); + expect(value.video).toBeUndefined(); }); }); diff --git a/js/publish/src/catalog.ts b/js/publish/src/catalog.ts index 1c37d4f72d..7c45ec7962 100644 --- a/js/publish/src/catalog.ts +++ b/js/publish/src/catalog.ts @@ -1,4 +1,4 @@ -import type * as Catalog from "@moq/hang/catalog"; +import * as Catalog from "@moq/hang/catalog"; import * as Json from "@moq/json"; import type * as Moq from "@moq/net"; import type { Effect } from "@moq/signals"; @@ -11,9 +11,13 @@ import type { Effect } from "@moq/signals"; * current catalog before receiving updates. Independent owners (the base `video`/`audio` and an * application's own sections, e.g. `scte35`) each edit only their own keys, so their sections * compose instead of clobbering one another. + * + * The root `clock` is advertised from the first snapshot: every js/publish timestamp is + * `performance.now()` in microseconds, so PTS zero is `performance.timeOrigin`. The mapping is fixed + * for the page, so a system-clock adjustment never retimes the broadcast. */ export class CatalogProducer { - #value: Catalog.Root = {}; + #value: Catalog.Root = { clock: pageClock() }; #outputs = new Set>(); /** Edit the catalog in place; the result is published to all current subscribers. */ @@ -54,3 +58,9 @@ export class CatalogProducer { }); } } + +// The wall time of `performance.now() === 0`, the zero every js/publish timestamp counts from. +function pageClock(): Catalog.Clock { + const wall = Math.round((performance.timeOrigin - Catalog.MOQ_EPOCH_UNIX_MILLIS) * 1000); + return { wall: Catalog.u53(wall), timescale: 1_000_000 }; +} diff --git a/js/publish/src/preview.ts b/js/publish/src/preview.ts index 0b34be9306..4d04c027a5 100644 --- a/js/publish/src/preview.ts +++ b/js/publish/src/preview.ts @@ -124,7 +124,7 @@ export class Renderer { effect.spawn(async () => { for (;;) { - const next = await Promise.race([reader.read(), effect.cancel]); + const next = await effect.race(reader.read()); if (!next?.value) break; this.#latest.update((prev) => { @@ -269,7 +269,7 @@ export class Transcode { inner.spawn(async () => { for (;;) { - const next = await Promise.race([reader.read(), inner.cancel]); + const next = await inner.race(reader.read()); if (!next?.value) break; // Ours now, so close it once the encoder has taken what it needs. diff --git a/js/publish/src/source/camera.ts b/js/publish/src/source/camera.ts index a61a43f64c..cd0e8601f3 100644 --- a/js/publish/src/source/camera.ts +++ b/js/publish/src/source/camera.ts @@ -121,7 +121,7 @@ export class Camera { let stream: MediaStream | undefined; try { - stream = await Promise.race([media, effect.cancel.then(() => undefined)]); + stream = await effect.race(media); } catch (error) { if (effect.abort.aborted) return; this.#out.error.set(error instanceof Error ? error : new Error(String(error))); diff --git a/js/publish/src/source/device.ts b/js/publish/src/source/device.ts index 8236a84847..1f9667f852 100644 --- a/js/publish/src/source/device.ts +++ b/js/publish/src/source/device.ts @@ -92,10 +92,7 @@ export class Device { effect.get(this.out.permission); // Ignore permission errors for now. - let devices = await Promise.race([ - navigator.mediaDevices.enumerateDevices().catch(() => undefined), - effect.cancel, - ]); + let devices = await effect.race(navigator.mediaDevices.enumerateDevices().catch(() => undefined)); if (!devices) return; // cancelled, keep stale values devices = devices.filter((d) => d.kind === `${this.kind}input`); diff --git a/js/publish/src/source/microphone.ts b/js/publish/src/source/microphone.ts index 7cf8c0602a..45258f294c 100644 --- a/js/publish/src/source/microphone.ts +++ b/js/publish/src/source/microphone.ts @@ -107,7 +107,7 @@ export class Microphone { let stream: MediaStream | undefined; try { - stream = await Promise.race([media, effect.cancel.then(() => undefined)]); + stream = await effect.race(media); } catch (error) { if (effect.abort.aborted) return; this.#out.error.set(error instanceof Error ? error : new Error(String(error))); diff --git a/js/publish/src/source/screen.ts b/js/publish/src/source/screen.ts index e885db1680..8e32f6e4c7 100644 --- a/js/publish/src/source/screen.ts +++ b/js/publish/src/source/screen.ts @@ -83,7 +83,7 @@ export class Screen { } effect.spawn(async () => { - const media = await Promise.race([ + const media = await effect.race( navigator.mediaDevices .getDisplayMedia({ video, @@ -97,8 +97,7 @@ export class Screen { // systemAudio: "exclude", }) .catch(() => undefined), - effect.cancel, - ]); + ); if (!media) return; const v = media.getVideoTracks().at(0) as Video.StreamTrack | undefined; diff --git a/js/publish/src/ui/components/stats-tab.ts b/js/publish/src/ui/components/stats-tab.ts index bdb9ee4943..07e7b05c28 100644 --- a/js/publish/src/ui/components/stats-tab.ts +++ b/js/publish/src/ui/components/stats-tab.ts @@ -115,7 +115,7 @@ export function statsTab(parent: Effect, publish: MoqPublish): HTMLElement { effect.spawn(async () => { for (;;) { - const next = await Promise.race([reader.read(), effect.cancel]); + const next = await effect.race(reader.read()); if (!next?.value) break; frames++; diff --git a/js/publish/src/video/encoder.ts b/js/publish/src/video/encoder.ts index 30e23d0a6e..a89c58b5b6 100644 --- a/js/publish/src/video/encoder.ts +++ b/js/publish/src/video/encoder.ts @@ -298,7 +298,7 @@ export class Encoder { effect.spawn(async () => { for (;;) { - const next = await Promise.race([reader.read(), effect.cancel]); + const next = await effect.race(reader.read()); if (!next?.value) break; // Ours now: every path below has to close it. diff --git a/js/room/src/metadata.ts b/js/room/src/metadata.ts index 0dd054d176..7ff0a458c9 100644 --- a/js/room/src/metadata.ts +++ b/js/room/src/metadata.ts @@ -231,7 +231,7 @@ function subscribeJson( const consumer = new Json.Snapshot.Consumer({ track }); effect.spawn(async () => { for (;;) { - const value = await Promise.race([effect.cancel, consumer.next()]); + const value = await effect.race(consumer.next()); if (value === undefined) break; update(value); } diff --git a/js/room/src/room.ts b/js/room/src/room.ts index 21ae254bcc..0cf2a48ba0 100644 --- a/js/room/src/room.ts +++ b/js/room/src/room.ts @@ -80,7 +80,7 @@ export class Room { async #run(announced: Moq.Announce.Consumer, prefix: Moq.Path.Valid, effect: Effect): Promise { for (;;) { - const update = await Promise.race([effect.cancel, announced.next()]); + const update = await effect.race(announced.next()); if (!update) break; // The scope's `**` captures what lies beneath the prefix. A broad route diff --git a/js/signals/src/index.test.ts b/js/signals/src/index.test.ts index 4cf02417f0..71c5b190d6 100644 --- a/js/signals/src/index.test.ts +++ b/js/signals/src/index.test.ts @@ -1,5 +1,5 @@ import { describe, expect, spyOn, test } from "bun:test"; -import { Computed, Effect, Once, Signal } from "./index.ts"; +import { Computed, type Dispose, Effect, type GetPromise, Once, race, Signal } from "./index.ts"; const NO_SUBSCRIPTION_WARNING = "Effect did not subscribe to any signals; it will never rerun."; @@ -1049,3 +1049,203 @@ describe("Once", () => { expect(seen).toEqual(["x"]); }); }); + +// A GetPromise over a Once that counts the listeners currently attached to it. +function counted(once = new Once()): GetPromise & { listeners: number; once: Once } { + const wrapper = { + once, + listeners: 0, + peek: () => once.peek(), + changed: ((fn?: (value: T | undefined) => void) => + fn ? track(once.changed(fn)) : once.changed()) as GetPromise["changed"], + subscribe: (fn: (value: T | undefined) => void) => track(once.subscribe(fn)), + // biome-ignore lint/suspicious/noThenProperty: mirrors Once. + then: once.then.bind(once) as GetPromise["then"], + }; + function track(dispose: Dispose): Dispose { + wrapper.listeners++; + let live = true; + return () => { + if (!live) return; + live = false; + wrapper.listeners--; + dispose(); + }; + } + return wrapper; +} + +// A thenable that counts how many reactions were attached to it. +function thenable(): PromiseLike & { reactions: number; resolve: (value: T) => void } { + const inner = Promise.withResolvers(); + return { + reactions: 0, + resolve: inner.resolve, + // biome-ignore lint/suspicious/noThenProperty: a counting thenable. + then(onFulfilled, onRejected) { + this.reactions++; + return inner.promise.then(onFulfilled, onRejected); + }, + }; +} + +describe("race", () => { + test("settles with the first value to settle", async () => { + const slow = new Promise((resolve) => setTimeout(() => resolve("slow"), 20)); + const fast = new Promise((resolve) => setTimeout(() => resolve("fast"), 1)); + expect(await race([slow, fast])).toBe("fast"); + }); + + test("rejects with the first rejection", async () => { + const pending = new Promise(() => {}); + await expect(race([pending, Promise.reject(new Error("boom"))])).rejects.toThrow("boom"); + }); + + test("an already settled Once wins at once", async () => { + const once = new Once(); + once.set("done"); + expect(await race([Promise.resolve("promise"), once])).toBe("done"); + }); + + test("resolves when a pending Once settles", async () => { + const once = new Once(); + const result = race([new Promise(() => {}), once]); + once.set(3); + expect(await result).toBe(3); + }); + + test("many races leave a pending Once without listeners", async () => { + const closed = counted(); + for (let i = 0; i < 1000; i++) { + expect(await race([Promise.resolve(i), closed])).toBe(i); + } + expect(closed.listeners).toBe(0); + }); + + test("many races attach one reaction to a long-lived promise", async () => { + const closed = thenable(); + for (let i = 0; i < 1000; i++) { + expect(await race([Promise.resolve(i), closed])).toBe(i); + } + expect(closed.reactions).toBe(1); + + closed.resolve("closed"); + expect(await race([new Promise(() => {}), closed])).toBe("closed"); + expect(closed.reactions).toBe(1); + }); + + test("many races against a long-lived promise leave it no listeners", async () => { + const closed = new Promise(() => {}); + const add = spyOn(Set.prototype, "add"); + let sets: Set[]; + try { + for (let i = 0; i < 1000; i++) await race([Promise.resolve(i), closed]); + sets = [...add.mock.contexts] as Set[]; + } finally { + add.mockRestore(); + } + + // `closed` is the last value each race listens to, so the last set added to is its listeners. + const listeners = sets.at(-1); + expect(sets.filter((set) => set === listeners).length).toBe(1000); + expect(sets.every((set) => set.size === 0)).toBe(true); + }); +}); + +describe("effect.race", () => { + test("resolves with the value while the run is live", async () => { + const effect = new Effect(); + expect(await effect.race(Promise.resolve(5))).toBe(5); + effect.close(); + }); + + test("resolves undefined when the run is torn down", async () => { + const trigger = new Signal(0); + let result: Promise | undefined; + const effect = new Effect((effect) => { + if (effect.get(trigger) === 0) result = effect.race(new Promise(() => {})); + }); + await settle(); + trigger.set(1); + expect(await result).toBeUndefined(); + effect.close(); + }); + + test("resolves undefined on close, and at once after it", async () => { + const effect = new Effect(); + const result = effect.race(new Promise(() => {})); + effect.close(); + expect(await result).toBeUndefined(); + expect(await effect.race(Promise.resolve(1))).toBeUndefined(); + }); + + test("many races leave neither teardown nor the value with a listener", async () => { + const added = spyOn(AbortSignal.prototype, "addEventListener"); + const removed = spyOn(AbortSignal.prototype, "removeEventListener"); + try { + const closed = counted(); + const effect = new Effect(); + for (let i = 0; i < 1000; i++) { + expect(await effect.race(Promise.resolve(i), closed)).toBe(i); + } + expect(closed.listeners).toBe(0); + expect(added.mock.calls.length).toBe(removed.mock.calls.length); + + // A teardown that wins releases the value's listener too. + const pending = effect.race(closed); + expect(closed.listeners).toBe(1); + effect.close(); + expect(await pending).toBeUndefined(); + expect(closed.listeners).toBe(0); + } finally { + added.mockRestore(); + removed.mockRestore(); + } + }); +}); + +describe("spawn retention", () => { + test("an effect that never reruns drops settled tasks", async () => { + const effect = new Effect(); + const add = spyOn(Set.prototype, "add"); + let sets: Set[]; + try { + for (let i = 0; i < 100; i++) effect.spawn(async () => {}); + sets = [...add.mock.contexts] as Set[]; + } finally { + add.mockRestore(); + } + + // The effect's own task set, found by what spawn added to it. + const tasks = sets[0]; + expect(sets.every((set) => set === tasks)).toBe(true); + expect(tasks?.size).toBe(100); + + await settle(); + expect(tasks?.size).toBe(0); + effect.close(); + }); + + test("a rerun still waits for a pending task", async () => { + const trigger = new Signal(0); + const task = Promise.withResolvers(); + let runs = 0; + const effect = new Effect((effect) => { + runs++; + effect.get(trigger); + if (runs === 1) { + effect.spawn(async () => {}); + effect.spawn(() => task.promise); + } + }); + await settle(); + trigger.set(1); + await settle(); + expect(runs).toBe(1); + + task.resolve(); + await settle(); + expect(runs).toBe(2); + effect.close(); + }); +}); diff --git a/js/signals/src/index.ts b/js/signals/src/index.ts index fd7c085fef..5b4681faf9 100644 --- a/js/signals/src/index.ts +++ b/js/signals/src/index.ts @@ -277,6 +277,114 @@ export class Once implements GetPromise { } } +// Every promise a race has watched, mapped to the listeners of the races still waiting on it. A +// native reaction can never be removed, so each promise gets exactly one, shared by every race: +// racing a long-lived promise per frame then costs a removable listener, not a reaction per call. +type Settled = { ok: true; value: unknown } | { ok: false; error: unknown }; +type Listener = (settled: Settled) => void; +type Watched = { settled?: Settled; listeners: Set }; +const watched = new WeakMap(); + +function isThenable(value: unknown): value is PromiseLike & object { + return ( + (typeof value === "object" || typeof value === "function") && + value !== null && + typeof (value as PromiseLike).then === "function" + ); +} + +// Calls `fn` once when `value` settles, synchronously if it already has. Returns a disposer. +function listen(value: unknown, fn: Listener): Dispose { + if (!isThenable(value)) { + fn({ ok: true, value }); + return noop; + } + + if (getterShaped(value)) { + const readable = value as GetPromise; + const current = readable.peek(); + if (current !== undefined) { + fn({ ok: true, value: current }); + return noop; + } + + const dispose = readable.subscribe((next) => { + if (next === undefined) return; + dispose(); + fn({ ok: true, value: next }); + }); + return dispose; + } + + let entry = watched.get(value); + if (!entry) { + const created: Watched = { listeners: new Set() }; + Promise.resolve(value).then( + (value) => settle(created, { ok: true, value }), + (error: unknown) => settle(created, { ok: false, error }), + ); + watched.set(value, created); + entry = created; + } + + if (entry.settled) { + fn(entry.settled); + return noop; + } + + const listeners = entry.listeners; + listeners.add(fn); + return () => listeners.delete(fn); +} + +function settle(entry: Watched, settled: Settled): void { + entry.settled = settled; + const listeners = [...entry.listeners]; + entry.listeners.clear(); + for (const fn of listeners) fn(settled); +} + +// Settles with the first of `values`, or `undefined` once `abort` fires, then drops every listener. +function raceUntil(values: readonly unknown[], abort?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const disposes: Dispose[] = []; + let done = false; + const finish = (settled: Settled) => { + if (done) return; + done = true; + for (const dispose of disposes) dispose(); + if (settled.ok) resolve(settled.value as T); + else reject(settled.error); + }; + + if (abort) { + if (abort.aborted) return finish({ ok: true, value: undefined }); + const stop = () => finish({ ok: true, value: undefined }); + abort.addEventListener("abort", stop); + disposes.push(() => abort.removeEventListener("abort", stop)); + } + + for (const value of values) { + const dispose = listen(value, finish); + if (done) return dispose(); + disposes.push(dispose); + } + }); +} + +/** + * Settles with the first of `values` to settle, like `Promise.race`, then drops every listener it + * registered. + * + * Accepts promises and {@link GetPromise} values such as a {@link Once}. Racing a value that + * outlives the call (a `closed` pending for the whole track) once per frame therefore leaks nothing, + * where `Promise.race` would leave a listener or reaction behind on every call. An already settled + * value wins at once. + */ +export function race(values: T): Promise> { + return raceUntil(values); +} + type SetterType = S extends Setter ? T : never; /** The value type a {@link Getter} yields, e.g. `number` for `Getter`. */ @@ -405,7 +513,7 @@ export class Effect { #draining?: Dispose[]; #drained = 0; #unwatch: Dispose[] = []; - #async: Promise[] = []; + #async = new Set>(); #stack?: string; #scheduled = false; @@ -479,7 +587,7 @@ export class Effect { // would hand that task's cleanup registrations, and its `abort` signal, to a run it never // belonged to. The dispose functions above already closed whatever the task was awaiting, // so anything that observes cancellation unwinds from here. - if (this.#async.length > 0) { + if (this.#async.size > 0) { // Diagnostic only: a task that ignores cancellation stalls the rerun, so name it. const warn = DEV ? setTimeout(() => { @@ -496,14 +604,14 @@ export class Effect { try { // A task can spawn another as it unwinds, so drain until nothing new is queued. - while (this.#dispose !== undefined && this.#async.length > 0) { + while (this.#dispose !== undefined && this.#async.size > 0) { const pending = this.#async; - this.#async = []; + this.#async = new Set(); // close() has to release the wait rather than wait behind it. It already ran // every dispose function, and there is no next run left to protect, so a task // that never settles must not pin this loop (or the timer below) forever. - await Promise.race([Promise.all(pending), this.#closed.promise]); + await race([Promise.all(pending), this.#closed.promise]); } } catch (error) { console.error("async effect error", error); @@ -534,7 +642,7 @@ export class Effect { this.#dispose !== undefined && this.#unwatch.length === 0 && this.#dispose.length === 0 && - this.#async.length === 0 && + this.#async.size === 0 && !this.#abortUsed ) { console.warn("Effect did not subscribe to any signals; it will never rerun.", this.#stack); @@ -604,7 +712,12 @@ export class Effect { // rerun path drains it, so pushing now would pin the task on an effect that can never rerun. if (this.#dispose === undefined) return; - this.#async.push(promise); + // A settled task has nothing left for a rerun to wait on. Dropping it matters for an effect + // that never reruns (`new Effect()` spawning per group), which would otherwise keep every one. + // The set, not `this`, so a task that never settles cannot pin a closed effect. + const tasks = this.#async; + tasks.add(promise); + void promise.then(() => tasks.delete(promise)); } /** Runs `fn` after `ms` milliseconds, unless the effect reruns or closes first. */ @@ -920,7 +1033,7 @@ export class Effect { for (const signal of this.#unwatch) signal(); this.#unwatch.length = 0; - this.#async.length = 0; + this.#async.clear(); if (DEV) { Effect.#finalizer.unregister(this); @@ -932,11 +1045,23 @@ export class Effect { return this.#closed.promise; } - /** Resolves when the current run is about to be torn down, by a rerun or close. */ + /** + * Resolves when the current run is about to be torn down, by a rerun or close. + * + * @internal Racing it adds a reaction per call that lives until the run ends; use {@link race}. + */ get cancel(): Promise { return this.#stopped.promise; } + /** + * Settles with the first of `values`, like the free {@link race}, or resolves `undefined` once the + * current run is torn down. Either way it drops every listener it registered. + */ + race(...values: T): Promise | undefined> { + return raceUntil(values, this.#abort.signal); + } + /** An AbortSignal that fires when the current run is torn down. */ get abort(): AbortSignal { this.#abortUsed = true; diff --git a/js/watch/src/audio/decoder.ts b/js/watch/src/audio/decoder.ts index c469aabdff..8745d04af4 100644 --- a/js/watch/src/audio/decoder.ts +++ b/js/watch/src/audio/decoder.ts @@ -181,10 +181,7 @@ export class Decoder { // abandoned, so building against its name would throw. Gate on the race result, not // `context.state`, because `AudioContext.close()` only flips `.state` to "closed" synchronously // on Chrome (Firefox/Safari report "suspended"). - const loaded = await Promise.race([ - context.audioWorklet.addModule(RenderWorklet).then(() => true), - effect.cancel, - ]); + const loaded = await effect.race(context.audioWorklet.addModule(RenderWorklet).then(() => true)); if (!loaded) return; // Create the worklet node. outputChannelCount must be set explicitly diff --git a/js/watch/src/audio/source.ts b/js/watch/src/audio/source.ts index 7961836df5..8503e10a74 100644 --- a/js/watch/src/audio/source.ts +++ b/js/watch/src/audio/source.ts @@ -85,13 +85,10 @@ export class Source { effect.spawn(async () => { const available: Record = {}; - // `supported` comes from the consumer, so we cannot assume it ever settles. A rerun - // waits for the tasks it spawned, so an unraced probe would hold the next run shut - // for good. Captured here so it stays this run's promise once we start awaiting. - const cancelled = effect.cancel.then(() => undefined); - for (const [name, config] of Object.entries(renditions)) { - const isSupported = await Promise.race([supported(config), cancelled]); + // `supported` comes from the consumer, so we cannot assume it ever settles. A rerun + // waits for the tasks it spawned, so an unraced probe would hold the next run shut. + const isSupported = await effect.race(supported(config)); // Torn down: stop probing and publish nothing, since the rerun redoes this. if (effect.abort.aborted) return; diff --git a/js/watch/src/broadcast.ts b/js/watch/src/broadcast.ts index 65b435f720..1064a0708c 100644 --- a/js/watch/src/broadcast.ts +++ b/js/watch/src/broadcast.ts @@ -200,7 +200,7 @@ export class Broadcast { effect.spawn(async () => { for (;;) { - const entry = await Promise.race([effect.cancel, announced.next()]); + const entry = await effect.race(announced.next()); if (!entry) break; this.#announced.mutate((active) => { if (!active) return; @@ -335,7 +335,7 @@ export class Broadcast { effect.spawn(async () => { try { for (;;) { - const update = await Promise.race([effect.cancel, fetchNext()]); + const update = await effect.race(fetchNext()); if (!update) break; console.debug("received catalog", format, this.in.name.peek(), update); diff --git a/js/watch/src/retention.test.ts b/js/watch/src/retention.test.ts new file mode 100644 index 0000000000..af0af0860e --- /dev/null +++ b/js/watch/src/retention.test.ts @@ -0,0 +1,64 @@ +import { heapStats } from "bun:jsc"; +import { expect, test } from "bun:test"; +import { Container } from "@moq/hang"; +import * as Moq from "@moq/net"; +import { Time } from "@moq/net"; +import { Effect } from "@moq/signals"; +import { nextMedia, subscribeMedia } from "./media"; +import { Sync } from "./sync"; + +// The player path a decoder drives, one frame per group like AAC audio: the container consumer +// reads each frame, the shared clock anchors on it, and presentation waits on the clock against +// the effect's teardown. Retention anywhere along it grows the heap with the frame count. +test("a long subscription through the player path keeps a flat heap", async () => { + const broadcast = new Moq.Broadcast.Producer(); + // A tiny publisher window, so the track's own replay cache stays flat too. + const track = broadcast.createTrack("audio", { maxAge: Time.Milli(1) }); + const format = new Container.Legacy.Format("audio"); + const producer = new Container.Legacy.Producer(track, format); + + const sync = new Sync({ delay: Time.Milli(10) }); + const effect = new Effect(); + const sub = subscribeMedia(effect, { + broadcast: broadcast.consume(), + track: "audio", + priority: 0, + maxAge: sync.out.maxAge, + }); + if (!sub) throw new Error("no subscription"); + const consumer = new Container.Consumer(sub, { format, maxAge: sync.out.maxAge }); + + // Presentations overlap, as a decoder's outputs do, so the clock's sleeps are shared. + const presenting = new Set>(); + const play = async (count: number) => { + for (let i = 0; i < count; i++) { + producer.encode(new Uint8Array([i & 0xff]), Math.round(Time.Micro.now()) as Time.Micro, true); + // A new group closes the previous one with a duration marker, which reads as no frame. + let next = await nextMedia(consumer); + while (next && !next.frame) next = await nextMedia(consumer); + if (!next?.frame) throw new Error("the track ended"); + + const timestamp = Time.Milli.fromMicro(next.frame.timestamp); + sync.received(timestamp); + const presented = effect.race(sync.wait(timestamp)); + presenting.add(presented); + void presented.finally(() => presenting.delete(presented)); + } + await Promise.all(presenting); + }; + + const heap = () => { + Bun.gc(true); + return heapStats().objectCount; + }; + + await play(200); + const before = heap(); + await play(2000); + expect(heap() - before).toBeLessThan(1000); + + consumer.close(); + effect.close(); + sync.close(); + broadcast.close(); +}); diff --git a/js/watch/src/sync.test.ts b/js/watch/src/sync.test.ts index ed2411a8ec..2376f625e9 100644 --- a/js/watch/src/sync.test.ts +++ b/js/watch/src/sync.test.ts @@ -1,7 +1,8 @@ +import { heapStats } from "bun:jsc"; import { describe, expect, it } from "bun:test"; -import type { Time } from "@moq/net"; +import { Time } from "@moq/net"; import { Signal } from "@moq/signals"; -import { Sync } from "./sync"; +import { type Delay, Sync } from "./sync"; // Effects in @moq/signals flush on a microtask, so let pending updates drain before asserting. const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); @@ -96,3 +97,55 @@ describe("delay and buffer", () => { sync.close(); }); }); + +describe("wait", () => { + const promises = () => { + Bun.gc(true); + return heapStats().objectTypeCounts.Promise ?? 0; + }; + + it("leaves nothing behind on a stable clock", async () => { + const sync = new Sync({ delay: 10 as Time.Milli }); + await flush(); + sync.received(Time.Milli.now()); + + const before = promises(); + for (let round = 0; round < 10; round++) { + const now = Time.Milli.now(); + await Promise.all(Array.from({ length: 100 }, () => sync.wait(now))); + } + expect(promises() - before).toBeLessThan(100); + sync.close(); + }); + + it("wakes a sleeping wait when the delay switches to instant", async () => { + const delay = new Signal(10_000 as Time.Milli); + const sync = new Sync({ delay }); + await flush(); + sync.received(Time.Milli.now()); + + let woke = false; + const waiting = sync.wait(Time.Milli.now()).then(() => { + woke = true; + }); + await flush(); + expect(woke).toBe(false); + + delay.set("instant"); + await waiting; + expect(woke).toBe(true); + sync.close(); + }); + + it("wakes a sleeping wait on reset", async () => { + const sync = new Sync({ delay: 10_000 as Time.Milli }); + await flush(); + sync.received(Time.Milli.now()); + + const waiting = sync.wait(Time.Milli.now()); + await flush(); + sync.reset(); + await waiting; + sync.close(); + }); +}); diff --git a/js/watch/src/sync.ts b/js/watch/src/sync.ts index 07a5da3d12..c6d3404be7 100644 --- a/js/watch/src/sync.ts +++ b/js/watch/src/sync.ts @@ -87,10 +87,6 @@ export class Sync { }; readonly out = readonlys(this.#out); - // A ghetto way to learn when the reference/buffer changes. - // There's probably a way to use Effect, but lets keep it simple for now. - #update: PromiseWithResolvers; - // Per-label late-frame tracking: accumulate count and max lateness, flush on recovery. #late = new Map(); @@ -108,8 +104,6 @@ export class Sync { probe: getter(props?.probe), }; - this.#update = Promise.withResolvers(); - this.#signals.run(this.#runJitter.bind(this)); this.#signals.run(this.#runDelay.bind(this)); this.#signals.run(this.#runMaxAge.bind(this)); @@ -178,9 +172,6 @@ export class Sync { const instant = effect.get(this.in.delay) === "instant"; const delay = instant ? Time.Milli.zero : Time.Milli.add(media, jitter); this.#out.delay.set(delay); - - this.#update.resolve(); - this.#update = Promise.withResolvers(); } // Fold a newly received frame into the reference. The reference anchors playback to the @@ -193,7 +184,7 @@ export class Sync { // First frame anchors the reference. if (currentRef === undefined) { - this.#setReference(ref); + this.#out.reference.set(ref); return; } @@ -229,13 +220,7 @@ export class Sync { if (sleep <= cap) return; // within budget: let the buffer grow instead of skipping ahead // Over the cap: re-anchor down so the resulting lookahead is exactly the cap. - this.#setReference(Time.Milli.add(ref, Time.Milli.sub(cap, delay))); - } - - #setReference(ref: Time.Milli): void { - this.#out.reference.set(ref); - this.#update.resolve(); - this.#update = Promise.withResolvers(); + this.#out.reference.set(Time.Milli.add(ref, Time.Milli.sub(cap, delay))); } // Re-anchor playback to the next frame received. Call this at an utterance boundary @@ -244,8 +229,6 @@ export class Sync { reset(): void { this.#out.reference.set(undefined); this.#late.clear(); - this.#update.resolve(); - this.#update = Promise.withResolvers(); } // The PTS that should be rendering right now, derived from the reference + buffer. @@ -268,7 +251,7 @@ export class Sync { } for (;;) { - // Switching to "instant" resolves `#update`, so frames parked here wake and leave. + // Switching to "instant" wakes the sleep below, so frames parked here leave. if (this.in.delay.peek() === "instant") return; // Sleep until it's time to decode the next frame. @@ -285,13 +268,29 @@ export class Sync { // Skip setTimeout for small sleeps; the timer resolution (~4ms) would overshoot. if (sleep < 5) return; - const wait = new Promise((resolve) => setTimeout(resolve, sleep)).then(() => true); - - const ok = await Promise.race([this.#update.promise, wait]); - if (ok) return; + if (await this.#sleep(sleep)) return; } } + // Sleeps for `ms`, or returns false early once anything the sleep was computed from changes. + // Releases every listener either way: frames sleep once each, so a listener left on a signal + // that never changes would pile up for the life of the player. + #sleep(ms: number): Promise { + return new Promise((resolve) => { + const wake = (ok: boolean) => { + clearTimeout(timer); + for (const dispose of disposes) dispose(); + resolve(ok); + }; + const timer = setTimeout(() => wake(true), ms); + const disposes = [ + this.in.delay.changed(() => wake(false)), + this.#out.delay.changed(() => wake(false)), + this.#out.reference.changed(() => wake(false)), + ]; + }); + } + static #formatDuration(ms: number): string { ms = Math.round(ms); if (ms < 1000) return `${ms}ms`; diff --git a/js/watch/src/video/decoder.ts b/js/watch/src/video/decoder.ts index d8bab4511a..c6319ff5e9 100644 --- a/js/watch/src/video/decoder.ts +++ b/js/watch/src/video/decoder.ts @@ -339,8 +339,7 @@ class DecoderTrack { } // Returns immediately when the latency is "instant". - const wait = this.sync.wait(timestamp).then(() => true); - const ok = await Promise.race([wait, effect.cancel]); + const ok = await effect.race(this.sync.wait(timestamp).then(() => true)); if (!ok) return; if (generation !== this.#discontinuity) return; // a rewind happened while waiting diff --git a/js/watch/src/video/source.ts b/js/watch/src/video/source.ts index 047060df11..b6e4ac831e 100644 --- a/js/watch/src/video/source.ts +++ b/js/watch/src/video/source.ts @@ -289,11 +289,6 @@ export class Source { effect.spawn(async () => { const available: Record = {}; - // `supported` comes from the consumer, so we cannot assume it ever settles. A rerun - // waits for the tasks it spawned, so an unraced probe would hold the next run shut - // for good. Captured here so it stays this run's promise once we start awaiting. - const cancelled = effect.cancel.then(() => undefined); - for (const [name, config] of Object.entries(renditions)) { const cacheKey = (supported as CacheableSupported)[supportCacheKey]; const key = cacheKey ? cacheKey(config) : JSON.stringify(config); @@ -304,7 +299,9 @@ export class Source { } else { let failed = false; try { - isSupported = await Promise.race([supported(config), cancelled]); + // `supported` comes from the consumer, so we cannot assume it ever settles. A + // rerun waits for the tasks it spawned, so an unraced probe would hold it shut. + isSupported = await effect.race(supported(config)); } catch (err) { failed = true; console.warn( diff --git a/py/moq-rs/README.md b/py/moq-rs/README.md index d3d6c3c7e5..6add3e7adb 100644 --- a/py/moq-rs/README.md +++ b/py/moq-rs/README.md @@ -151,8 +151,8 @@ client = moq.Client( - **`BroadcastProducer()`**. Create a broadcast to publish tracks into. - `.dynamic() → BroadcastDynamic` - - `.publish_audio(format, init, *, label=None) → MediaProducer`. `init` is required: an OpusHead or AudioSpecificConfig resolves the whole rendition. - - `.publish_video(format, init=b"", *, label=None, hint=None) → MediaProducer`. `init` may be empty for a format that resolves in band; a `VideoHint` pins catalog fields the stream can't reveal (bitrate) or publishes the catalog before the first keyframe. + - `.publish_audio(format, init, *, label=None, track=None) → MediaProducer`. `init` is required: an OpusHead or AudioSpecificConfig resolves the whole rendition. `track` names the track; otherwise a unique name is derived from the format. + - `.publish_video(format, init=b"", *, label=None, hint=None, track=None) → MediaProducer`. `init` may be empty for a format that resolves in band; a `VideoHint` pins catalog fields the stream can't reveal (bitrate) or publishes the catalog before the first keyframe. `track` names the track as in `publish_audio`. - `.encode_video(input, output, *, bandwidth=None) → VideoProducer`. Encode raw `VideoFrame`s inside the binding; `.write(frame)` each one. - `.encode_audio(name, input, output, *, bandwidth=None) → AudioProducer`. Encode raw PCM `AudioFrame`s; the codec is `output.codec`, e.g. `AudioCodec.opus()`, with `output.frame_duration_us` setting the Opus frame length. - `.finish()` diff --git a/py/moq-rs/moq/publish.py b/py/moq-rs/moq/publish.py index bbebf04259..2a5929954a 100644 --- a/py/moq-rs/moq/publish.py +++ b/py/moq-rs/moq/publish.py @@ -52,12 +52,14 @@ from .subscribe import BroadcastConsumer, GroupConsumer, TrackConsumer -def _audio_init(format: AudioFormat, init: bytes, label: str | None) -> MoqAudioInit: - return MoqAudioInit(format=format, data=init, label=label) +def _audio_init(format: AudioFormat, init: bytes, label: str | None, track: str | None = None) -> MoqAudioInit: + return MoqAudioInit(format=format, data=init, label=label, track=track) -def _video_init(format: VideoFormat, init: bytes, label: str | None, hint: VideoHint | None) -> MoqVideoInit: - return MoqVideoInit(format=format, data=init, label=label, hint=hint) +def _video_init( + format: VideoFormat, init: bytes, label: str | None, hint: VideoHint | None, track: str | None = None +) -> MoqVideoInit: + return MoqVideoInit(format=format, data=init, label=label, hint=hint, track=track) class MediaProducer: @@ -665,11 +667,13 @@ def publish_audio( init: bytes, *, label: str | None = None, + track: str | None = None, ) -> MediaProducer: """Publish one audio codec as a new track. `init` is required: audio resolves its whole rendition from those bytes (an OpusHead, an AudioSpecificConfig, a STREAMINFO). `label` is - the human-readable rendition name stored in the catalog.""" - return MediaProducer(self._inner.publish_audio(_audio_init(format, init, label))) + the human-readable rendition name stored in the catalog. `track` names the track; otherwise + a unique name is derived from the format.""" + return MediaProducer(self._inner.publish_audio(_audio_init(format, init, label, track))) def publish_video( self, @@ -678,11 +682,13 @@ def publish_video( *, label: str | None = None, hint: VideoHint | None = None, + track: str | None = None, ) -> MediaProducer: """Publish one video codec as a new track. `init` may be empty for a format that resolves in band. `hint` seeds catalog fields the stream can't reveal (bitrate) or publishes the catalog - before the first keyframe. See :class:`VideoHint`.""" - return MediaProducer(self._inner.publish_video(_video_init(format, init, label, hint))) + before the first keyframe. See :class:`VideoHint`. `track` names the track; otherwise a + unique name is derived from the format.""" + return MediaProducer(self._inner.publish_video(_video_init(format, init, label, hint, track))) def publish_container( self, @@ -722,11 +728,12 @@ def publish_video_stream( *, label: str | None = None, hint: VideoHint | None = None, + track: str | None = None, ) -> MediaStreamProducer: """Publish a video track fed by a raw byte stream (unknown frame boundaries). Only the self-delimiting formats work: `AVC3`, `HEV1`, `AV01`. There is no audio counterpart, since - audio has no frame boundaries to infer.""" - return MediaStreamProducer(self._inner.publish_video_stream(_video_init(format, b"", label, hint))) + audio has no frame boundaries to infer. `track` names the track as in :meth:`publish_video`.""" + return MediaStreamProducer(self._inner.publish_video_stream(_video_init(format, b"", label, hint, track))) def publish_container_stream(self, format: ContainerFormat) -> ContainerStreamProducer: """Publish a container fed by a raw byte stream, which recovers its own framing.""" diff --git a/py/moq-rs/tests/test_local.py b/py/moq-rs/tests/test_local.py index 6b64a0ce09..ad004a566f 100644 --- a/py/moq-rs/tests/test_local.py +++ b/py/moq-rs/tests/test_local.py @@ -237,6 +237,21 @@ async def test_video_publish_consume(): break +async def test_video_publish_named_track(): + origin = moq.OriginProducer() + broadcast = create_announced(origin, "video-named-test") + media = broadcast.publish_video(moq.VideoFormat.AVC3, h264_init(), track="hd") + assert media.name == "hd" + + consumer = origin.consume() + + async for announcement in consumer.announced(): + broadcast_consumer = await consumer.request_broadcast(announcement.prefix) + catalog = await broadcast_consumer.catalog() + assert list(catalog.video.keys()) == ["hd"] + break + + async def test_multiple_frames_ordering(): origin = moq.OriginProducer() broadcast = create_announced(origin, "ordering-test") diff --git a/quest/m0/README.md b/quest/m0/README.md index b71009509c..eb67865d6d 100644 --- a/quest/m0/README.md +++ b/quest/m0/README.md @@ -93,7 +93,6 @@ do not add another media abstraction or a renderer crate during stabilization. ## Quests -- [JS retention](/quest/m0/js-retention.md) - no `js/` package retains a listener, reaction, or task per frame, so a long-running player keeps a flat heap - [Release](/quest/m0/release.md) - the release moq.pro adopts: binding docs, an upgrade page, and a staging soak gate it rather than the merge - [Binary stats](/quest/m0/stats-binary/README.md) - an allocation-free stats tick and an on-demand FlatBuffers `.fb.z` flavor with a checked-in schema - [Audio jitter target](/quest/m0/audio-jitter-target/README.md) - the audio playout target is a measured estimate of arrival timing in both languages, not a round-trip guess diff --git a/quest/m0/js-retention.md b/quest/m0/js-retention.md deleted file mode 100644 index 7588c2e833..0000000000 --- a/quest/m0/js-retention.md +++ /dev/null @@ -1,70 +0,0 @@ -# [L] A long-running JS player or publisher keeps a flat heap - -## Goal - -No `js/` package retains a listener, promise reaction, or task per frame or -group. A four-camera player measured over minutes today grows from about 715k -to 2M heap nodes and doubles its major-GC pauses (#4024, #4025, #4026); after -this quest its heap stays flat for the life of a subscription or effect. - -The cause is one pattern: a per-frame wait that attaches to a value living as -long as the track or effect run, and never detaches. - -- `Promise.race` against a pending `Once` (`track.closed`, `producer.closed`) - registers a listener per call through `Once.then`, released only when the - track closes (#4024, lite and IETF subscribers). -- `Promise.race` against a pending native promise (`effect.cancel`, `Sync`'s - update promise) adds a reaction per call, released only on rerun (#4025). -- `Effect.spawn` keeps every settled task on an effect that never reruns, one - per group in `@moq/hang`'s container `Consumer` (#4026). - -## Plan - -Settled with the maintainer: - -- Add `effect.race(promise)` to `@moq/signals`: resolves with the promise's - value, or `undefined` once the current run tears down, and removes its - teardown listener either way. One promise; a site racing several combines - them with `race` first. A teardown that wins must also dispose the inner - `race`'s subscriptions, or each run leaks them; settle how at PR time. -- Export a free `race(values)` from `@moq/signals`, shaped like - `Promise.race`: it accepts promises and `GetPromise` values, subscribes to - pending ones (an already settled one wins at once), and disposes every - subscription when the first settles. A native promise's reaction cannot be - removed, so a caller never passes one that outlives the call. It sits beside - `Signal.race`. -- Every internal `effect.cancel` race moves to `effect.race`, and - `effect.cancel` is marked `@internal`, per the `js/CLAUDE.md` deprecation - convention. Its deletion is a published break, - left to [Remove effect.cancel](/quest/m1/effect-cancel.md). Both additions - are additive, so this quest targets main. -- `Effect.spawn` drops a task once it settles; a rerun still waits on pending - ones, and close still releases them without waiting. -- Audit every `Promise.race` and `Once.then` under `js/`, not only the reported - sites: signals, net, hang, watch, publish, room, and the rest. Convert each - race whose operand outlives the call. `Sync.wait` is internal, so it can wake - on signal changes it disposes, as the reporter's proxy does, rather than a - shared native promise. -- The reporter still saw about 48 `Promise` objects/s growing after their - patches, with reactions and closures flat (#4025). Find and fix it. -- Add a rule to `js/CLAUDE.md`: never race a value that outlives the call; use - `effect.race` or `race`. Read `PROMPTING.md` first and keep it one line. -- Update `doc/lib/js/signals.md` and the `effect.cancel` example in - `doc/lib/js/watch.md` inline. - -Tests, in per-PR CI: - -- Per site, a deterministic test that many frames or groups leave the - long-lived value's listener count unchanged, and for `race` and - `effect.race` themselves. -- One heap backstop: a long mock subscription through the player path under - forced GC, asserting heap does not grow with the number of frames. - -Reproduce each leak before fixing it. The issues carry measured numbers and -the reporter's local patches, useful as a reference but not as the design. - -## Closes - -- [#4024](https://github.com/moq-dev/moq/issues/4024) - close this issue when the quest finishes -- [#4025](https://github.com/moq-dev/moq/issues/4025) - close this issue when the quest finishes -- [#4026](https://github.com/moq-dev/moq/issues/4026) - close this issue when the quest finishes diff --git a/quest/m0/release.md b/quest/m0/release.md index ab2b22979f..81072cc0a6 100644 --- a/quest/m0/release.md +++ b/quest/m0/release.md @@ -86,5 +86,4 @@ Public API: none beyond the required quests. Wire: none. ## Required -- [JS retention](/quest/m0/js-retention.md) - the player no longer grows its heap per frame - The merged relay has soaked on moq.pro staging and the maintainer has signed it off diff --git a/quest/m1/2278-watch-absolute-wall-clock-latency-target-for-synchronized.md b/quest/m1/2278-watch-absolute-wall-clock-latency-target-for-synchronized.md index 5eaff2b733..80800d1b8a 100644 --- a/quest/m1/2278-watch-absolute-wall-clock-latency-target-for-synchronized.md +++ b/quest/m1/2278-watch-absolute-wall-clock-latency-target-for-synchronized.md @@ -30,7 +30,7 @@ a live-only broadcast with no archive timeline. ## Required -- [Publisher clocks](/quest/m1/publisher-clock.md) - built-in publishers populate the mapping applications read +- [CLI import clock](/quest/m1/cli-import-clock.md) - built-in publishers populate the mapping applications read ## Closes diff --git a/quest/m1/README.md b/quest/m1/README.md index 6f8b01252c..60c9c1c16f 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -21,19 +21,21 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Missing fetch group](/quest/m1/fetch-missing-group.md) - HTTP /fetch answers 404 and `moq fetch` fails cleanly for a group the track lacks - [libmoq hidden opt-in](/quest/m1/libmoq-hidden.md) - `moq_origin_announced` takes a `hidden` flag so C callers can list `.`-named broadcasts - [JS track tail](/quest/m1/js-track-tail.md) - a `@moq/net` subscriber delivers every group up to the declared end over lite and IETF, and JS publishers drain their groups before ending a subscription +- [lite-07 stream count](/quest/m1/lite-stream-count.md) - moq-lite-07 replaces SUBSCRIBE_DROP with a group-stream count in SUBSCRIBE_END, like moq-transport - [Rust track tail](/quest/m1/rust-track-tail.md) - a moq-net subscriber accepts groups that arrive after the subscription's end, and PublishDone carries the real stream count - [Session death error](/quest/m1/session-death-error.md) - a dying session ends its tracks with its own error in Rust and JS, never a clean end, `Dropped`, or `Cancel` +- [Signal.race cleanup](/quest/m1/signal-race.md) - `Signal.race` releases its signal listeners when its result loses a race - [Origin narrowing](/quest/m1/origin-narrowing.md) - a live origin grant narrows in place and ends the subscriptions it no longer covers, the deafen boundary #2714 asked for - [Auth embedder](/quest/m1/auth-embedder.md) - the lease owns its re-check clock, a gateway session holds a lease, and `Cluster::admit` scopes and tags origins in one call - [Auth expiry clock](/quest/m1/auth-expiry-clock.md) - moq-auth and the relay hold one fixed expiry deadline and honour the same skew allowance - [Binding surface](/quest/m1/binding-surface.md) - moq-ffi, libmoq, and every wrapper expose the decode delay, route source, and connection timing - [FFI shape](/quest/m1/ffi-shape/README.md) - the bindings mirror Rust's layers: net at the root, then media, json, audio, and video namespaces built from the handle below - [Track demand](/quest/m1/track-demand.md) - Rust and JS watch a track's subscribers through `demand()` alone -- [IETF subscriptions end cleanly](/quest/m1/ietf-publish-done.md) - a finished moq-transport track ends cleanly for its subscriber instead of reading PUBLISH_DONE as an error - [Data sections](/quest/m1/data-sections.md) - an application lists JSON and binary tracks in its own catalog section with its own per-track fields, published in one moq-mux call; data entries gain `bitrate` and `jitter` - [Broadcast close](/quest/m1/broadcast-close/README.md) - `close()` is the one way to end a broadcast in every language, a permanent retraction that leaves in-flight tracks alone - [Relay peer set](/quest/m1/relay-peer-set.md) - a wire consumer tells a client hop from a peer hop, and every mesh credential can mark a peer -- [Publisher clocks](/quest/m1/publisher-clock.md) - wire the shared clock through native and browser publisher restarts +- [CLI import clock](/quest/m1/cli-import-clock.md) - fMP4, TS, and FLV imports publish on the shared broadcast clock across restarts +- [Native clock fixtures](/quest/m1/native-clock-fixtures.md) - CI drives native capture through clock edge cases and asserts the published timestamps - [CLI inspection](/quest/m1/cli-inspect/README.md) - `moq ls` lists what is live and `moq fetch` reads a group over MoQ, and a guide shows how to inspect a relay - [JS caught up](/quest/m1/js-announce-caught-up.md) - @moq/net's announce consumer says when the initial set has landed, like Rust - [Bindings caught up](/quest/m1/announce-live-bindings.md) - moq-ffi, libmoq, and every wrapper yield the same flat announce event, `Live` included @@ -49,13 +51,13 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Tooling](/quest/m1/tooling/README.md) - justfiles become a one-line menu over `sh/`, one impact map scopes CI, and every workflow step runs a recipe - [Path patterns](/quest/m1/path-patterns.md) - one matcher for every predicate over broadcast paths: tokens, origins, interest - [In-band auth](/quest/m1/auth/README.md) - a session tells its peer what it may publish and subscribe to, unions tokens presented in band, and fails loud on an out-of-scope publish -- [Test ports](/quest/m1/tokio-test-ports.md) - moq-tokio tests bind QUIC and WebSocket on independent ephemeral ports, so a parallel run cannot collide - [Decoded frame ownership](/quest/m1/decoded-frames.md) - retain moq-video Frames across bindings, with native views or CPU conversion as needed - [C++ through moq-ffi](/quest/m1/cpp/README.md) - generated C++ over moq-ffi with futures and expected-style errors, shipped as a tarball, vcpkg, and Conan, and adopted by the OBS plugin - [OBS native codecs](/quest/m1/obs-moq-video/README.md) - remove FFmpeg decoding dependencies, deliver GPU frames, and use native audio/video encoders - [Audio codecs](/quest/m1/audio-codecs/README.md) - platform audio codecs, explicit unsupported cases, and channel layouts up to 7.1 - [Opus descriptions](/quest/m1/audio-opus-input.md) - validate headers and honor codec clock, pre-skip, and gain - [Capture formats](/quest/m1/audio-capture-format.md) - unsupported overrides refuse before device open and channel counts cannot wrap +- [NVENC teardown](/quest/m1/nvenc-teardown.md) - a rejected NVENC encode no longer hangs process shutdown - [NVENC recovery](/quest/m1/nvenc-recovery.md) - partial initialization and rejected rate changes preserve valid state - [GPU pool reservation](/quest/m1/gpu-pool-reservation.md) - a full GPU frame pool is a `None` reservation the caller drops on, not an error to match - [Transcode source](/quest/m1/transcode-source.md) - select a rendition the chosen backend can actually decode diff --git a/quest/m1/auth/README.md b/quest/m1/auth/README.md index 960d8d6b1d..f2e080d67e 100644 --- a/quest/m1/auth/README.md +++ b/quest/m1/auth/README.md @@ -90,13 +90,11 @@ existing lite-06 ALPN. ## Quests - [Interop grants](/quest/m1/auth/interop.md) - the interop matrix asserts - each lite-06 cell's grant and that a publish outside it fails loud + each AUTH cell's grant and that a publish outside it fails loud - [Unauthorized reset](/quest/m1/auth/unauthorized.md) - a subscription that loses access resets with a dedicated UNAUTHORIZED stream code - [Relay tokens](/quest/m1/auth/relay-refresh.md) - the relay verifies tokens sent in band, unions their grants, and cancels only work that loses access -- [moq-transport](/quest/m1/auth/moq-transport.md) - the same exchange as a - setup-option extension on draft-17+, specified in a new draft - [Bindings](/quest/m1/auth/bindings.md) - grants and tokens reach every binding through moq-ffi and libmoq - [Token in band](/quest/m1/auth/token-in-band.md) - the credential can leave diff --git a/quest/m1/auth/interop.md b/quest/m1/auth/interop.md index d1f33f4970..a376e202f7 100644 --- a/quest/m1/auth/interop.md +++ b/quest/m1/auth/interop.md @@ -2,7 +2,8 @@ ## Goal -`just test interop --all` fails a lite-06 cell whose client did not receive +`just test interop --all` fails a cell with AUTH (lite-06, or moq-transport +draft-17+ with MoQ Auth) whose client did not receive the grant its relay token implies, and a new negative round passes only when a client publishing outside its grant fails loud with `Unauthorized`. Today the matrix checks media alone, so a malformed AUTH_OK that leaves a session @@ -15,6 +16,6 @@ parseable line; `test/interop/interop.sh` compares it against the token the cell minted. The negative round mints a token that excludes the published path and expects the publisher's session to close with `Unauthorized` naming the path, and the subscriber to see -nothing. Cells below lite-06 skip both checks, as do binding clients until +nothing. Cells without AUTH skip both checks, as do binding clients until [Bindings](/quest/m1/auth/bindings.md) gives them a grant to print; that quest adds them to the same assertion. No public API or wire change. diff --git a/quest/m1/auth/moq-transport.md b/quest/m1/auth/moq-transport.md deleted file mode 100644 index 8bf5a64270..0000000000 --- a/quest/m1/auth/moq-transport.md +++ /dev/null @@ -1,101 +0,0 @@ -# [M] moq-transport - -## Goal - -The grant exchange works on a moq-transport session between two moq-net -peers, with the same grant lifecycle through `Session::auth()`. This initial -IETF encoding supports prefix-representable grants and explicitly refuses -other pattern unions. An IETF publisher fails loud on an out-of-scope -PUBLISH_NAMESPACE before sending it. A -new `drafts/draft-lcurley-moq-auth.md` specifies it as an extension a -conforming peer can ignore. - -## Plan - -### Draft - -`drafts/draft-lcurley-moq-auth.md`, modeled on `draft-lcurley-moq-solicit.md` -for the setup option and on `draft-lcurley-moq-cluster.md` for the IANA -tables. It declares: - -- Setup Option `AUTH`, an even key in the `0x40B5x` series the cluster and - solicit options use, value `1`. Both endpoints send it; the extension is - negotiated only when both did, per the moqt extension rule that the set is - fixed once both SETUPs are seen. Draft-17 and later only, since that is the - first unified SETUP, the same gate `cluster::supported` applies. -- One AUTH request stream per token, living as long as the token, the way a - subscribe request stream outlives its SUBSCRIBE_OK. AUTH carries a Request - ID like every request plus the token; AUTH_OK and AUTH_ERROR carry the lite - fields and may repeat on the stream with the lite update and revoke - meanings; closing the stream withdraws the token. Track namespaces are - tuples on this wire, so a prefix is a namespace tuple, matching how - SUBSCRIBE_NAMESPACE spells one. Sent only after negotiation; an endpoint - that receives one without negotiating closes with PROTOCOL_VIOLATION, which - is what moq-net already does for an unknown request stream. -- Which existing codes AUTH_ERROR reuses: `UNAUTHORIZED`, `EXPIRED_AUTH_TOKEN`, - `MALFORMED_AUTH_TOKEN`, and `NOT_SUPPORTED` from the request error registry. - An unrepresentable pattern grant returns `NOT_SUPPORTED`, surfaced by the - public handle as `Unsupported`, rather than widening it into a namespace - prefix. The shared request error mapping already supports that code. -- A note relating it to the AUTHORIZATION TOKEN setup option: a token - presented there is the connection credential an empty AUTH refers to. - -Cite [moq-wg #1854](https://github.com/moq-wg/moq-transport/issues/1854) in -the introduction: the grant answers which role a peer will play. Run -`just drafts check`; `doc/.vitepress/drafts.ts` discovers the file by name. - -### Grant conversion - -Keep the public grant pattern-valued. Convert only unions that can be expressed -exactly as namespace-prefix tuples; a subtree and the all-path grant are -representable, while exact-path, suffix, and segment-wildcard grants are not. -Validate the whole publish/subscribe union before emitting AUTH_OK. Never -silently drop a member, broaden it to a literal head, or leave the request -waiting indefinitely. An unsupported initial grant refuses that token; an -unsupported update revokes its previous grant with AUTH_ERROR and closes the -stream. Other tokens and work still covered by them remain on the session. - -Full-pattern IETF encoding would require a separately negotiated extension -revision and is outside this initial prefix-tuple contract. Document this -capability limit in the draft and token-in-band guidance. - -### Code - -`rs/moq-net/src/ietf/auth.rs` beside `solicit.rs` and `cluster.rs`: the setup -option round trip with the tri-state `from_setup` shape solicit uses, the -three messages as `Message` impls with IDs from the draft, and negotiation -recorded on the peer state. `run_dispatch` in `ietf/session.rs` routes an AUTH -request stream to the shared `auth::Handle` the lite wire already uses; -`ietf::start` opens the empty-token -stream after SETUP when negotiated and answers the peer's from the origin -handles exactly as lite does, and `add` opens further ones. The fail-loud -check moves into the shared handle so the IETF subscriber half consults it -before a new PUBLISH_NAMESPACE, aborting with `Unauthorized` and the path. -A shrinking grant instead withdraws previously authorized publications and -cancels only affected subscriptions, preserving the session as lite does. - -`js/net/src/ietf/auth.ts` mirrors it, wired through `handshake.ts` like -`Ietf.Cluster.intoSetup` and `fromSetup`, and `ietf/connection.ts` dispatches -the request streams. - -Version-gate on draft-17+; earlier drafts leave `grant()` at `None` and `add` -at `Unsupported`. - -### Tests - -Cross-language fixtures cover prefix unions and the root grant, refusal of -exact/suffix/segment-wildcard and mixed unions, plus an update from a supported -grant to an unsupported one that revokes the old permission without changing -other tokens. Assert `Unsupported` completes promptly and no unauthorized -PUBLISH_NAMESPACE is sent. - -Setup option round trip on every supported draft and absence on 14 to 16; -negotiation requires both sides; grants from scoped origins over an IETF -session in Rust, JS, and across; two tokens union and closing one shrinks the -union without disconnecting, cancelling only work that loses authorization; -an out-of-scope new publication aborts before any PUBLISH_NAMESPACE is written; a -peer without the option (the relay built without it, and the interop runner's -reference relay) sees no AUTH stream and keeps working. Run -`just test interop --all`. - -On main, additive. diff --git a/quest/m1/auth/token-in-band.md b/quest/m1/auth/token-in-band.md index 6b9077aaf1..5bd8bdbff4 100644 --- a/quest/m1/auth/token-in-band.md +++ b/quest/m1/auth/token-in-band.md @@ -75,5 +75,3 @@ Additive. widen path the configured tokens reuse - [Bindings](/quest/m1/auth/bindings.md) - supplies the client surface the new token setters sit beside -- [moq-transport](/quest/m1/auth/moq-transport.md) - supplies the IETF AUTH - exchange the setup-option token pairs with diff --git a/quest/m1/cli-import-clock.md b/quest/m1/cli-import-clock.md new file mode 100644 index 0000000000..ccdf7f764b --- /dev/null +++ b/quest/m1/cli-import-clock.md @@ -0,0 +1,34 @@ +# [M] CLI imports publish on the broadcast clock + +## Goal + +`moq import` of fMP4, TS, and FLV publishes timestamps on the shared broadcast +clock, like native capture and `js/publish` already do, including source +restarts, late first frames, and real idle gaps. Today the imports publish +source PTS verbatim against a wall clock sampled at startup, so a TS feed with +a large starting PTS or a late first frame advertises the wrong wall time. + +## Plan + +Use `moq_mux::Clock` and `SourceMap` with the root catalog `clock`; this adds +no clock API or catalog field. Select each source's initial mapping once, +account for a delayed first frame, and translate source resets onto the same +monotonic clock while preserving real idle gaps. System-wall adjustments do not +retime a running broadcast or old archive records. Preserve allowed B-frame +ordering within a group. + +- fMP4 is passthrough, so translation must rewrite `tfdt`. +- A muxed source needs one mapping for all of its tracks, since interleaved + audio and video can step back further than `SourceMap::MAX_REORDER`. +- Keep conversion at the adapter boundary and refuse an unmappable source + explicitly. Discontinuity markers signal the existing playhead contract; + they do not replace the wall epoch. + +CI fixtures drive the import path, not only the clock helper: simultaneous +A/V, a late first frame, a restart to zero, a restart after idle, and retained +archive playback. Update the import docs. + +## Related + +- [Native clock fixtures](/quest/m1/native-clock-fixtures.md) - the same scenarios through native capture +- [GStreamer clock](/quest/m1/3021-moq-gst-anchor-generated-media-timelines-to-wall-clock.md) - separate source adapter diff --git a/quest/m1/effect-cancel.md b/quest/m1/effect-cancel.md index 037be1cdd3..540fa9db1a 100644 --- a/quest/m1/effect-cancel.md +++ b/quest/m1/effect-cancel.md @@ -3,15 +3,10 @@ ## Goal `@moq/signals` no longer exports `Effect.cancel`. Its only use was racing a -per-run pending promise, which leaks a reaction per call; -[JS retention](/quest/m0/js-retention.md) deprecates it in favor of -`effect.race` and moves every internal caller off it. +per-run pending promise, which leaks a reaction per call. It is already +marked `@internal`, and every internal caller uses `effect.race` instead. ## Plan A published break to `@moq/signals`, so it targets dev. Delete the getter and the promise backing it, and fix any caller or doc the deprecation left behind. - -## Required - -- [JS retention](/quest/m0/js-retention.md) - adds `effect.race` and moves the callers diff --git a/quest/m1/ietf-publish-done.md b/quest/m1/ietf-publish-done.md deleted file mode 100644 index 27e2f8e671..0000000000 --- a/quest/m1/ietf-publish-done.md +++ /dev/null @@ -1,21 +0,0 @@ -# [S] IETF subscriptions end cleanly - -## Goal - -A moq-transport subscription whose publisher finishes the track ends cleanly -for its subscriber, on every negotiated draft, the way it does over moq-lite. -Today it ends in error. - -## Plan - -Found while testing announce-to-serve over the mock session -(`rs/moq-net/tests/announce_to_serve.rs`): a finished track arrives whole, -then the subscriber reports `short buffer` instead of the end. The Rust -publisher writes PUBLISH_DONE before closing the subscribe stream, and the -Rust subscriber's `run_subscribe` waits on `stream.reader.poll_closed`, which -treats any trailing bytes as a decode error, so it never reads the message. -Decode PUBLISH_DONE and map its status to a clean finish or an abort. Check -`js/net`'s IETF subscriber for the same gap. - -Once fixed, the IETF case in `announce_to_serve.rs` stops special-casing the -track's end: it should match the local and moq-lite runs exactly. diff --git a/quest/m1/lite-stream-count.md b/quest/m1/lite-stream-count.md new file mode 100644 index 0000000000..30f8a05363 --- /dev/null +++ b/quest/m1/lite-stream-count.md @@ -0,0 +1,43 @@ +# [M] moq-lite-07 counts group streams instead of dropping groups + +## Goal + +On moq-lite-07, a subscriber knows a subscription has delivered everything +once it has seen as many group streams as the publisher opened, the way +moq-transport's PUBLISH_DONE Stream Count works. SUBSCRIBE_DROP is gone from +lite-07: a group the publisher skipped or never opened is simply not counted, +so nothing has to name it. Published versions (lite-01 to -06) keep decoding +SUBSCRIBE_DROP unchanged. + +Where reliable reset is negotiated, the count is exact and the subscriber +waits for nothing else. Where it is not (browsers today), a stream reset +before its header arrived is still invisible, so the track-tail grace stays. + +## Plan + +- Wire: SUBSCRIBE_END gains `Stream Count`, the number of group streams the + publisher opened for this subscription. The publisher sends it once every + group stream below the end has been opened (not finished), like + PUBLISH_DONE, so the boundary arrives slightly later than today. Remove + SUBSCRIBE_DROP and its type from lite-07 and reword the Subscribe Stream + section: the FIN follows once every counted stream has finished or been + reset. lite-07 is unpublished, so this changes it in place; update + `drafts/draft-lcurley-moq-lite.md` and its changelog. +- Rust and JS publishers count the streams they open per subscription and + send the count; a relay counts its own downstream streams, never forwarding + the upstream count. +- Subscribers on lite-07 stop waiting once the count is reached, accepting a + late stream below the end within the grace. On lite-05 and -06, the + DROP accounting from JS track tail (#4086) stays as it is. +- Tests in both languages: a late stream after SUBSCRIBE_END, a skipped group + that is never counted, a reset stream, and a count of zero. Add a Rust-JS + interop case. + +This lands before lite-07 is published. Rust has never sent or acted on +SUBSCRIBE_DROP, so [Rust track tail](/quest/m1/rust-track-tail.md) builds its +lite accounting on the count rather than on drops. + +## Related + +- [Rust track tail](/quest/m1/rust-track-tail.md) - builds on this count for moq-lite +- [Reliable stream reset](/quest/m1/quic/reliable-reset.md) - makes the count exact by keeping a reset stream's header diff --git a/quest/m1/native-clock-fixtures.md b/quest/m1/native-clock-fixtures.md new file mode 100644 index 0000000000..88b158e028 --- /dev/null +++ b/quest/m1/native-clock-fixtures.md @@ -0,0 +1,19 @@ +# [S] Native capture proves the broadcast clock in CI + +## Goal + +Per-PR CI drives the native video and audio capture publishers through clock +edge cases and asserts the published timestamps: simultaneous A/V, a late +first frame, a restart to zero, a restart after idle, a system-wall +adjustment, and retained archive playback. Anything they catch is fixed here. + +## Plan + +Native video already maps the device timeline onto `catalog.clock()` at open, +and native audio stamps arrival on it. The fixtures exercise publisher +integration with a synthetic device source and an injected clock, rather than +only the clock helper. No new clock API or catalog representation. + +## Related + +- [CLI import clock](/quest/m1/cli-import-clock.md) - the same scenarios through `moq import` diff --git a/quest/m1/nvenc-teardown.md b/quest/m1/nvenc-teardown.md new file mode 100644 index 0000000000..cde5459bee --- /dev/null +++ b/quest/m1/nvenc-teardown.md @@ -0,0 +1,26 @@ +# [S] A failed NVENC encode does not hang shutdown + +## Goal + +After NVENC rejects an encode (P7 with high-quality tuning returns +`InvalidParam`), the process shuts down promptly. Today it hangs on exit. + +## Plan + +Reproduce it first with the `encode-presets` example from #4099. Suspects, +from reading the code: `encode::Sink` runs NVENC on the `moq-video-encode` +thread, and `Worker::drop` joins it. That thread then drops the encoder, and +`Session::drop` calls a synchronous end-of-stream `encode_picture`. A +`Pending` that did not finish runs a blocking `lock_bitstream`. Either one can +wedge on a session the driver already refused, and the join then waits +forever. + +Fix the cause, for example by skipping end-of-stream on a session whose +encode failed. A timeout on the join doesn't count as a fix. Add a regression +test that forces the failing configuration on hardware where NVENC exists and +asserts teardown returns. Wire it into the nightly GPU lane if there is one; +otherwise say where it runs. + +## Related + +- [NVENC recovery](/quest/m1/nvenc-recovery.md) - the other NVENC failure path, rate changes and partial init diff --git a/quest/m1/publisher-clock.md b/quest/m1/publisher-clock.md deleted file mode 100644 index 23f437f7c9..0000000000 --- a/quest/m1/publisher-clock.md +++ /dev/null @@ -1,33 +0,0 @@ -# [L] Use the broadcast clock across media publishers - -## Goal - -Native video/audio capture, CLI imports, and `js/publish` publish timestamps -against the shared broadcast clock, including source and encoder restarts. -A live-only publisher exposes its clock without constructing an archive. - -## Plan - -Use the dev clock owner and catalog schema. Select each source's initial mapping -once, account for delayed first frames, and translate source resets onto the -same monotonic clock while preserving real idle gaps. Share the clock between -audio and video. System-wall adjustments do not retime a running broadcast or -old archive records. Preserve allowed B-frame ordering within a group. - -Keep source-specific timestamp conversion at the adapter boundary. Refuse an -unmappable source explicitly. Discontinuity markers signal the existing -playhead contract; they do not replace the wall epoch. In `js/publish`, the -video encoder marks a break through `Container.Legacy.Producer.cut()` whenever -its encode loop stops (demand gap or capture swap). The audio encoder writes its -own marker on a demand gap only: an audio pipeline rebuild and the framer's -input-gap reset still write none. - -Add CI fixtures for simultaneous A/V, late first frames, restart to zero, -restart after idle, system-wall adjustment, and retained archive playback. -The fixtures must exercise publisher integration rather than only the clock -helper. Update publisher and import docs; this quest adds no new clock API or -catalog representation. GStreamer's clock observation remains its own quest. - -## Related - -- [GStreamer clock](/quest/m1/3021-moq-gst-anchor-generated-media-timelines-to-wall-clock.md) - separate source adapter diff --git a/quest/m1/rust-track-tail.md b/quest/m1/rust-track-tail.md index 09a528d793..5af1ff663d 100644 --- a/quest/m1/rust-track-tail.md +++ b/quest/m1/rust-track-tail.md @@ -47,6 +47,10 @@ session in `rs/moq-net/tests/support`. Add a Rust-JS interop case to `just test smoke --all` for a publisher that ends a track with a group still in flight. +## Required + +- [lite-07 stream count](/quest/m1/lite-stream-count.md) - the moq-lite accounting this builds on, instead of SUBSCRIBE_DROP + ## Related - [JS track tail](/quest/m1/js-track-tail.md) - the same rule in `@moq/net` diff --git a/quest/m1/signal-race.md b/quest/m1/signal-race.md new file mode 100644 index 0000000000..90695f5847 --- /dev/null +++ b/quest/m1/signal-race.md @@ -0,0 +1,30 @@ +# [S] Signal.race releases its listeners when it loses a race + +## Goal + +`Signal.race` from `@moq/signals` no longer leaves a `changed` listener on +each signal when its own promise is raced and loses. Today it disposes them +only when one of the signals changes, so `js/net/src/origin.ts`'s +`#changed()`, raced against `closed` in `connection/forward.ts`, keeps +listeners for as long as the table stays quiet. Awaiting it directly still +works, with the same signature. + +## Plan + +Make it consistent with the free `race()` and `effect.race` from +[JS retention](https://github.com/moq-dev/moq/pull/4085), which already +subscribe to `Once`/`GetPromise` values and dispose them when the race +settles. `Signal.race` returns that same kind of awaitable instead of a +native promise: it attaches its signal listeners when first awaited or +subscribed, and releases them when it settles or when its last subscriber +detaches. Racing it through `race()` or `effect.race` then cleans up both +sides. Settle the exact return type at PR time, keeping `await` source +compatible; if the change is a published type break, stop and bring it back. + +Audit the callers in `js/net` (`announced`, `broadcast`, `group`, `origin`, +`track`, both subscribers) for the ones that race the result, and add a +listener-count test for the `origin.ts` case that fails today. + +## Required + +- JS retention (#4085) merged, which adds the free `race()` this builds on diff --git a/quest/m1/tokio-test-ports.md b/quest/m1/tokio-test-ports.md deleted file mode 100644 index f94afdb374..0000000000 --- a/quest/m1/tokio-test-ports.md +++ /dev/null @@ -1,17 +0,0 @@ -# [XS] moq-tokio tests bind their ports independently - -## Goal - -moq-tokio's integration tests never fail on a port another process or -parallel test holds. `websocket_forbidden_does_not_end_a_quic_connect` -(`rs/moq-tokio/tests/broadcast.rs`) fails about 1 in 15 local runs today. - -## Plan - -`test_server()` takes an ephemeral UDP port and then binds TCP on the same -number, which was never reserved on TCP. Bind the QUIC and WebSocket -listeners each on `:0` and hand the test client the WebSocket port -explicitly, adding a client config override for the fallback URL's port if -none exists (test-only if possible; otherwise note it as a public API -addition). No retry: this replaces the bounded bind retry -[#4055](https://github.com/moq-dev/moq/pull/4055) added. No wire change. diff --git a/quest/m2/2819-moq-video-carry-pipewire-dma-bufs-safely-into-the-vulkan.md b/quest/m2/2819-moq-video-carry-pipewire-dma-bufs-safely-into-the-vulkan.md index b1ec015db1..aa313e8dba 100644 --- a/quest/m2/2819-moq-video-carry-pipewire-dma-bufs-safely-into-the-vulkan.md +++ b/quest/m2/2819-moq-video-carry-pipewire-dma-bufs-safely-into-the-vulkan.md @@ -46,7 +46,7 @@ The PipeWire producer must therefore retain the dequeued buffer until the last ` - \[x] Document the wgpu device feature required for DMA-BUF import. A custom device-creation helper is unnecessary with wgpu 30. - \[ ] Import multi-plane NV12 with explicit DRM modifier plane layouts. - \[ ] Copy imported NV12 Y/UV planes into wgpu-sampleable R8/RG8 textures without touching the CPU. -- \[ ] Handle unsupported Intel tiling with a VAAPI VPP re-tile path. The current `moq-vaapi` API does not expose VPP yet. +- \[ ] Handle unsupported Intel tiling with a VAAPI VPP re-tile path. `Processor` blits exist (moq-vaapi 0.1.0); this item is the renderer using one when a modifier will not import. #### Validation gates @@ -75,4 +75,5 @@ into VAAPI or NVENC. ## Related +- [Capture multi-plane PipeWire cameras](/quest/m2/pipewire-camera-planes.md) - separate memory blocks from a camera, which is the capture offer rather than this renderer import - [#2893: video: validate PipeWire DMA-BUF capture on KDE hardware](/quest/m3/2893-video-validate-pipewire-dma-buf-capture-on-kde-hardware.md) - related open work diff --git a/quest/m2/README.md b/quest/m2/README.md index 33404a8e59..1a0426d872 100644 --- a/quest/m2/README.md +++ b/quest/m2/README.md @@ -33,10 +33,13 @@ upstream release waits in [m4](/quest/m4/README.md). - [Media Foundation encode](/quest/m2/audio-encode-mediafoundation.md) - Windows encodes AAC-LC - [MediaCodec decode](/quest/m2/audio-decode-mediacodec.md) - Android decodes HE-AAC, multichannel AAC, and what else the device offers - [MediaCodec encode](/quest/m2/audio-encode-mediacodec.md) - Android encodes AAC-LC +- [AAC encode refusal](/quest/m2/aac-encode-refusal.md) - AAC config encode refuses channel counts it cannot name, on dev - [Video codec coverage](/quest/m2/video-codec-coverage.md) - prioritize remaining native AV1 and portable decoder gaps - [#2147](/quest/m2/2147-moq-video-10-bit-hevc-and-av1-support-in-the-nvidia-codec.md) - moq-video: 10-bit HEVC and AV1 support in the NVIDIA codec path +- [NVENC buffer pool](/quest/m2/nvenc-pool.md) - NVENC reuses input and output buffers instead of allocating per frame, if a benchmark shows it wins - [Direct3D11 render import](/quest/m2/render-d3d11.md) - Windows presents without downloading every frame to system memory - [Intra-refresh GOPs](/quest/m2/intra-refresh/README.md) - video with periodic intra refresh publishes, imports, and tunes in cleanly with one group per sweep and a catalog `warmup` +- [Capture multi-plane PipeWire cameras](/quest/m2/pipewire-camera-planes.md) - I420 and NV12 cameras that deliver one memory block per plane - [#2819](/quest/m2/2819-moq-video-carry-pipewire-dma-bufs-safely-into-the-vulkan.md) - moq-video: carry PipeWire DMA-BUFs safely into the Vulkan renderer - [Unreal prototype](/quest/m2/unreal.md) - a UE5 module on the C++ package with exceptions disabled, rendering a subscribed broadcast to a texture - [Unity prototype](/quest/m2/unity.md) - the C# package under IL2CPP, playing subscribed audio @@ -44,6 +47,7 @@ upstream release waits in [m4](/quest/m4/README.md). - [vcpkg registry](/quest/m2/cpp-vcpkg.md) - a registry we own serves the prebuilt package to `vcpkg` manifests - [Conan remote](/quest/m2/cpp-conan.md) - a remote we own serves the same tarball to `conan install` - [Compressed tracks](/quest/m2/flate/README.md) - any track compresses per group from every language, not only the JSON modes +- [Binary delta stats](/quest/m2/stats-delta.md) - an on-demand varint delta flavor of every stats track, if relay encode CPU still matters after the JSON fixes - [#3115](/quest/m2/3115-moqsink-the-publication-has-no-generation-so-a-flush.md) - moqsink: a flushing restart after EOS opens a new publication generation - [Redundant ingest](/quest/m2/redundant-ingest.md) - decide whether two publishers sharing one epoch may splice, and who declares the incumbent dead before the keep-alive does - [Multipath spike](/quest/m2/multipath-spike.md) - whether bonded contribution over multipath QUIC is worth building, given it needs noq on both ends @@ -56,6 +60,7 @@ upstream release waits in [m4](/quest/m4/README.md). - [L4S on the backbone](/quest/m2/quic-ecn.md) - an ECT(1) option in the fork, an `ecn` config knob, and a dualpi2 measurement - [Careful resume on reconnect](/quest/m2/quic-careful-resume.md) - a redial starts at the previous connection's rate - [Keep-alive by deadline](/quest/m2/quic-keep-alive.md) - a PING only when the idle deadline nears, no fixed timer +- [noq socket close](/quest/m2/noq-socket-close.md) - noq releases an endpoint's socket on close, so moq-tokio drops its wrapper - [Bounded announce prefix table](/quest/m2/announce-prefix-table.md) - compress repeated path tuples on each ordered lite-08 announce stream, with bounded state and measured QUIC-byte savings - [Drop the hidden cluster exemption](/quest/m2/hidden-exemption.md) - relays stop forcing hidden broadcasts on cluster peers once every peer opts in on the wire - [Routing cost domains](/quest/m2/routing-cost-domains.md) - design operator boundaries and policy without adding incomparable costs diff --git a/quest/m2/aac-encode-refusal.md b/quest/m2/aac-encode-refusal.md new file mode 100644 index 0000000000..3008bf0702 --- /dev/null +++ b/quest/m2/aac-encode-refusal.md @@ -0,0 +1,22 @@ +# [S] AAC encode refuses a channel count it cannot name + +## Goal + +Writing an AudioSpecificConfig for a channel count that no AAC +channelConfiguration names is an error, not a stereo config with a warning, +in `moq_mux::codec::aac::Config::encode` and `@moq/hang`'s +`audioSpecificConfig`. This mirrors the parse side, which since #4093 refuses +reserved values instead of guessing stereo. + +## Plan + +Both functions become fallible, a published API break in each language, so +this targets `dev`. Counts with a PCE-free configuration map as today. For +the others, either write channelConfiguration 0 with a PCE derived from the +layout, or refuse. Pick one at PR time and apply it in both languages. Test +every count from 1 to 8 and one beyond. + +## Related + +- [AAC PCE](https://github.com/moq-dev/moq/pull/4093) - the parse half +- [Layout](/quest/m1/audio-codecs/layout.md) - the layout a PCE would be derived from diff --git a/quest/m2/noq-socket-close.md b/quest/m2/noq-socket-close.md new file mode 100644 index 0000000000..3fa7a9621b --- /dev/null +++ b/quest/m2/noq-socket-close.md @@ -0,0 +1,21 @@ +# [M] noq releases an endpoint's socket on close + +## Goal + +A noq endpoint can close its UDP socket, and report when it has, without +waiting for every connection handle to drop. moq-tokio then deletes the +closable socket wrapper that #4087 added to make `Listener::close` release the +port. + +## Plan + +In moq-dev/noq, add an endpoint operation that closes the endpoint, waits +until each connection has sent its close, and then releases the socket. Later +sends are dropped and receives end. Test it there. Cut a noq release, bump the +pin, and replace moq-tokio's wrapper with the upstream call. The Go +`TestReconnectAcrossRelayRestart` and moq-tokio's +`close_releases_quic_socket` stay green. + +## Related + +- [Listener close](https://github.com/moq-dev/moq/pull/4087) - the local wrapper this replaces diff --git a/quest/m2/nvenc-pool.md b/quest/m2/nvenc-pool.md new file mode 100644 index 0000000000..c1723df728 --- /dev/null +++ b/quest/m2/nvenc-pool.md @@ -0,0 +1,18 @@ +# [M] NVENC reuses its input and output buffers + +## Goal + +The NVENC backend stops allocating per frame. At 1080p, creating the output +bitstream and input buffer (or registering the CUDA resource) is about half +of frame-to-packet time. A benchmark shows the pooled path is faster at +720p and 1080p, with the numbers in the PR. If it doesn't win, abandon the +quest and report the numbers. + +## Plan + +`rs/moq-video/src/encode/backend/nvenc.rs` calls `create_output_bitstream`, +`create_input_buffer` (CPU frames) or `register_generic_resource` (CUDA +frames) on every `encode`, and frees them all at the end of the call. Keep a +small pool sized by the frames in flight, which is one today since B-frames +are off. Re-register a CUDA resource only when its pointer changes. Measure +with the `encode-presets` example from #4099. diff --git a/quest/m2/obs-linux-gpu.md b/quest/m2/obs-linux-gpu.md index 7bad013317..7d29c34ec7 100644 --- a/quest/m2/obs-linux-gpu.md +++ b/quest/m2/obs-linux-gpu.md @@ -7,7 +7,7 @@ A supported Linux OBS graphics/encoder combination publishes composited video wi ## Plan - Start with an API feasibility probe. OBS exposes DMA-BUF import in `graphics.h`; that is not proof its compositor textures can be exported. Determine whether EGL/GL allocation export is available, or whether an upstream OBS hook or an encoder-owned exportable render target is required. -- The current VAAPI adapter converts every surface through I420 and uploads NV12. Coordinate its native import with the existing VAAPI quest; OBS allocation export alone cannot remove that download. +- moq-video encodes a `Surface::DmaBuf` on VAAPI without a download, and scales one through VPP (moq-vaapi 0.1.0). This quest still has to turn an OBS compositor texture into a DMA-BUF that import accepts. - Negotiate DRM device, fourcc, plane offsets/strides, modifiers, and synchronization. Reuse `Surface::DmaBuf` and the hardware encoder's real import path, retaining allocation ownership until completion. A borrowed fd or an importable packed RGB texture does not prove the encoder accepts NV12 on the same device. - Prefer direct import; otherwise GPU-convert into exportable NV12 surfaces. Document and measure each GPU copy. Keep CPU staging as a visible fallback, not as the successful accelerated result. - Validate at least one Intel/AMD VAAPI path on hardware before promising broad support. Scope NVIDIA/CUDA interoperability separately if it needs another allocation or synchronization strategy. Test unsupported modifiers, multi-plane buffers, fd closure, cancellation, device loss, resize, and repeated pool reuse. @@ -15,10 +15,9 @@ A supported Linux OBS graphics/encoder combination publishes composited video wi ## Required -- [VAAPI encode and decode](/quest/m4/video-vaapi.md) - supply the native DMA-BUF encoder import required by the Intel/AMD no-readback path - - [Encoder adapter](/quest/m1/obs-moq-video/adapter.md) - frame ownership, queue policy, packet output, and comparison baseline ## Related +- [VAAPI encode and decode](/quest/m4/video-vaapi.md) - H.265 and checked-in bindings. The DMA-BUF encoder import this quest needs is already on main (moq-vaapi 0.1.0). - [Video hardware validation](/quest/m3/video-hardware.md) - native input and encoder acceptance need hardware evidence diff --git a/quest/m2/pipewire-camera-planes.md b/quest/m2/pipewire-camera-planes.md new file mode 100644 index 0000000000..d33e8fb114 --- /dev/null +++ b/quest/m2/pipewire-camera-planes.md @@ -0,0 +1,16 @@ +# [M] Capture multi-plane PipeWire cameras + +## Goal + +A PipeWire camera that delivers I420 or NV12 in separate memory blocks produces frames. Single-block cameras keep working. Other pixel formats stay unsupported. Importing multi-plane NV12 into Vulkan stays with the PipeWire DMA-BUF quest. + +## Plan + +The buffer offer sets `SPA_PARAM_BUFFERS_blocks` to 1, so a producer that puts each plane in its own block never links. NV12 is already negotiated. Offer up to two blocks for NV12 and map them into the NV12 conversion that exists. I420 is not. `camera::RAW_FORMATS` drops it before selection, and `convert` has no I420 arm, so negotiate it, offer up to three blocks, and pack its planes into the I420 frame the rest of the pipeline already takes. A one-block buffer of a format already supported keeps the current mapping. + +Unit-test the offer, a multi-block NV12 buffer, and a multi-block I420 buffer, with no camera attached. `doc/lib/rs/moq-video.md` already says a Pi CSI camera and a sandboxed camera are reachable. This quest is what makes the separate-plane case of that sentence true. No new page. + +## Related + +- [Validate PipeWire cameras on a portal and a Pi](/quest/m3/pipewire-camera-hardware.md) - the pass that shows whether a real Pi or portal camera delivers separate planes +- [PipeWire DMA-BUFs into Vulkan](/quest/m2/2819-moq-video-carry-pipewire-dma-bufs-safely-into-the-vulkan.md) - multi-plane NV12 import in the renderer diff --git a/quest/m2/stats-delta.md b/quest/m2/stats-delta.md new file mode 100644 index 0000000000..19bd074bb2 --- /dev/null +++ b/quest/m2/stats-delta.md @@ -0,0 +1,115 @@ +# [M] Binary delta stats flavor + +## Goal + +A consumer can request a binary delta flavor of every stats track (name TBD, +for example `publisher.bin.z`, `subscriber.bin.z`, `sessions.bin.z`, per +tier) that a relay encodes and a reader decodes several times cheaper than +`.json.z`, in fewer bytes. The relay serves it only on request, so an +unrequested flavor costs nothing, and `moq_stats::Consumer`, the aggregate, +and a dependency-free JS decoder read it into the same frame types as JSON. +The JSON tracks stay unchanged on the wire. + +Not here: replacing or deprecating `.json` / `.json.z`, a relay config switch, +or moving the demo dashboard off JSON. + +## Plan + +**Gate first.** Bandwidth is small in absolute terms (about 0.8 Mbps per +stats subscriber in the worst scenario below), so bytes alone do not justify +a new format. The motivation is relay encode CPU at 10k+ broadcasts and +aggregator fan-in. #4019 already cut `.json.z` decode allocations about 42x +(time 2.5-4x) by dropping per-key path tracking; `rs/moq-stats/benches/decode.rs` +measures it. Proceed only if CPU still matters after a profile of the +moq-json snapshot encoder (about 100 ms per tick at 10k broadcasts) has fixed +or ruled out its cost. Re-run the benchmark against that baseline; if JSON is +close enough, abandon this quest with the numbers. + +**Evidence.** A prototype benchmark on top of #3955's commit `d86496be2` +simulated relay traffic from how `moq_net::stats` counts and how +`moq_stats::produce` emits (live-or-changed rows, pruned a drain after they +close), through the same group policy for every format and checked against +ground truth every tick. Only the traffic tracks were modelled, not +`sessions`. Bytes per tick, mean over 600 ticks: + +| format | steady 1k | churn (conference) | large 10k x3 tiers | idle cams 2k | +|---|---:|---:|---:|---:| +| `.json.z` today | 14735 | 55211 | 101472 | 888 | +| `.fb.z` full snapshot (#3955) | 73653 | 87048 | 616416 | 20906 | +| stable-slot FlatBuffers XOR | 8105 | 18798 | 57464 | 1040 | +| protobuf merge patch, absolute values | 10670 | 53294 | 97011 | 834 | +| custom varint delta | 3754 | 9494 | 25616 | 364 | + +At 10k broadcasts x 3 tiers, per tick: `.json.z` encodes in 98 ms and +decodes in 76 ms with 1M decode allocations (before #4019); a typed +`.json.z` merge patch (same wire, not landed) cut decode to 5.7 ms and 445 +allocations; the varint delta encodes in 10 ms and decodes in 0.8 ms with 12 +and 167 allocations. Against the best JSON decode, the remaining wins are +encode CPU (7-13x), bytes (2.4-5.8x), and a smaller decode margin (3-9x). + +What the numbers taught, so the design does not relearn it: + +- A full snapshot per frame fails once it outgrows DEFLATE's 32 KiB window; + that is what sank `.fb.z`. +- Absolute cumulative counters do not compress: each is 5-6 near-random + bytes. Varint deltas of monotonic counters are 1-3 bytes. +- Naive XOR against the previous frame keyframes on 95-99% of frames because + any row join or leave shifts the layout. + +**Format sketch** (the prototype's; adjust as the implementation learns): + +- Each group is one DEFLATE window, rolled by the same policy as + `moq_json::snapshot` (frame count, growth against the first frame, the + cache bound). The first frame of a group is a keyframe: every row's path + and absolute values. +- A delta frame carries new paths inline (ids assigned implicitly, continuing + the group's dictionary), then each changed row as `(id gap varint, + changed-field bitmask, one varint delta per set bit)`, then the removed ids + as gaps. Ids ascend within each list, so gaps are small. +- A counter that goes backwards is a remove plus a re-add under a new id, + which keeps every delta unsigned and matches the existing "a decrease starts + a fresh segment" rule. +- An unchanged tick writes no frame. + +Design points left open for the implementer: + +- Extensibility: if every field is a varint, a reader can skip mask bits it + does not know, so fields append without breaking old readers. Decide that + or refuse unknown bits loudly, and say which. +- Gauges: the [client stats](/quest/m1/qos/stats/README.md) extension adds + non-monotonic gauges (a latency, a target bitrate). Encode them absolute or + zigzag, or have a producer with a non-`()` extension refuse this flavor, and + document the contract. +- Measure `sessions` (`Presence`) too; the benchmark skipped it. +- Sweep the aggregate over node publishers x entries per node, for both + flavors; the benchmark above only varied one relay's stream, so it cannot + show the fan-in cost the gate cites. + +**Serving.** Like `.fb.z` was planned: a flavor suffix accepted by +`requested_track_shape` and the track-name helpers for every name that takes +`.json.z`, created only when requested rather than with the plain/compressed +pair. Weigh replacing `compressed: bool` in the helpers with a flavor enum, +which is a published API break and goes to `dev` unless additive. + +**Readers.** `Consumer` and the aggregate read either flavor into +`TrafficFrame` / `SessionsFrame`. The JS decoder is about 120 lines and +dependency-free apart from inflate (`@moq/flate`); read varints through +`BigInt` or split 32-bit halves, since counters exceed 2^53. It belongs with +the JS stats reader, `@moq/stats` if [browser reporters](/quest/m1/qos/stats/js.md) +has landed by then. Share fixtures between Rust and JS so the two stay wire +identical, and keep the benchmark in-tree, wired into CI at least nightly. + +**Docs and spec.** No IETF draft covers stats today. Write the format into +the stats format page ([stats-binary docs](/quest/m0/stats-binary/docs.md)) +and the stats section of `doc/bin/relay/config.md`, plus the moq-stats crate +docs. Whether stats needs its own `draft-lcurley-moq-stats.md` is the +maintainer's call; ask before writing one. + +Public API impact: additive on moq-stats unless the helpers change. Wire +impact: new on-demand tracks; existing tracks unchanged. + +## Related + +- [Stats format page](/quest/m0/stats-binary/docs.md) - where the new flavor is documented +- [Client stats](/quest/m1/qos/stats/README.md) - the extension and gauges the format must carry or refuse +- [Compressed tracks](/quest/m2/flate/README.md) - the group-window discipline this flavor repeats diff --git a/quest/m2/teleop/correlation.md b/quest/m2/teleop/correlation.md index 7a0c44f451..779d6f6e07 100644 --- a/quest/m2/teleop/correlation.md +++ b/quest/m2/teleop/correlation.md @@ -30,4 +30,4 @@ the same property that makes an MCAP recording valuable. ## Required - [Robot teleoperation primitive](/quest/m2/teleop/robot.md) -- [Publisher clocks](/quest/m1/publisher-clock.md) - publishers populate the fixed broadcast mapping used to join tracks +- [CLI import clock](/quest/m1/cli-import-clock.md) - publishers populate the fixed broadcast mapping used to join tracks diff --git a/quest/m3/README.md b/quest/m3/README.md index 8442c55773..d4403e5bef 100644 --- a/quest/m3/README.md +++ b/quest/m3/README.md @@ -16,6 +16,7 @@ condition clears, move the quest to the milestone its work belongs in. - [DPDK](/quest/m3/dpdk.md) - a kernel-bypass UDP path for the relay, once a provider offers SR-IOV or bare metal - [Video hardware validation](/quest/m3/video-hardware.md) - run the encode, capture, and zero-copy paths that were written but never run on real machines +- [Validate PipeWire cameras on a portal and a Pi](/quest/m3/pipewire-camera-hardware.md) - run the shipped PipeWire camera through the camera portal and a Pi CSI node - [#2893](/quest/m3/2893-video-validate-pipewire-dma-buf-capture-on-kde-hardware.md) - video: validate PipeWire DMA-BUF capture on KDE hardware - [Embedded video](/quest/m3/video-embedded.md) - EGL import in the renderer, so moq-video presents on a Pi - [Vision worker](/quest/m3/processor-vision.md) - a documented customer-run vision worker proves the processor contract diff --git a/quest/m3/pipewire-camera-hardware.md b/quest/m3/pipewire-camera-hardware.md new file mode 100644 index 0000000000..29ac05026d --- /dev/null +++ b/quest/m3/pipewire-camera-hardware.md @@ -0,0 +1,23 @@ +# [S] Validate PipeWire cameras on a portal and a Pi + +## Goal + +The xdg-desktop-portal Camera path and a Raspberry Pi CSI camera each capture frames through the PipeWire camera that already shipped, or this quest records what stopped the pass. There is no new capture API. + +## Plan + +Open `pipewire` and one `pipewire:` in a sandbox, where the portal raises its permission dialog, and on a Pi whose CSI camera is a PipeWire node (spa-libcamera). Record the mode that opened, whether frames arrived, and whether the producer used one memory block or one per plane. + +Fix only a defect the pass hits. A separate-plane producer belongs to the multi-plane quest. If that is why a Pi produces nothing, write that down and stop. `doc/lib/rs/moq-video.md` says both paths are reachable. If a path cannot capture, correct that sentence in the same change. + +A libcamera source stays out. Embedded video already leaves `rpicam-vid` to the application. This pass uses the PipeWire camera only. + +## Required + +- A sandbox that can show the camera portal dialog, and a Raspberry Pi whose CSI camera appears as a PipeWire node + +## Related + +- [Capture multi-plane PipeWire cameras](/quest/m2/pipewire-camera-planes.md) - separate-plane I420 and NV12, when the pass finds them +- [Video hardware validation](/quest/m3/video-hardware.md) - the other encode and capture runs that still need a machine +- [Embedded video path](/quest/m3/video-embedded.md) - presenting on a Pi, which is a different gap diff --git a/quest/m3/video-hardware.md b/quest/m3/video-hardware.md index fac7614ee3..355e929ae5 100644 --- a/quest/m3/video-hardware.md +++ b/quest/m3/video-hardware.md @@ -10,15 +10,13 @@ real hardware get run on it, and what breaks gets fixed. Every item here is blocked on a physical machine rather than on code, which is why they sit together and why they sit in m3. -- **VAAPI encode on an Intel or AMD box**: low-power against full entrypoint, - the NV12 upload round trip, and `cargo deny` license resolution. The - backend's own comment says NOT YET VALIDATED ON HARDWARE. The opt-in `vaapi` - feature still dlopens libva, so this run either clears that comment or - records what still blocks it. It covers the - Intel-based ground robots and NUC companions the teleoperation line needs. -- **VAAPI zero-copy dmabuf input**: the backend uses an NV12 surface upload - today. Exercise the `Surface::DmaBuf` path with a V4L2 `VIDIOC_EXPBUF` - source instead. +- **VAAPI low-power entrypoint and a second GPU.** H.264 encode, DMA-BUF + input, and VPP resize ran on Intel Meteor Lake with iHD (moq-vaapi 0.1.0). + Still unrun: the low-power encode entrypoint, which that device does not + expose, and `MOQ_VAAPI_DEVICE` naming a node other than the first render + node. +- **VAAPI input from V4L2 `VIDIOC_EXPBUF`.** DMA-BUF encode ran from a VA-API + decode and from PipeWire. A V4L2 export has not been the source. - **Windows Media Foundation capture**: on-demand open and close, so the camera LED is off when nobody is watching, and NV12 delivery from MJPEG and YUY2 cameras. @@ -31,4 +29,5 @@ come from plain `cuMemAlloc`. That is not a bug any amount of review finds. ## Related -- [PipeWire DMA-BUF on KDE](/quest/m3/2893-video-validate-pipewire-dma-buf-capture-on-kde-hardware.md) - the same kind of gate, for the capture side +- [Validate PipeWire cameras on a portal and a Pi](/quest/m3/pipewire-camera-hardware.md) - the camera portal and a Pi CSI node, which are a different machine from this list +- [PipeWire DMA-BUF on KDE](/quest/m3/2893-video-validate-pipewire-dma-buf-capture-on-kde-hardware.md) - the same kind of gate, for screen capture diff --git a/quest/m4/README.md b/quest/m4/README.md index 4cda7b3210..de01a3059f 100644 --- a/quest/m4/README.md +++ b/quest/m4/README.md @@ -13,7 +13,8 @@ the milestone its priority belongs in. ## Quests -- [VAAPI encode and decode](/quest/m4/video-vaapi.md) - DMA-BUF encode, H.265 decode, and pre-generated bindings that remove the libclang build dependency, all gated on a moq-dev/vaapi release +- [VAAPI encode and decode](/quest/m4/video-vaapi.md) - H.265 encode and decode, and pre-generated bindings that remove the libclang build dependency, gated on a moq-dev/vaapi release +- [Pool VAAPI resize surfaces](/quest/m4/vaapi-resize-pool.md) - a resize reuses one output surface per size once moq-vaapi ships that pool - [#2907](/quest/m4/2907-bind-the-browser-through-moq-ffi-uniffi-instead-of-a.md) - the browser reaches moq-ffi through a generated TypeScript binding once a JS generator is stable - [Safari WebTransport](/quest/m4/safari-webtransport.md) - WebKit browsers return to WebTransport once WebKit 319818 ships fixed - [MSFTS convergence](/quest/m4/msfts-convergence.md) - the demultiplexed TS lane maps onto MSFTS ES-level carriage once msfts#33 settles the payload unit diff --git a/quest/m4/vaapi-resize-pool.md b/quest/m4/vaapi-resize-pool.md new file mode 100644 index 0000000000..6041e66901 --- /dev/null +++ b/quest/m4/vaapi-resize-pool.md @@ -0,0 +1,19 @@ +# [S] Pool VAAPI resize surfaces + +## Goal + +A VAAPI resize reuses one output surface per destination size instead of allocating one per frame. `Surface::resize` stays the same call. The decoder pool is unchanged. + +## Plan + +`Processor` in moq-vaapi allocates the blit output with `ExportedFrame::from_surface` on every resize. Keep one surface per output size. When the exported frame drops, that surface returns and the next blit of the same size uses it. A frame the consumer still holds is not overwritten; the processor allocates another. The pool keeps one free surface per size and destroys any surface returned past that, so a burst of released frames cannot park them all. That is the decoder pool's rule, applied to resize outputs. + +moq-video already blits through `Processor` and exports the result. The reuse test belongs in moq-vaapi. Here, bump the workspace requirement and confirm a resize still returns an NV12 DMA-BUF. + +## Required + +- A `moq-vaapi` release whose `Processor` reuses one output surface per destination size and destroys free surfaces past one per size + +## Related + +- [VAAPI encode and decode](/quest/m4/video-vaapi.md) - H.265 and checked-in bindings, the other moq-vaapi gate diff --git a/quest/m4/video-vaapi.md b/quest/m4/video-vaapi.md index e2bc947fce..1f3ee5658a 100644 --- a/quest/m4/video-vaapi.md +++ b/quest/m4/video-vaapi.md @@ -48,3 +48,7 @@ already falls back cleanly, since `Encoder::new` returns `Err` and - A `moq-dev/vaapi` release exposing an HEVC encoder (H.264 decode is in 0.0.4; DMA-BUF encode and VPP shipped in 0.1.0) and pre-generated bindings instead of a bindgen build script + +## Related + +- [Pool VAAPI resize surfaces](/quest/m4/vaapi-resize-pool.md) - reuse one VPP output surface per size, a separate moq-vaapi release diff --git a/rs/libmoq/src/api.rs b/rs/libmoq/src/api.rs index 16559d7a53..42bfcecbe7 100644 --- a/rs/libmoq/src/api.rs +++ b/rs/libmoq/src/api.rs @@ -433,6 +433,22 @@ pub struct moq_json_stream_config { pub compression: bool, } +/// Options for a binary data track, in either mode. +/// +/// The mode is fixed by which constructor is called ([moq_publish_binary_snapshot] or +/// [moq_publish_binary_stream]), so it is not in here. +#[repr(C)] +#[allow(non_camel_case_types)] +pub struct moq_binary_config { + /// DEFLATE-compress each payload, advertised in the catalog entry. + pub compression: bool, + + /// The payloads' media type (e.g. `image/jpeg`), or NULL to leave it unstated. + pub mime: *const c_char, + /// Length of `mime` in bytes. + pub mime_len: usize, +} + /// A JSON value delivered by a consumer callback. #[repr(C)] #[allow(non_camel_case_types)] @@ -2902,10 +2918,12 @@ pub extern "C" fn moq_publish_group_abort(group: u32, error_code: u16) -> i32 { /// Create a JSON snapshot track (lossy latest-value) on a broadcast. /// /// Values published via [moq_publish_json_snapshot_update] reach subscribers as a single latest -/// state; a late joiner only sees the newest. Advertise the track in the catalog with -/// [moq_publish_catalog_section] if consumers should discover it. +/// state; a late joiner only sees the newest. The track is advertised in the broadcast's catalog +/// under `json.tracks.` with `mode: snapshot` (and `compression: deflate` when set), and the +/// entry is retired when the track finishes or fails, so consumers discover it with no extra call. /// -/// Returns a non-zero handle to the JSON producer on success, or a negative code on failure. +/// Returns a non-zero handle to the JSON producer on success, or a negative code on failure, +/// including a mux error when the catalog already carries an entry named `name`. /// /// # Safety /// - The caller must ensure `name` is a valid pointer to `name_len` bytes and `config` a valid pointer. @@ -2920,13 +2938,9 @@ pub unsafe extern "C" fn moq_publish_json_snapshot( let broadcast = ffi::parse_id(broadcast)?; let name = unsafe { ffi::parse_str(name, name_len)? }; let config = unsafe { config.as_ref() }.ok_or(Error::InvalidPointer)?; - let mut producer = moq_json::snapshot::Config::default(); - producer.delta_ratio = config.delta_ratio; - producer.compression = if config.compression { - moq_json::Compression::Deflate - } else { - moq_json::Compression::None - }; + let producer = moq_mux::json::Config::default() + .with_compression(config.compression) + .with_delta_ratio(config.delta_ratio); State::lock().publish.json_snapshot(broadcast, name, producer) }) } @@ -2962,8 +2976,11 @@ pub extern "C" fn moq_publish_json_snapshot_finish(json: u32) -> i32 { /// Create a JSON stream track (lossless append-log) on a broadcast. /// /// Every record appended via [moq_publish_json_stream_append] is preserved and delivered in order. +/// The track is advertised in the broadcast's catalog under `json.tracks.` with +/// `mode: stream`, for as long as the track lives. /// -/// Returns a non-zero handle to the JSON stream producer on success, or a negative code on failure. +/// Returns a non-zero handle to the JSON stream producer on success, or a negative code on failure, +/// including a mux error when the catalog already carries an entry named `name`. /// /// # Safety /// - The caller must ensure `name` is a valid pointer to `name_len` bytes and `config` a valid pointer. @@ -2978,10 +2995,7 @@ pub unsafe extern "C" fn moq_publish_json_stream( let broadcast = ffi::parse_id(broadcast)?; let name = unsafe { ffi::parse_str(name, name_len)? }; let config = unsafe { config.as_ref() }.ok_or(Error::InvalidPointer)?; - let mut producer = moq_json::stream::Config::default(); - if config.compression { - producer.compression = moq_json::Compression::Deflate; - } + let producer = moq_mux::json::Config::default().with_compression(config.compression); State::lock().publish.json_stream(broadcast, name, producer) }) } @@ -3013,6 +3027,128 @@ pub extern "C" fn moq_publish_json_stream_finish(stream: u32) -> i32 { }) } +/// Parse a [moq_binary_config] into the mux's binary track config. +/// +/// # Safety +/// - `config` must be a valid pointer, and its `mime` a valid pointer to `mime_len` bytes when not NULL. +unsafe fn binary_config(config: *const moq_binary_config) -> Result { + let config = unsafe { config.as_ref() }.ok_or(Error::InvalidPointer)?; + let mut binary = moq_mux::binary::Config::default().with_compression(config.compression); + if let Some(mime) = unsafe { ffi::parse_str_optional(config.mime, config.mime_len)? } { + binary = binary.with_mime(mime); + } + Ok(binary) +} + +/// Create a binary snapshot track (lossy latest-value) on a broadcast: each payload supersedes the +/// last, and a late joiner only sees the newest, e.g. the latest thumbnail of a camera. +/// +/// The track is advertised in the broadcast's catalog under `binary.tracks.` with +/// `mode: snapshot` (plus `mime` and `compression` when set), and the entry is retired when the +/// track finishes or fails. +/// +/// Returns a non-zero handle to the binary producer on success, or a negative code on failure, +/// including a mux error when the catalog already carries an entry named `name`. +/// +/// # Safety +/// - The caller must ensure `name` is a valid pointer to `name_len` bytes and `config` a valid pointer. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn moq_publish_binary_snapshot( + broadcast: u32, + name: *const c_char, + name_len: usize, + config: *const moq_binary_config, +) -> i32 { + ffi::enter(move || { + let broadcast = ffi::parse_id(broadcast)?; + let name = unsafe { ffi::parse_str(name, name_len)? }; + let config = unsafe { binary_config(config)? }; + State::lock().publish.binary_snapshot(broadcast, name, config) + }) +} + +/// Publish a new payload to a binary snapshot track, superseding the last. +/// +/// Returns a zero on success, or a negative code on failure. +/// +/// # Safety +/// - The caller must ensure `payload` is a valid pointer to `payload_len` bytes. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn moq_publish_binary_snapshot_update( + binary: u32, + payload: *const u8, + payload_len: usize, +) -> i32 { + ffi::enter(move || { + let binary = ffi::parse_id(binary)?; + let payload = unsafe { ffi::parse_slice(payload, payload_len)? }; + State::lock().publish.binary_snapshot_update(binary, payload) + }) +} + +/// Finish a binary snapshot track and retire its catalog entry. No more payloads can be published. +/// +/// Returns a zero on success, or a negative code on failure. +#[unsafe(no_mangle)] +pub extern "C" fn moq_publish_binary_snapshot_finish(binary: u32) -> i32 { + ffi::enter(move || { + let binary = ffi::parse_id(binary)?; + State::lock().publish.binary_snapshot_finish(binary) + }) +} + +/// Create a binary stream track (lossless append-log) on a broadcast: every payload is preserved +/// and delivered in order. +/// +/// The track is advertised in the broadcast's catalog under `binary.tracks.` with +/// `mode: stream` (plus `mime` and `compression` when set), for as long as the track lives. +/// +/// Returns a non-zero handle to the binary stream producer on success, or a negative code on +/// failure, including a mux error when the catalog already carries an entry named `name`. +/// +/// # Safety +/// - The caller must ensure `name` is a valid pointer to `name_len` bytes and `config` a valid pointer. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn moq_publish_binary_stream( + broadcast: u32, + name: *const c_char, + name_len: usize, + config: *const moq_binary_config, +) -> i32 { + ffi::enter(move || { + let broadcast = ffi::parse_id(broadcast)?; + let name = unsafe { ffi::parse_str(name, name_len)? }; + let config = unsafe { binary_config(config)? }; + State::lock().publish.binary_stream(broadcast, name, config) + }) +} + +/// Append one payload to a binary stream track. +/// +/// Returns a zero on success, or a negative code on failure. +/// +/// # Safety +/// - The caller must ensure `payload` is a valid pointer to `payload_len` bytes. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn moq_publish_binary_stream_append(stream: u32, payload: *const u8, payload_len: usize) -> i32 { + ffi::enter(move || { + let stream = ffi::parse_id(stream)?; + let payload = unsafe { ffi::parse_slice(payload, payload_len)? }; + State::lock().publish.binary_stream_append(stream, payload) + }) +} + +/// Finish a binary stream track and retire its catalog entry. No more payloads can be appended. +/// +/// Returns a zero on success, or a negative code on failure. +#[unsafe(no_mangle)] +pub extern "C" fn moq_publish_binary_stream_finish(stream: u32) -> i32 { + ffi::enter(move || { + let stream = ffi::parse_id(stream)?; + State::lock().publish.binary_stream_finish(stream) + }) +} + /// Create a catalog consumer for a broadcast. /// /// `on_catalog` is invoked with a positive catalog ID for each catalog update diff --git a/rs/libmoq/src/publish.rs b/rs/libmoq/src/publish.rs index fafa224c0e..fd7db2f681 100644 --- a/rs/libmoq/src/publish.rs +++ b/rs/libmoq/src/publish.rs @@ -70,11 +70,18 @@ pub struct Publish { /// Raw group producers, created from a raw track producer. groups: NonZeroSlab, - /// JSON snapshot producers (lossy latest-value tracks). - json_snapshot: NonZeroSlab>, + /// JSON snapshot producers (lossy latest-value tracks), each advertised in its broadcast's + /// catalog for as long as it lives. + json_snapshot: NonZeroSlab>, - /// JSON stream producers (lossless append-log tracks). - json_stream: NonZeroSlab>, + /// JSON stream producers (lossless append-log tracks), advertised the same way. + json_stream: NonZeroSlab>, + + /// Binary snapshot producers (lossy latest-value tracks of opaque bytes), advertised the same way. + binary_snapshot: NonZeroSlab>, + + /// Binary stream producers (lossless append-log tracks of opaque bytes), advertised the same way. + binary_stream: NonZeroSlab>, /// Demand watchers. Close signals shutdown; the task delivers a final callback, then removes itself. demand: NonZeroSlab>, @@ -132,6 +139,12 @@ impl Publish { Ok(&mut self.broadcasts.get_mut(id).ok_or(Error::BroadcastNotFound)?.catalog) } + /// The broadcast's current catalog, as consumers would receive it next. + #[cfg(test)] + pub fn catalog_snapshot(&mut self, id: Id) -> Result, Error> { + Ok(self.catalog(id)?.snapshot()) + } + /// Mutable access to both the broadcast and its catalog producer. /// Used by sibling modules (e.g. `audio`) that need to attach a new /// track to an existing publish. @@ -736,20 +749,26 @@ impl Publish { Ok(()) } - /// Create a JSON snapshot track (lossy latest-value) on a broadcast. - /// - /// Values published via [`Self::json_snapshot_update`] reach subscribers as a single latest - /// state; a late joiner only sees the newest value. Advertise the track in the catalog with - /// [`Self::catalog_section_set`] if consumers should discover it. - pub fn json_snapshot( + /// Create a track on a broadcast and hand it, with the broadcast's catalog, to `publish`, which + /// wraps it in a data producer that advertises the track in that catalog. + fn data_track( &mut self, broadcast: Id, name: &str, - config: moq_json::snapshot::Config, - ) -> Result { - let broadcast = self.producer(broadcast)?; - let track = broadcast.create_track(name, None)?; - let producer = moq_json::snapshot::Producer::new(track, config); + publish: impl FnOnce(&moq_mux::catalog::Producer, moq_net::track::Producer) -> moq_mux::Result, + ) -> Result { + let broadcast = self.broadcasts.get_mut(broadcast).ok_or(Error::BroadcastNotFound)?; + let track = broadcast.producer.create_track(name, None)?; + Ok(publish(&broadcast.catalog, track)?) + } + + /// Create a JSON snapshot track (lossy latest-value) on a broadcast, advertised in its catalog. + /// + /// Values published via [`Self::json_snapshot_update`] reach subscribers as a single latest + /// state; a late joiner only sees the newest value. The catalog entry (`json.tracks.`, + /// `mode: snapshot`) is written now and retired when the track finishes or fails. + pub fn json_snapshot(&mut self, broadcast: Id, name: &str, config: moq_mux::json::Config) -> Result { + let producer = self.data_track(broadcast, name, |catalog, track| catalog.json_snapshot(track, config))?; self.json_snapshot.insert(producer) } @@ -760,20 +779,19 @@ impl Publish { Ok(()) } - /// Finish a JSON snapshot track. No more values can be published. + /// Finish a JSON snapshot track and retire its catalog entry. No more values can be published. pub fn json_snapshot_finish(&mut self, json: Id) -> Result<(), Error> { - let mut producer = self.json_snapshot.remove(json).ok_or(Error::TrackNotFound)?; + let producer = self.json_snapshot.remove(json).ok_or(Error::TrackNotFound)?; producer.finish()?; Ok(()) } - /// Create a JSON stream track (lossless append-log) on a broadcast. + /// Create a JSON stream track (lossless append-log) on a broadcast, advertised in its catalog. /// /// Every record appended via [`Self::json_stream_append`] is preserved and delivered in order. - pub fn json_stream(&mut self, broadcast: Id, name: &str, config: moq_json::stream::Config) -> Result { - let broadcast = self.producer(broadcast)?; - let track = broadcast.create_track(name, None)?; - let producer = moq_json::stream::Producer::new(track, config); + /// The catalog entry (`json.tracks.`, `mode: stream`) lives as long as the track. + pub fn json_stream(&mut self, broadcast: Id, name: &str, config: moq_mux::json::Config) -> Result { + let producer = self.data_track(broadcast, name, |catalog, track| catalog.json_stream(track, config))?; self.json_stream.insert(producer) } @@ -784,9 +802,51 @@ impl Publish { Ok(()) } - /// Finish a JSON stream track. No more records can be appended. + /// Finish a JSON stream track and retire its catalog entry. No more records can be appended. pub fn json_stream_finish(&mut self, stream: Id) -> Result<(), Error> { - let mut producer = self.json_stream.remove(stream).ok_or(Error::TrackNotFound)?; + let producer = self.json_stream.remove(stream).ok_or(Error::TrackNotFound)?; + producer.finish()?; + Ok(()) + } + + /// Create a binary snapshot track (lossy latest-value) on a broadcast, advertised in its catalog + /// as `binary.tracks.`, `mode: snapshot`. + pub fn binary_snapshot(&mut self, broadcast: Id, name: &str, config: moq_mux::binary::Config) -> Result { + let producer = self.data_track(broadcast, name, |catalog, track| catalog.binary_snapshot(track, config))?; + self.binary_snapshot.insert(producer) + } + + /// Publish a new payload to a binary snapshot track, superseding the last. + pub fn binary_snapshot_update(&mut self, binary: Id, payload: &[u8]) -> Result<(), Error> { + let producer = self.binary_snapshot.get_mut(binary).ok_or(Error::TrackNotFound)?; + producer.update(bytes::Bytes::copy_from_slice(payload))?; + Ok(()) + } + + /// Finish a binary snapshot track and retire its catalog entry. + pub fn binary_snapshot_finish(&mut self, binary: Id) -> Result<(), Error> { + let producer = self.binary_snapshot.remove(binary).ok_or(Error::TrackNotFound)?; + producer.finish()?; + Ok(()) + } + + /// Create a binary stream track (lossless append-log) on a broadcast, advertised in its catalog + /// as `binary.tracks.`, `mode: stream`. + pub fn binary_stream(&mut self, broadcast: Id, name: &str, config: moq_mux::binary::Config) -> Result { + let producer = self.data_track(broadcast, name, |catalog, track| catalog.binary_stream(track, config))?; + self.binary_stream.insert(producer) + } + + /// Append one payload to a binary stream track. + pub fn binary_stream_append(&mut self, stream: Id, payload: &[u8]) -> Result<(), Error> { + let producer = self.binary_stream.get_mut(stream).ok_or(Error::TrackNotFound)?; + producer.append(bytes::Bytes::copy_from_slice(payload))?; + Ok(()) + } + + /// Finish a binary stream track and retire its catalog entry. + pub fn binary_stream_finish(&mut self, stream: Id) -> Result<(), Error> { + let producer = self.binary_stream.remove(stream).ok_or(Error::TrackNotFound)?; producer.finish()?; Ok(()) } diff --git a/rs/libmoq/src/test.rs b/rs/libmoq/src/test.rs index 00673bcc31..535d3574d8 100644 --- a/rs/libmoq/src/test.rs +++ b/rs/libmoq/src/test.rs @@ -4833,3 +4833,260 @@ fn server_listen_refuses_bad_config() { MOQ_ERROR_INVALID_CONFIG ); } + +/// Subscribe to `name` on `consume` as a raw track and return the payloads of its first `count` +/// frames. +fn read_raw_frames(consume: u32, name: &[u8], count: usize) -> Vec> { + let frame_cb = Callback::new(); + let subscription = moq_subscription { + priority: 0, + max_age_us: 1_000_000, + group_start: 0, + group_start_present: false, + group_end: 0, + group_end_present: false, + }; + let track = id(unsafe { + moq_consume_track( + consume, + name.as_ptr() as *const c_char, + name.len(), + &subscription, + Some(channel_callback), + frame_cb.ptr, + ) + }); + let mut payloads = Vec::with_capacity(count); + for _ in 0..count { + let frame_id = id(frame_cb.recv()); + let mut frame = moq_frame { + payload: std::ptr::null(), + payload_size: 0, + timestamp_us: 0, + keyframe: false, + }; + assert_eq!(unsafe { moq_consume_track_frame(frame_id, &mut frame) }, 0); + payloads.push(unsafe { std::slice::from_raw_parts(frame.payload, frame.payload_size) }.to_vec()); + assert_eq!(moq_consume_track_frame_free(frame_id), 0); + } + assert_eq!(moq_consume_track_cancel(track), 0); + // The callback context must outlive libmoq's last call into it: wait for the terminal. + assert_eq!(frame_cb.recv_terminal(), 0, "clean cancel delivers terminal 0"); + payloads +} + +/// The broadcast's current catalog, read on the publish side. +fn published_catalog(broadcast: u32) -> moq_mux::catalog::hang::Catalog { + let id = crate::Id::try_from(broadcast).expect("valid broadcast id"); + crate::State::lock() + .publish + .catalog_snapshot(id) + .expect("broadcast exists") +} + +#[test] +fn json_tracks_are_advertised_in_the_catalog() { + let origin = id(moq_origin_create()); + let broadcast = publish_broadcast(origin, b"json-catalog"); + + let status = b"status"; + let snapshot = id(unsafe { + moq_publish_json_snapshot( + broadcast, + status.as_ptr() as *const c_char, + status.len(), + &moq_json_snapshot_config { + delta_ratio: 4, + compression: true, + }, + ) + }); + let events = b"events"; + let stream = id(unsafe { + moq_publish_json_stream( + broadcast, + events.as_ptr() as *const c_char, + events.len(), + &moq_json_stream_config { compression: false }, + ) + }); + + let catalog = published_catalog(broadcast); + let entry = catalog.json.tracks.get("status").expect("snapshot track advertised"); + assert_eq!(entry.mode, hang::catalog::Mode::Snapshot); + assert_eq!(entry.compression, Some(hang::catalog::Compression::Deflate)); + let entry = catalog.json.tracks.get("events").expect("stream track advertised"); + assert_eq!(entry.mode, hang::catalog::Mode::Stream); + assert_eq!(entry.compression, None); + + // Finishing a track retires its entry; the other stays. + assert_eq!(moq_publish_json_snapshot_finish(snapshot), 0); + let catalog = published_catalog(broadcast); + assert!( + !catalog.json.tracks.contains_key("status"), + "finished track still advertised" + ); + assert!(catalog.json.tracks.contains_key("events")); + + assert_eq!(moq_publish_json_stream_finish(stream), 0); + assert!(published_catalog(broadcast).json.tracks.is_empty()); + + assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_origin_close(origin), 0); +} + +#[test] +fn binary_snapshot_is_advertised_and_delivered() { + let origin = id(moq_origin_create()); + let path = b"binary-snapshot"; + let broadcast = publish_broadcast(origin, path); + + let name = b"thumbnail"; + let mime = b"image/jpeg"; + let producer = id(unsafe { + moq_publish_binary_snapshot( + broadcast, + name.as_ptr() as *const c_char, + name.len(), + &moq_binary_config { + compression: false, + mime: mime.as_ptr() as *const c_char, + mime_len: mime.len(), + }, + ) + }); + + let catalog = published_catalog(broadcast); + let entry = catalog.binary.tracks.get("thumbnail").expect("binary track advertised"); + assert_eq!(entry.mode, hang::catalog::Mode::Snapshot); + assert_eq!(entry.mime.as_deref(), Some("image/jpeg")); + + // The payload reaches a raw subscriber of the same track name, untouched. + let payload = [0xff_u8, 0xd8, 0xff, 0xe0, 1, 2, 3]; + assert_eq!( + unsafe { moq_publish_binary_snapshot_update(producer, payload.as_ptr(), payload.len()) }, + 0 + ); + let consume = request_broadcast(origin, path); + let frames = read_raw_frames(consume, name, 1); + assert_eq!(frames, vec![payload.to_vec()]); + + assert_eq!(moq_publish_binary_snapshot_finish(producer), 0); + assert!( + moq_publish_binary_snapshot_finish(producer) < 0, + "double-finish should fail" + ); + assert!(published_catalog(broadcast).binary.tracks.is_empty()); + + assert_eq!(moq_consume_close(consume), 0); + assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_origin_close(origin), 0); +} + +#[test] +fn binary_stream_is_advertised_and_delivered() { + let origin = id(moq_origin_create()); + let path = b"binary-stream"; + let broadcast = publish_broadcast(origin, path); + + // A NULL mime leaves the media type unstated. + let name = b"blobs"; + let producer = id(unsafe { + moq_publish_binary_stream( + broadcast, + name.as_ptr() as *const c_char, + name.len(), + &moq_binary_config { + compression: false, + mime: std::ptr::null(), + mime_len: 0, + }, + ) + }); + + let catalog = published_catalog(broadcast); + let entry = catalog.binary.tracks.get("blobs").expect("binary stream advertised"); + assert_eq!(entry.mode, hang::catalog::Mode::Stream); + assert_eq!(entry.mime, None); + + // Every appended payload is delivered, in order. + let payloads: [&[u8]; 2] = [b"first", b"second"]; + for payload in payloads { + assert_eq!( + unsafe { moq_publish_binary_stream_append(producer, payload.as_ptr(), payload.len()) }, + 0 + ); + } + let consume = request_broadcast(origin, path); + let frames = read_raw_frames(consume, name, payloads.len()); + assert_eq!(frames, payloads.map(<[u8]>::to_vec)); + + assert_eq!(moq_publish_binary_stream_finish(producer), 0); + assert!(published_catalog(broadcast).binary.tracks.is_empty()); + + assert_eq!(moq_consume_close(consume), 0); + assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_origin_close(origin), 0); +} + +#[test] +fn data_track_names_cannot_collide() { + let origin = id(moq_origin_create()); + let broadcast = publish_broadcast(origin, b"data-collide"); + + let name = b"state"; + let first = id(unsafe { + moq_publish_json_snapshot( + broadcast, + name.as_ptr() as *const c_char, + name.len(), + &moq_json_snapshot_config { + delta_ratio: 0, + compression: false, + }, + ) + }); + // A second data track under the same name is refused rather than silently replacing the + // first entry. + assert!( + unsafe { + moq_publish_binary_stream( + broadcast, + name.as_ptr() as *const c_char, + name.len(), + &moq_binary_config { + compression: false, + mime: std::ptr::null(), + mime_len: 0, + }, + ) + } < 0, + "a duplicate data track name should fail" + ); + assert_eq!( + published_catalog(broadcast) + .json + .tracks + .get("state") + .map(|e| e.mode.clone()), + Some(hang::catalog::Mode::Snapshot), + "the refused duplicate must leave the first entry in place" + ); + + // A NULL config is refused. + let other = b"other"; + assert!( + unsafe { + moq_publish_binary_stream( + broadcast, + other.as_ptr() as *const c_char, + other.len(), + std::ptr::null(), + ) + } < 0 + ); + + assert_eq!(moq_publish_json_snapshot_finish(first), 0); + assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_origin_close(origin), 0); +} diff --git a/rs/moq-ffi/src/media.rs b/rs/moq-ffi/src/media.rs index b3318bed09..5042c51083 100644 --- a/rs/moq-ffi/src/media.rs +++ b/rs/moq-ffi/src/media.rs @@ -242,6 +242,10 @@ pub struct MoqAudioInit { /// Human-readable rendition name for a track picker. #[uniffi(default = None)] pub label: Option, + /// Track name. `None` derives a unique name from the format. Refused on a requested track, + /// which already carries its name. + #[uniffi(default = None)] + pub track: Option, } /// What a video publish needs: a format, optional init bytes, a label, and hints. @@ -260,6 +264,10 @@ pub struct MoqVideoInit { /// Catalog fields the stream cannot reveal itself. #[uniffi(default = None)] pub hint: Option, + /// Track name. `None` derives a unique name from the format. Refused on a requested track, + /// which already carries its name. + #[uniffi(default = None)] + pub track: Option, } /// What a container publish needs: a format and its leading bytes. diff --git a/rs/moq-ffi/src/producer.rs b/rs/moq-ffi/src/producer.rs index 9fd9b3aafc..04822ca67c 100644 --- a/rs/moq-ffi/src/producer.rs +++ b/rs/moq-ffi/src/producer.rs @@ -317,19 +317,17 @@ impl MoqBroadcastProducer { /// Publish one audio codec as a new track. /// - /// The track is named after the format (`0.opus`), so the catalog is how a subscriber finds it. - /// [`MoqAudioInit::data`] is required: audio resolves its rendition entirely from those bytes. - pub fn publish_audio(&self, init: MoqAudioInit) -> Result, MoqError> { + /// The track is [`MoqAudioInit::track`], or else named after the format (`0.opus`), so the + /// catalog is how a subscriber finds it. [`MoqAudioInit::data`] is required: audio resolves its + /// rendition entirely from those bytes. + pub fn publish_audio(&self, mut init: MoqAudioInit) -> Result, MoqError> { let _guard = crate::ffi::enter(); let guard = self.state.lock().unwrap(); let state = guard.as_ref().ok_or(MoqError::Closed)?; + let track = init.track.take(); let init: moq_mux::import::AudioInit = init.into(); - let broadcast = state.broadcast.clone(); - let name = broadcast.unique_name(&format!(".{}", init.format)); - let request = broadcast - .reserve_track(name) - .map_err(|err| MoqError::Codec(format!("init failed: {err}")))?; + let request = reserve_track(&state.broadcast, track, &init.format)?; let import = moq_mux::import::Track::audio(request, state.catalog.reserve(), init) .map_err(|err| MoqError::Codec(format!("init failed: {err}")))?; @@ -341,17 +339,14 @@ impl MoqBroadcastProducer { /// Named as in [`publish_audio`](Self::publish_audio). [`MoqVideoInit::data`] may be empty for a /// format that resolves in band; a hint carrying the codec publishes the catalog before the /// first keyframe. - pub fn publish_video(&self, init: MoqVideoInit) -> Result, MoqError> { + pub fn publish_video(&self, mut init: MoqVideoInit) -> Result, MoqError> { let _guard = crate::ffi::enter(); let guard = self.state.lock().unwrap(); let state = guard.as_ref().ok_or(MoqError::Closed)?; + let track = init.track.take(); let init: moq_mux::import::VideoInit = init.into(); - let broadcast = state.broadcast.clone(); - let name = broadcast.unique_name(&format!(".{}", init.format)); - let request = broadcast - .reserve_track(name) - .map_err(|err| MoqError::Codec(format!("init failed: {err}")))?; + let request = reserve_track(&state.broadcast, track, &init.format)?; let import = moq_mux::import::Track::video(request, state.catalog.reserve(), init) .map_err(|err| MoqError::Codec(format!("init failed: {err}")))?; @@ -387,6 +382,9 @@ impl MoqBroadcastProducer { let guard = self.state.lock().unwrap(); let state = guard.as_ref().ok_or(MoqError::Closed)?; + if init.track.is_some() { + return Err(MoqError::Codec("a requested track already has a name".into())); + } let request = request.take()?; let import = moq_mux::import::Track::audio(request, state.catalog.reserve(), init.into()) .map_err(|err| MoqError::Codec(format!("init failed: {err}")))?; @@ -404,6 +402,9 @@ impl MoqBroadcastProducer { let guard = self.state.lock().unwrap(); let state = guard.as_ref().ok_or(MoqError::Closed)?; + if init.track.is_some() { + return Err(MoqError::Codec("a requested track already has a name".into())); + } let request = request.take()?; let import = moq_mux::import::Track::video(request, state.catalog.reserve(), init.into()) .map_err(|err| MoqError::Codec(format!("init failed: {err}")))?; @@ -414,17 +415,14 @@ impl MoqBroadcastProducer { /// /// Only the self-delimiting formats work here (`Avc3`, `Hev1`, `Av01`); the rest need length /// prefixes or an out-of-band config record. There is no audio counterpart for the same reason. - pub fn publish_video_stream(&self, init: MoqVideoInit) -> Result, MoqError> { + pub fn publish_video_stream(&self, mut init: MoqVideoInit) -> Result, MoqError> { let _guard = crate::ffi::enter(); let guard = self.state.lock().unwrap(); let state = guard.as_ref().ok_or(MoqError::Closed)?; + let track = init.track.take(); let init: moq_mux::import::VideoInit = init.into(); - let broadcast = state.broadcast.clone(); - let name = broadcast.unique_name(&format!(".{}", init.format)); - let request = broadcast - .reserve_track(name) - .map_err(|err| MoqError::Codec(format!("init failed: {err}")))?; + let request = reserve_track(&state.broadcast, track, &init.format)?; let import = moq_mux::import::TrackStream::video(request, state.catalog.reserve(), init) .map_err(|err| MoqError::Codec(format!("init failed: {err}")))?; @@ -1134,3 +1132,15 @@ impl MoqContainerStreamProducer { Ok(()) } } + +/// Reserve the named track, or a unique one named after the format. +fn reserve_track( + broadcast: &moq_net::broadcast::Producer, + track: Option, + format: &impl std::fmt::Display, +) -> Result { + let name = track.unwrap_or_else(|| broadcast.unique_name(&format!(".{format}"))); + broadcast + .reserve_track(name) + .map_err(|err| MoqError::Codec(format!("init failed: {err}"))) +} diff --git a/rs/moq-ffi/src/test.rs b/rs/moq-ffi/src/test.rs index 04dae275d6..4963dff98f 100644 --- a/rs/moq-ffi/src/test.rs +++ b/rs/moq-ffi/src/test.rs @@ -155,6 +155,7 @@ fn audio_init(format: MoqAudioFormat, data: Vec) -> MoqAudioInit { format, data, label: None, + track: None, } } @@ -164,6 +165,7 @@ fn video_init(format: MoqVideoFormat, data: Vec) -> MoqVideoInit { data, label: None, hint: None, + track: None, } } @@ -1239,6 +1241,65 @@ async fn requested_track_dynamic_survives_accept() { assert_eq!(frame.timestamp_us, 180_000); } +#[tokio::test] +async fn video_publish_named_track() { + let broadcast = MoqBroadcastProducer::new().unwrap(); + let consumer = broadcast.consume().unwrap(); + let catalog_consumer = consumer.subscribe_catalog().await.unwrap(); + + let named = |track: &str| MoqVideoInit { + track: Some(track.into()), + ..video_init(MoqVideoFormat::Avc3, h264_init()) + }; + let hd = broadcast.publish_video(named("hd")).unwrap(); + assert_eq!(hd.name().unwrap(), "hd"); + let sd = broadcast.publish_video_stream(named("sd")).unwrap(); + drop(sd); + + // A name is the caller's contract, so a duplicate fails rather than being made unique. + assert!(matches!(broadcast.publish_video(named("hd")), Err(MoqError::Codec(_)))); + + let catalog = tokio::time::timeout(TIMEOUT, catalog_consumer.next()) + .await + .expect("timed out waiting for catalog") + .unwrap() + .expect("expected a catalog"); + assert!(catalog.video.contains_key("hd"), "catalog: {:?}", catalog.video.keys()); +} + +#[tokio::test] +async fn requested_track_refuses_a_name() { + let broadcast = MoqBroadcastProducer::new().unwrap(); + let dynamic = broadcast.dynamic().unwrap(); + let consumer = broadcast.consume().unwrap(); + let subscribe = tokio::spawn(async move { + consumer + .subscribe_media("requested".into(), crate::media::MoqContainer::Legacy, None) + .await + }); + + let request = tokio::time::timeout(TIMEOUT, dynamic.requested_track()) + .await + .expect("timed out waiting for requested track") + .unwrap(); + + let named = MoqVideoInit { + track: Some("other".into()), + ..video_init(MoqVideoFormat::Avc3, h264_init()) + }; + assert!(matches!( + broadcast.publish_video_on_track(&request, named), + Err(MoqError::Codec(_)) + )); + + // The refusal leaves the request unaccepted, so it still publishes under its own name. + let media = broadcast + .publish_video_on_track(&request, video_init(MoqVideoFormat::Avc3, h264_init())) + .unwrap(); + assert_eq!(media.name().unwrap(), "requested"); + subscribe.abort(); +} + #[tokio::test] async fn dynamic_track_request_can_publish_media() { let broadcast = MoqBroadcastProducer::new().unwrap(); diff --git a/rs/moq-gst/Cargo.toml b/rs/moq-gst/Cargo.toml index dd465ad391..327a77d7c2 100644 --- a/rs/moq-gst/Cargo.toml +++ b/rs/moq-gst/Cargo.toml @@ -32,4 +32,5 @@ url = { workspace = true } gst-plugin-version-helper = "0.8" [dev-dependencies] +futures = { workspace = true } tokio = { workspace = true, features = ["test-util"] } diff --git a/rs/moq-gst/src/sink/session.rs b/rs/moq-gst/src/sink/session.rs index 9e00dc8f76..58ff9b107a 100644 --- a/rs/moq-gst/src/sink/session.rs +++ b/rs/moq-gst/src/sink/session.rs @@ -251,6 +251,8 @@ impl SessionRegistration { /// `Session` (or the producers held by the element) tears it down. pub(crate) struct Session { join: tokio::task::JoinHandle<()>, + /// The reconnect loop, held so [`stop`](Self::stop) can wait for it to end. + connection: moq_tokio::Connection, status: Arc, /// The live send-bitrate estimate, tracked across reconnects by the reconnect loop. Read directly /// by the `estimated-send-rate` getter. @@ -307,7 +309,7 @@ impl Session { // installing this session, and its bus error would be discarded for belonging to no live one. let gate = Arc::new(tokio::sync::Notify::new()); let join = RUNTIME.spawn(forward( - reconnect, + reconnect.clone(), origin, status.clone(), completion.clone(), @@ -318,6 +320,7 @@ impl Session { Ok(( Self { join, + connection: reconnect, status, send_bandwidth, recv_bandwidth, @@ -360,9 +363,38 @@ impl Session { self.completion.clone() } - /// Stop the session: a clean local close, never an error. [`Drop`] aborts the task, cancelling the - /// in-flight connect or reconnect loop at its next await point and dropping the connection. - pub fn stop(self) {} + /// Stop the session: a clean local close, never an error. Returns once the reconnect loop has ended. + /// + /// Aborting only cancels the loop at its next await point, and a worker may be mid-dial, generating + /// TLS randomness. A process exiting in that window (`gst-launch` right after NULL) races aws-lc's + /// exit destructors, which free its seed DRBG and then `abort()` a thread asking for more. + pub fn stop(self) { + // The status task goes first, so it never reports the loop's end as a failure. + self.join.abort(); + self.connection.abort(moq_net::Error::Cancel); + // Parks the thread rather than entering an executor, which would panic under a caller's own. + let closed = || { + let waiter = moq_net::kio::Waiter::new(Arc::new(Unpark(std::thread::current())).into()); + while self.connection.poll_closed(&waiter).is_pending() { + std::thread::park(); + } + }; + match tokio::runtime::Handle::try_current().map(|handle| handle.runtime_flavor()) { + // A state change from a notify or bus sync handler runs on a worker, whose queue may hold the + // loop's cancellation. Handing the worker off lets it run while this thread blocks. + Ok(tokio::runtime::RuntimeFlavor::MultiThread) => tokio::task::block_in_place(closed), + _ => closed(), + } + } +} + +/// Wakes a thread parked in [`Session::stop`]. +struct Unpark(std::thread::Thread); + +impl std::task::Wake for Unpark { + fn wake(self: Arc) { + self.0.unpark(); + } } impl Drop for Session { @@ -383,8 +415,8 @@ impl Drop for Session { /// presence change notifies `sessions` and `connection-stats`, and a bitrate change notifies just that /// bitrate. The loop stops only on a terminal error (a non-retryable auth failure, or a bounded backoff's /// give-up), which the `Err` arm posts as a bus error. -/// [`Session`]'s `Drop` aborts this task, which drops the `Connection` handle and quietly tears the loop -/// down. +/// [`Session`] aborts this task on stop or drop, and dropping its own `Connection` handle with it quietly +/// tears the loop down. async fn forward( reconnect: moq_tokio::Connection, origin: moq_net::origin::Producer, @@ -519,6 +551,59 @@ mod tests { assert_eq!(structure.get::("ended"), Ok(2)); } + fn started() -> (Session, moq_tokio::Connection) { + gst::init().unwrap(); + let settings = ResolvedSettings { + url: "https://127.0.0.1:1".parse().unwrap(), + broadcast: "test".into(), + tls_disable_verify: false, + quic_idle_timeout: None, + quic_keep_alive: None, + }; + let (session, registration, _, _) = Session::start(settings, glib::WeakRef::new()).unwrap(); + registration.mark_registered(); + let connection = session.connection.clone(); + (session, connection) + } + + fn is_closed(connection: &moq_tokio::Connection) -> bool { + connection.poll_closed(&moq_net::kio::Waiter::noop()).is_ready() + } + + // A dial still running once the element reached NULL can outlive `main`, and aws-lc aborts the + // process when a thread asks it for randomness after its exit destructors ran. + #[test] + fn stop_returns_after_the_reconnect_loop_ends() { + let (session, connection) = started(); + session.stop(); + assert!(is_closed(&connection)); + } + + // A notify or bus sync handler can stop the element from a runtime worker. With the loop parked, its + // cancellation lands in that worker's own LIFO slot, which no other worker can steal, so blocking the + // worker outright would never let it run. + #[test] + fn stop_from_a_runtime_worker_does_not_deadlock() { + let (session, connection) = started(); + let metrics = RUNTIME.metrics(); + // An odd count means that worker is parked. + while metrics.global_queue_depth() > 0 + || (0..metrics.num_workers()).any(|worker| metrics.worker_park_unpark_count(worker).is_multiple_of(2)) + { + std::thread::yield_now(); + } + RUNTIME.block_on(RUNTIME.spawn(async move { session.stop() })).unwrap(); + assert!(is_closed(&connection)); + } + + // An application driving its own executor can reach NULL from inside it, and executors refuse to nest. + #[test] + fn stop_inside_another_executor() { + let (session, connection) = started(); + futures::executor::block_on(async move { session.stop() }); + assert!(is_closed(&connection)); + } + #[tokio::test] async fn a_terminal_result_waits_until_the_session_is_registered() { let gate = Arc::new(tokio::sync::Notify::new()); diff --git a/rs/moq-mux/src/json.rs b/rs/moq-mux/src/json.rs index 026c489d88..26692fa745 100644 --- a/rs/moq-mux/src/json.rs +++ b/rs/moq-mux/src/json.rs @@ -74,6 +74,13 @@ pub struct Config { /// An optional identifier for the shape of each value, typically a JSON Schema URL. pub schema: Option, + + /// Override the snapshot encoder's [`delta_ratio`](moq_json::snapshot::Config::delta_ratio), + /// or `None` for its default. Only a [`Snapshot`] reads it: a stream has no deltas. + /// + /// Not part of the catalog entry: deltas are a property of the frames, which every consumer + /// decodes the same way, so a reader needs nothing from the entry to follow them. + pub delta_ratio: Option, } impl Config { @@ -89,6 +96,12 @@ impl Config { self } + /// Set [`delta_ratio`](Self::delta_ratio) (a builder, since the struct is `#[non_exhaustive]`). + pub fn with_delta_ratio(mut self, delta_ratio: u32) -> Self { + self.delta_ratio = Some(delta_ratio); + self + } + /// The catalog entry describing a track published under this config in `mode`. pub(crate) fn entry(&self, mode: Mode) -> JsonConfig { let mut entry = JsonConfig::new(mode); @@ -117,6 +130,9 @@ impl Snapshot { if config.compression { json.compression = moq_json::Compression::Deflate; } + if let Some(delta_ratio) = config.delta_ratio { + json.delta_ratio = delta_ratio; + } let inner = moq_json::snapshot::Producer::new(track, json); rendition.set(config.entry(Mode::Snapshot))?; Ok(Self { inner, rendition }) diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index 9b9a877dfb..ceee1b52fa 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -1,6 +1,6 @@ //! In-band authorization: present tokens to the peer and learn what they grant. //! -//! Each side of a moq-lite-06 session presents the credential its connection +//! Each side of a session presents the credential its connection //! already carried (the URL, a client certificate, or nothing) right after //! setup, and learns the [`Grant`] it earned. [`Session::auth`](crate::Session::auth) //! returns the [`Handle`]: [`grant`](Handle::grant) is the union of every token @@ -12,8 +12,10 @@ //! itself takes [`requests`](Handle::requests) before running the session's //! driver, and then answers every token the peer presents. //! -//! Older versions and moq-transport carry no AUTH exchange: there the grant stays -//! `None` and [`add`](Handle::add) fails with [`Error::Unsupported`]. +//! moq-transport draft-17+ carries the same exchange when both sides negotiate the +//! MoQ Auth extension. Older versions, and peers that do not negotiate it, carry no +//! AUTH exchange: there the grant stays `None` and [`add`](Handle::add) fails with +//! [`Error::Unsupported`]. use std::{ collections::{BTreeMap, VecDeque}, @@ -364,6 +366,23 @@ impl Handle { .is_none_or(|union| union.patterns(direction).matches(path)) } + /// The peer turned out not to negotiate AUTH: fail every token as unsupported and + /// close the requests, leaving the union unknown. + pub(crate) fn unsupported(&self) { + let mut state = self.state.lock(); + state.supported = false; + state.opening.clear(); + for slot in state.tokens.values_mut() { + slot.answered.get_or_insert(Err(Error::Unsupported)); + slot.ended.get_or_insert(Error::Unsupported); + } + // Nothing will read a withdrawn slot again. + state.tokens.retain(|_, slot| !slot.withdrawn); + if let Acceptor::App(queue) = &state.acceptor { + queue.close(); + } + } + /// End the session: fail every pending token, end every watch, and close the /// requests. pub(crate) fn close(&self, err: Error) { @@ -720,3 +739,68 @@ impl Gate { } } } + +/// The close reason naming a broadcast published outside the grant. [`Error`] carries +/// no payload, so the path travels here, in the session's own terms. +pub(crate) fn unauthorized_reason(path: &crate::Path) -> String { + format!("unauthorized: {path}") +} + +/// Finds a broadcast this side publishes that its grant never covered, so the session +/// can fail loudly instead of waiting for a subscription that never comes. +/// +/// Waits until the tokens the session presented at setup are answered, then checks +/// each broadcast when it is first announced. A grant that later shrinks withdraws what +/// it no longer covers without aborting: the union is processed before new +/// announcements, so a revocation is never mistaken for a new unauthorized +/// publication. Only the dialing side enforces: a server's publish origin is everything +/// the peer may read, not what it intends to push. +#[derive(Default)] +pub(crate) struct Enforce { + epoch: u64, + permit: Option, + /// Every broadcast admitted so far, still announced. + live: std::collections::HashSet, + setup: bool, +} + +impl Enforce { + /// Resolve with the first broadcast announced outside the union, or `None` once the + /// origin ends. + pub(crate) fn poll( + &mut self, + handle: &Handle, + announced: &mut crate::announce::Consumer, + waiter: &kio::Waiter, + ) -> Poll> { + if !self.setup { + ready_or!(handle.poll_setup_answered(waiter)); + self.setup = true; + } + while let Poll::Ready(union) = handle.poll_union(&mut self.epoch, waiter) { + self.permit = union.map(|grant| grant.publish); + } + // No grant yet (the peer never answered with one): nothing to check against. + let Some(permit) = &self.permit else { + return Poll::Pending; + }; + + loop { + let Some(update) = ready_or!(announced.poll_next(waiter)) else { + return Poll::Ready(None); + }; + match update.kind { + crate::announce::Kind::Announced if !self.live.contains(&update.prefix) => { + if !permit.matches(update.prefix.as_str()) { + return Poll::Ready(Some(update.prefix)); + } + self.live.insert(update.prefix); + } + crate::announce::Kind::Retracted => { + self.live.remove(&update.prefix); + } + _ => {} + } + } + } +} diff --git a/rs/moq-net/src/client.rs b/rs/moq-net/src/client.rs index 510eb44098..2b23f53b05 100644 --- a/rs/moq-net/src/client.rs +++ b/rs/moq-net/src/client.rs @@ -257,6 +257,8 @@ impl Client { // Draft-17+: SETUP is exchanged by the connection driver. // We advertise the request path in our SETUP for URL-less transports. + // The peer's SETUP decides whether AUTH is negotiated. + let auth = crate::auth::Handle::new(true); let (protocol, goaway) = ietf::start(ietf::Config { runtime: runtime.clone(), session: session.clone(), @@ -271,6 +273,7 @@ impl Client { path: self.setup_path.clone(), peer_setup_stream: None, peer_declared: None, + auth: auth.clone(), })?; tracing::debug!(version = ?v, "connected"); @@ -281,7 +284,7 @@ impl Client { None, crate::driver::Protocol::Ietf(protocol), goaway, - crate::auth::Handle::new(false), + auth, )); } Some(ALPN_16) => { @@ -420,6 +423,7 @@ impl Client { path: None, peer_setup_stream: None, peer_declared: Some(peer_declared), + auth: crate::auth::Handle::new(false), })?; ( None, diff --git a/rs/moq-net/src/ietf/auth.rs b/rs/moq-net/src/ietf/auth.rs new file mode 100644 index 0000000000..d4ed90b5db --- /dev/null +++ b/rs/moq-net/src/ietf/auth.rs @@ -0,0 +1,656 @@ +//! The MoQ Auth extension (draft-lcurley-moq-auth-00). +//! +//! The moq-transport binding of the lite-06 Auth Stream (see [`crate::auth`]): each +//! token rides a request stream of its own, answered with the namespace prefixes it +//! grants. Negotiated with the AUTH Setup Option on draft-17+ only, where SETUP is a +//! Key-Value-Pair block. +//! +//! The wire carries prefixes, so a grant is told only when it is a union of subtrees. +//! Anything narrower is refused with NOT_SUPPORTED rather than widened. + +use std::task::Poll; +use std::time::Duration; + +use bytes::Bytes; + +use crate::auth::{Grant, Handle, Issue, Reply, Request}; +use crate::coding::{Decode, DecodeError, Encode, EncodeError, Sizer, Stream}; +use crate::{Error, Path, Pattern, Patterns, SessionError}; + +use super::namespace::{decode_namespace, encode_namespace}; +use super::{Control, Message, RequestId, Version, cluster, peer}; + +/// AUTH Setup Option: the sender speaks this extension. Even, so the value is a bare +/// varint. +pub const AUTH: u64 = 0x40B60; + +/// Whether a version negotiates this extension: draft-17+, like MoQ Cluster. +pub fn supported(version: Version) -> bool { + cluster::supported(version) +} + +/// What the peer declared: `None` for no option, otherwise whether it offered the +/// extension. Only an explicit 1 negotiates it. +pub fn from_setup(params: &super::Parameters, version: Version) -> Option { + if !supported(version) { + return None; + } + params.get_varint(super::ParameterVarInt::Auth).map(|value| value == 1) +} + +/// Offer the extension, on the versions that negotiate it. +pub fn into_setup(params: &mut super::Parameters, version: Version) { + if supported(version) { + params.set_varint(super::ParameterVarInt::Auth, 1); + } +} + +/// Refuse a message on a version that cannot negotiate the extension. +fn check_version(version: Version) -> Result<(), DecodeError> { + match supported(version) { + true => Ok(()), + false => Err(DecodeError::Version), + } +} + +/// AUTH: the first message on an Auth request stream, presenting a token. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Auth { + pub request_id: RequestId, + /// Empty presents the credential the connection already carried. + pub token: Bytes, +} + +impl Message for Auth { + const ID: u64 = 0x40B61; + + fn encode_msg(&self, w: &mut W, version: Version) -> Result<(), EncodeError> { + check_version(version).map_err(|_| EncodeError::Version)?; + self.request_id.encode(w, version)?; + self.token.encode(w, version) + } + + fn decode_msg(r: &mut R, version: Version) -> Result { + check_version(version)?; + Ok(Self { + request_id: RequestId::decode(r, version)?, + token: Bytes::decode(r, version)?, + }) + } +} + +/// AUTH_OK: the grant a token earns, replacing any earlier one on the stream. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AuthOk { + /// What the presenter may publish to the acceptor. + pub publish: Patterns, + /// What the presenter may subscribe to from the acceptor. + pub subscribe: Patterns, + /// How long until the grant lapses, or `None` for never. + pub expires: Option, +} + +/// Largest millisecond count every implementation carries losslessly. +const MAX_EXPIRES_MS: u64 = (1 << 53) - 1; + +/// Encode patterns as namespace prefix tuples, refusing any that is not a subtree: +/// sending `room` for a grant of the literal `room/alice` would hand out more than was +/// granted. +fn encode_prefixes(patterns: &Patterns, w: &mut W, version: Version) -> Result<(), EncodeError> { + patterns.len().encode(w, version)?; + for pattern in patterns { + let prefix = pattern.as_prefix().ok_or(EncodeError::Unsupported)?; + encode_namespace(w, &Path::new(prefix), version)?; + } + Ok(()) +} + +fn decode_prefixes(r: &mut R, version: Version) -> Result { + let count = usize::decode(r, version)?; + let mut patterns = Patterns::new(); + // No preallocation: the count is peer-controlled, and the message size limit bounds + // how many prefixes actually fit. + for _ in 0..count { + let prefix = decode_namespace(r, version)?; + let pattern = Pattern::subtree(prefix.as_str()).map_err(|_| DecodeError::InvalidValue)?; + patterns.insert(pattern); + } + Ok(patterns) +} + +impl Message for AuthOk { + const ID: u64 = 0x40B62; + + fn encode_msg(&self, w: &mut W, version: Version) -> Result<(), EncodeError> { + check_version(version).map_err(|_| EncodeError::Version)?; + encode_prefixes(&self.publish, w, version)?; + encode_prefixes(&self.subscribe, w, version)?; + // 0 means never, so a grant that has already lapsed rounds up to the smallest + // value that still reads as an expiry. + let expires = match self.expires { + None => 0, + Some(expires) => (expires.as_nanos().div_ceil(1_000_000).min(MAX_EXPIRES_MS as u128) as u64).max(1), + }; + expires.encode(w, version) + } + + fn decode_msg(r: &mut R, version: Version) -> Result { + check_version(version)?; + let publish = decode_prefixes(r, version)?; + let subscribe = decode_prefixes(r, version)?; + let expires = match u64::decode(r, version)? { + 0 => None, + ms => Some(Duration::from_millis(ms)), + }; + Ok(Self { + publish, + subscribe, + expires, + }) + } +} + +/// AUTH_ERROR: the acceptor refusing a token, or revoking it after an AUTH_OK. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AuthError { + /// A code from the REQUEST_ERROR registry. + pub code: u64, + pub reason: String, +} + +/// Longest AUTH_ERROR reason, in bytes, matching the lite wire. +const MAX_REASON: usize = 8192; + +impl Message for AuthError { + const ID: u64 = 0x40B63; + + fn encode_msg(&self, w: &mut W, version: Version) -> Result<(), EncodeError> { + check_version(version).map_err(|_| EncodeError::Version)?; + if self.reason.len() > MAX_REASON { + return Err(EncodeError::TooLarge); + } + self.code.encode(w, version)?; + self.reason.as_str().encode(w, version) + } + + fn decode_msg(r: &mut R, version: Version) -> Result { + check_version(version)?; + let code = u64::decode(r, version)?; + let reason = String::decode(r, version)?; + if reason.len() > MAX_REASON { + return Err(DecodeError::InvalidValue); + } + Ok(Self { code, reason }) + } +} + +/// The REQUEST_ERROR codes AUTH_ERROR carries. +const UNAUTHORIZED: u64 = 0x1; +const NOT_SUPPORTED: u64 = 0x3; + +/// The AUTH_ERROR code for a refusal. The public API speaks session codes, and the +/// only one this registry distinguishes is a version mismatch. +fn to_code(code: SessionError) -> u64 { + match code { + SessionError::Version => NOT_SUPPORTED, + _ => UNAUTHORIZED, + } +} + +/// Read an AUTH_ERROR code. NOT_SUPPORTED means the acceptor could not tell the grant, +/// not that it refused one; every other code is a refusal. +fn from_code(code: u64) -> Error { + match code { + NOT_SUPPORTED => Error::Unsupported, + _ => Error::Session(SessionError::Unauthorized), + } +} + +/// What the peer's connection credential earns by default: publishing what our +/// subscribe half accepts, and subscribing to what our publish half serves. +pub(super) fn peer_grant( + publish: Option<&crate::origin::Consumer>, + subscribe: Option<&crate::origin::Producer>, +) -> Grant { + Grant { + publish: subscribe.map(|origin| origin.allowed()).unwrap_or_default(), + subscribe: publish.map(|origin| origin.allowed()).unwrap_or_default(), + expires: None, + } +} + +/// Present every token this side adds, one AUTH request each, once the peer's SETUP +/// says it negotiated the extension. Never returns: a session without it just fails +/// the tokens as unsupported. +pub(super) async fn run_present( + runtime: crate::time::Clock, + session: S, + control: Control, + handle: Handle, + peer_setup: peer::PeerSetup, + version: Version, + going_away: crate::goaway::GoingAway, +) where + S: crate::transport::poll::Boxable, +{ + if !peer_setup.get().await.auth { + handle.unsupported(); + return std::future::pending().await; + } + + let mut tasks = crate::util::TaskSet::owned(); + while let Some((id, token)) = tasks.drive(|waiter| handle.poll_opening(waiter)).await { + let present = Present { + runtime: runtime.clone(), + session: session.clone(), + control: control.clone(), + handle: handle.clone(), + version, + id, + }; + let going_away = going_away.clone(); + tasks.push(async move { + // After a GOAWAY the peer must not see new requests. + let err = match going_away.is_set() { + true => Error::GoingAway, + false => present.run(token).await, + }; + match &err { + Error::Cancel | Error::Unsupported | Error::Transport(_) | Error::Session(_) => { + tracing::debug!(%err, "auth token ended") + } + err => tracing::warn!(%err, "auth token ended"), + } + present.handle.ended(present.id, err); + }); + } + std::future::pending().await +} + +/// One token's Auth request. +struct Present { + runtime: crate::time::Clock, + session: S, + control: Control, + handle: Handle, + version: Version, + id: u64, +} + +impl Present { + /// Send the token, then track its grant until either side ends it, resolving with why. + async fn run(&self, token: Bytes) -> Error { + let request_id = match self.control.next_request_id(&self.runtime).await { + Ok(id) => id, + Err(err) => return err, + }; + let mut stream = match Stream::open(&mut self.session.clone(), self.version).await { + Ok(stream) => stream, + Err(err) => return err, + }; + if let Err(err) = stream.writer.encode_message(&Auth { request_id, token }).await { + return err; + } + + let mut answered = false; + loop { + enum Next { + Withdrawn, + Reply(Result, Error>), + } + + let next = { + let mut read = std::pin::pin!(read_message(&mut stream)); + kio::wait(|waiter| { + if self.handle.poll_withdrawn(self.id, waiter).is_ready() { + return Poll::Ready(Next::Withdrawn); + } + waiter.poll_future(read.as_mut()).map(Next::Reply) + }) + .await + }; + + let (id, mut data) = match next { + // Resetting our side is what tells the peer. + Next::Withdrawn => return Error::Cancel, + Next::Reply(Ok(Some(msg))) => msg, + // The peer ended the grant without revoking it. + Next::Reply(Ok(None)) if answered => return Error::Cancel, + // A peer that closes before answering could not tell us anything. + Next::Reply(Ok(None)) => return Error::Unsupported, + Next::Reply(Err(err)) => return err, + }; + + match id { + AuthOk::ID => { + let ok = match AuthOk::decode_msg(&mut data, self.version) { + Ok(ok) => ok, + Err(err) => return err.into(), + }; + let now = crate::runtime::Timers::now(&self.runtime); + // The expiry is the peer's number: one past the local clock's range is + // malformed, not a reason to panic. + let expires = match ok.expires.map(|expires| now.checked_add(expires)) { + Some(None) => return Error::ProtocolViolation, + expires => expires.flatten(), + }; + answered = true; + self.handle.granted( + self.id, + Grant { + publish: ok.publish, + subscribe: ok.subscribe, + expires, + }, + ); + } + AuthError::ID => { + let refused = match AuthError::decode_msg(&mut data, self.version) { + Ok(refused) => refused, + Err(err) => return err.into(), + }; + let err = from_code(refused.code); + tracing::warn!(%err, code = refused.code, reason = %refused.reason, "auth token refused"); + // A grant the acceptor could not tell is unknown, not empty, so only a + // real refusal settles the union. + if !matches!(err, Error::Unsupported) { + self.handle.refused(self.id); + } + return err; + } + _ => return Error::UnexpectedMessage, + } + } + } +} + +/// Read one `[type][size][body]` message, or `None` once the peer finished the stream. +async fn read_message( + stream: &mut Stream, +) -> Result, Error> { + let Some(id) = stream.reader.decode_maybe::().await? else { + return Ok(None); + }; + let size: u16 = stream.reader.decode().await?; + let data = stream.reader.read_exact(size as usize).await?; + Ok(Some((id, data))) +} + +/// Answers the peer's Auth requests: the app's verdict when it took the requests, +/// otherwise the default grant for the connection's own credential. +#[derive(Clone)] +pub(super) struct Serve { + pub runtime: crate::time::Clock, + pub handle: Handle, + /// What the peer's connection credential earns by default. + pub peer_grant: Grant, +} + +impl Serve { + /// Answer one Auth request. The stream lives as long as the token. + pub(super) async fn run( + self, + mut stream: Stream, + msg: Auth, + version: Version, + ) { + let issue = Issue::shared(); + match self.handle.acceptor() { + Some(requests) => { + // A closed queue hands the request back, and dropping it refuses the token. + let _ = requests.try_push(Request::new(msg.token, issue.clone())); + } + // Only the connection's own credential has a default answer; a token needs + // someone to verify it. + None if !msg.token.is_empty() => { + let reply = AuthError { + code: NOT_SUPPORTED, + reason: "tokens are not verified in band".to_string(), + }; + if stream.writer.encode_message(&reply).await.is_ok() { + let _ = stream.writer.close().await; + } + return; + } + None => issue.lock().outbox.push_back(Reply::Grant(self.peer_grant)), + } + + let err = match serve_issue(&self.runtime, &issue, &mut stream, version).await { + Ok(()) => Error::Cancel, + Err(err) => { + match &err { + Error::Cancel | Error::Unsupported | Error::Stream(_) | Error::Session(_) | Error::Transport(_) => { + tracing::debug!(%err, "auth request ended") + } + err => tracing::warn!(%err, "auth request error"), + } + err + } + }; + issue.lock().peer.get_or_insert(err); + } +} + +/// Write the acceptor's replies until either side ends the token. +async fn serve_issue( + runtime: &crate::time::Clock, + issue: &kio::Shared, + stream: &mut Stream, + version: Version, +) -> Result<(), Error> { + enum Next { + Withdrawn(Result<(), Error>), + Reply(Option), + } + + loop { + let next = kio::wait(|waiter| { + let mut cx = std::task::Context::from_waker(waiter.waker()); + if let Poll::Ready(res) = stream.reader.poll_closed(&mut cx) { + return Poll::Ready(Next::Withdrawn(res)); + } + match issue.poll(waiter, |issue| match issue.outbox.is_empty() && !issue.done { + true => Poll::Pending, + false => Poll::Ready(()), + }) { + Poll::Ready(mut issue) => Poll::Ready(Next::Reply(issue.outbox.pop_front())), + Poll::Pending => Poll::Pending, + } + }) + .await; + + match next { + // The presenter withdrew the token, by FIN or by a cancelling reset. + Next::Withdrawn(Ok(()) | Err(Error::Stream(crate::StreamError::Cancel))) => break, + Next::Withdrawn(Err(err)) => return Err(err), + Next::Reply(Some(Reply::Grant(grant))) => { + let now = crate::runtime::Timers::now(runtime); + let ok = AuthOk { + publish: grant.publish, + subscribe: grant.subscribe, + expires: grant.expires.map(|at| at.saturating_duration_since(now)), + }; + // Validated before anything is written, so an unrepresentable grant never + // leaves half a message on the wire. Never widen it: refuse, which revokes + // whatever this stream granted before. + if let Err(EncodeError::Unsupported) = ok.encode_msg(&mut Sizer::default(), version) { + tracing::debug!("auth grant not representable as prefixes; refusing the token"); + issue.lock().done = true; + let refused = AuthError { + code: NOT_SUPPORTED, + reason: "grant not representable as namespace prefixes".to_string(), + }; + stream.writer.encode_message(&refused).await?; + break; + } + stream.writer.encode_message(&ok).await?; + } + Next::Reply(Some(Reply::Refuse { code, reason })) => { + let refused = AuthError { + code: to_code(code), + reason, + }; + stream.writer.encode_message(&refused).await?; + } + // The app is done with the grant, or refused the token: close our side. + Next::Reply(None) => break, + } + } + + stream.writer.finish()?; + stream.writer.closed().await +} + +#[cfg(test)] +mod tests { + use super::*; + + const VERSION: Version = Version::Draft17; + + fn patterns(prefixes: &[&str]) -> Patterns { + prefixes.iter().map(|p| Pattern::subtree(p).unwrap()).collect() + } + + fn round_trip(msg: &T) -> T { + let mut buf = bytes::BytesMut::new(); + msg.encode_msg(&mut buf, VERSION).unwrap(); + let mut slice = &buf[..]; + let got = T::decode_msg(&mut slice, VERSION).unwrap(); + assert!(slice.is_empty(), "trailing bytes after decode"); + got + } + + /// Every draft that negotiates the extension round-trips the option; the drafts + /// before the unified SETUP carry none. + #[test] + fn setup_option_round_trips_on_supported_drafts() { + for version in [ + Version::Draft17, + Version::Draft18, + Version::Draft19, + Version::Draft20, + Version::Draft21, + Version::Draft22, + ] { + let mut params = super::super::Parameters::default(); + assert_eq!(from_setup(¶ms, version), None); + into_setup(&mut params, version); + assert_eq!(from_setup(¶ms, version), Some(true), "{version:?}"); + } + for version in [Version::Draft14, Version::Draft15, Version::Draft16] { + let mut params = super::super::Parameters::default(); + into_setup(&mut params, version); + assert_eq!(params.get_varint(super::super::ParameterVarInt::Auth), None); + assert_eq!(from_setup(¶ms, version), None, "{version:?}"); + } + } + + /// An explicit value other than 1 is an implementation that declined, which is + /// not the same statement as saying nothing. + #[test] + fn only_one_negotiates() { + let mut params = super::super::Parameters::default(); + params.set_varint(super::super::ParameterVarInt::Auth, 0); + assert_eq!(from_setup(¶ms, VERSION), Some(false)); + } + + #[test] + fn auth_round_trips() { + for token in [Bytes::new(), Bytes::from_static(b"eyJhbGciOi.jwt")] { + let msg = Auth { + request_id: RequestId(4), + token, + }; + assert_eq!(round_trip(&msg), msg); + } + } + + /// The root grant and a union of prefixes survive the trip, as distinct from the + /// empty grant. + #[test] + fn auth_ok_round_trips() { + for (publish, subscribe, expires) in [ + (patterns(&[""]), patterns(&[]), None), + ( + patterns(&["room/alice", "room/bob"]), + patterns(&["room"]), + Some(Duration::from_secs(60)), + ), + ] { + let msg = AuthOk { + publish, + subscribe, + expires, + }; + assert_eq!(round_trip(&msg), msg); + } + } + + /// A prefix is a namespace tuple, the way SUBSCRIBE_NAMESPACE spells one. + #[test] + fn prefixes_are_namespace_tuples() { + let msg = AuthOk { + publish: patterns(&["room/alice"]), + subscribe: Patterns::new(), + expires: None, + }; + let mut buf = bytes::BytesMut::new(); + msg.encode_msg(&mut buf, VERSION).unwrap(); + assert_eq!(&buf[..], b"\x01\x02\x04room\x05alice\x00\x00"); + } + + #[test] + fn auth_error_round_trips() { + let msg = AuthError { + code: UNAUTHORIZED, + reason: "expired".to_string(), + }; + assert_eq!(round_trip(&msg), msg); + } + + /// Only subtrees fit the prefix encoding, alone or in a union; anything narrower is + /// refused, never widened to its head. + #[test] + fn unrepresentable_grants_are_refused() { + for union in [&["room/alice"][..], &["room/*/cam"], &["**/cam"], &["room/**", "lobby"]] { + let msg = AuthOk { + publish: union.iter().map(|p| Pattern::try_from(*p).unwrap()).collect(), + subscribe: Patterns::new(), + expires: None, + }; + let mut buf = bytes::BytesMut::new(); + assert!( + matches!(msg.encode_msg(&mut buf, VERSION), Err(EncodeError::Unsupported)), + "{union:?} encoded" + ); + } + } + + #[test] + fn older_drafts_have_no_auth() { + for version in [Version::Draft14, Version::Draft15, Version::Draft16] { + let mut buf = bytes::BytesMut::new(); + let msg = Auth { + request_id: RequestId(0), + token: Bytes::new(), + }; + assert!(matches!(msg.encode_msg(&mut buf, version), Err(EncodeError::Version))); + let mut slice: &[u8] = &[0, 0]; + assert!(matches!( + Auth::decode_msg(&mut slice, version), + Err(DecodeError::Version) + )); + } + } + + /// NOT_SUPPORTED is the acceptor unable to tell a grant, never a refusal. + #[test] + fn not_supported_is_unsupported() { + assert!(matches!(from_code(NOT_SUPPORTED), Error::Unsupported)); + assert!(matches!( + from_code(UNAUTHORIZED), + Error::Session(SessionError::Unauthorized) + )); + assert_eq!(to_code(SessionError::Unauthorized), UNAUTHORIZED); + } +} diff --git a/rs/moq-net/src/ietf/mod.rs b/rs/moq-net/src/ietf/mod.rs index 8ae7dac1ec..f07640f303 100644 --- a/rs/moq-net/src/ietf/mod.rs +++ b/rs/moq-net/src/ietf/mod.rs @@ -7,6 +7,7 @@ #[macro_use] mod parameters; mod adapter; +pub(crate) mod auth; pub mod cluster; mod control; pub(crate) mod error; diff --git a/rs/moq-net/src/ietf/parameters.rs b/rs/moq-net/src/ietf/parameters.rs index 7b3b426f88..669f4f62d7 100644 --- a/rs/moq-net/src/ietf/parameters.rs +++ b/rs/moq-net/src/ietf/parameters.rs @@ -28,6 +28,8 @@ pub enum ParameterVarInt { Solicit = super::solicit::SOLICIT, /// HIDDEN, from the MoQ Hidden extension. Hidden = super::hidden::HIDDEN, + /// AUTH, from the MoQ Auth extension. + Auth = super::auth::AUTH, #[num_enum(catch_all)] Unknown(u64), } diff --git a/rs/moq-net/src/ietf/peer.rs b/rs/moq-net/src/ietf/peer.rs index 20f793ed12..336ffb755a 100644 --- a/rs/moq-net/src/ietf/peer.rs +++ b/rs/moq-net/src/ietf/peer.rs @@ -19,6 +19,9 @@ pub(crate) struct Peer { /// MoQ Hidden: whether the peer understands the HIDDEN parameter on /// SUBSCRIBE_NAMESPACE, so we may send it. pub hidden: bool, + + /// MoQ Auth: whether both sides negotiated the Auth request streams. + pub auth: bool, } /// Shared slot for [`Peer`], filled when the peer's SETUP is read. @@ -78,6 +81,7 @@ mod tests { }, solicit: None, hidden: false, + auth: false, }; let slot = PeerSetup::default(); @@ -89,6 +93,7 @@ mod tests { }, solicit: Some(true), hidden: true, + auth: true, }); assert_eq!(slot.get().await, first); diff --git a/rs/moq-net/src/ietf/publish.rs b/rs/moq-net/src/ietf/publish.rs index cdf7086e0b..3537bee311 100644 --- a/rs/moq-net/src/ietf/publish.rs +++ b/rs/moq-net/src/ietf/publish.rs @@ -126,6 +126,8 @@ pub(crate) enum PublishDoneStatus { InternalError, /// The track is no longer being published. TrackEnded, + /// The publisher's grant no longer covers the track (MoQ Auth). + Unauthorized, } impl PublishDoneStatus { @@ -144,6 +146,7 @@ impl PublishDoneStatus { | Version::Draft21 | Version::Draft22 => match self { Self::InternalError => 0x0, + Self::Unauthorized => 0x1, Self::TrackEnded => 0x2, }, } @@ -159,6 +162,30 @@ pub struct PublishDone<'a> { pub reason_phrase: Cow<'a, str>, } +impl PublishDone<'_> { + /// How the publisher ended the subscription: cleanly, or with the error its status names. + pub(crate) fn end(&self, version: Version) -> Result<(), crate::Error> { + match self.status_code { + code if code == PublishDoneStatus::TrackEnded.code(version) => Ok(()), + // SUBSCRIPTION_ENDED: the subscription reached the end its filter asked for. + // Draft-20 removed it and left 0x3 unassigned. + 0x3 if matches!( + version, + Version::Draft14 + | Version::Draft15 + | Version::Draft16 + | Version::Draft17 + | Version::Draft18 + | Version::Draft19 + ) => + { + Ok(()) + } + code => Err(crate::Error::Remote(u32::try_from(code).unwrap_or(u32::MAX))), + } + } +} + impl Message for PublishDone<'_> { const ID: u64 = 0x0b; @@ -692,6 +719,33 @@ mod tests { } } + /// A subscriber ends the track the way the publisher said it ended: cleanly for a + /// finished track or a reached filter end, with the publisher's code otherwise. + #[test] + fn publish_done_end_follows_the_status() { + let done = |status_code| PublishDone { + request_id: None, + status_code, + stream_count: 0, + reason_phrase: "".into(), + }; + + for version in [Version::Draft14, Version::Draft19, Version::Draft20, Version::Draft22] { + assert!(done(0x2).end(version).is_ok(), "{version:?}"); + assert!( + matches!(done(0x0).end(version), Err(crate::Error::Remote(0x0))), + "{version:?}" + ); + } + + // SUBSCRIPTION_ENDED is clean until draft-20 unassigned it. + assert!(done(0x3).end(Version::Draft19).is_ok()); + assert!(matches!( + done(0x3).end(Version::Draft20), + Err(crate::Error::Remote(0x3)) + )); + } + #[test] fn test_publish_v18_round_trip() { let msg = Publish { diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 1b4d7f3af6..527d6ab2a7 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -197,6 +197,10 @@ struct Namespaces { /// The open PUBLISH_NAMESPACE request carrying each advertised namespace. Empty when /// the entries ride a SUBSCRIBE_NAMESPACE stream inline. requests: HashMap>, + /// What our grant lets us publish (MoQ Auth), `None` while unknown. + permit: Option, + /// The grant union's epoch last applied to `permit`. + epoch: u64, } impl Namespaces { @@ -206,8 +210,15 @@ impl Namespaces { target, watched: HashMap::new(), requests: HashMap::new(), + permit: None, + epoch: 0, } } + + /// Whether our grant lets us advertise `path`. An unknown grant allows everything. + fn permitted(&self, path: &crate::Path) -> bool { + self.permit.as_ref().is_none_or(|permit| permit.matches(path.as_str())) + } } /// What woke an announce-forwarding loop. @@ -218,6 +229,8 @@ enum NamespaceEvent { Update(Option), /// The retry sleep fired: re-offer whatever the peer should be holding and isn't. Retry, + /// Our grant changed (MoQ Auth): re-check every namespace against it. + Regrant(Option), } #[derive(Clone)] @@ -242,6 +255,9 @@ pub(super) struct Publisher { // Shared across request handlers; None marks a dispatched subscription still resolving. joins: kio::Shared>>, version: Version, + // Our grant (MoQ Auth): only what it lets us publish is advertised and served, and a + // shrink withdraws what it no longer covers. + auth: crate::auth::Handle, } /// The snapshot a joining FETCH inherits from its subscription. @@ -297,9 +313,16 @@ where peer_setup, joins: Default::default(), version, + auth: crate::auth::Handle::new(false), } } + /// Bound what we publish by the grant this session's tokens earn (MoQ Auth). + pub fn with_auth(mut self, auth: crate::auth::Handle) -> Self { + self.auth = auth; + self + } + /// What the peer declared in its SETUP, or the default (extension off) on a version /// that cannot negotiate it. /// @@ -470,6 +493,22 @@ where tracing::info!(id = %request_id, broadcast = %absolute, track = %track_name, "subscribe started"); + // Serve only what our grant lets us publish (MoQ Auth), and stop once it no + // longer does. Checked before resolving, so a denied request never reaches the + // origin. + let mut gate = crate::auth::Gate::new( + self.auth.clone(), + msg.track_namespace.to_owned(), + crate::auth::Direction::Publish, + ); + if !self + .auth + .allows(crate::auth::Direction::Publish, msg.track_namespace.as_str()) + { + let err = Error::Unauthorized; + return self.reject_subscribe(stream, request_id, &err, "not granted").await; + } + // Stats (subscriptions, viewer refcount, groups/frames/bytes) are counted in // the model, through the tagged `origin::Consumer` the broadcast resolves from. @@ -607,6 +646,10 @@ where if let Poll::Ready(res) = waiter.poll_future(serve.as_mut()) { return Poll::Ready(res); } + if gate.poll_denied(waiter).is_ready() { + tracing::info!(broadcast = %absolute, track = %track_name, "subscription no longer authorized"); + return Poll::Ready(Err(Error::Unauthorized)); + } let mut cx = std::task::Context::from_waker(waiter.waker()); if stream.reader.poll_closed(&mut cx).is_ready() || closed_session.poll_closed(&mut cx).is_ready() { return Poll::Ready(Ok(())); @@ -619,6 +662,7 @@ where // Send PublishDone let (status, reason) = match &res { Ok(()) => (ietf::PublishDoneStatus::TrackEnded, "track ended"), + Err(Error::Unauthorized) => (ietf::PublishDoneStatus::Unauthorized, "not granted"), Err(_) => (ietf::PublishDoneStatus::InternalError, "internal error"), }; let _ = stream.writer.encode(&ietf::PublishDone::ID).await; @@ -1308,17 +1352,23 @@ where suffix: &crate::PathOwned, path: &crate::PathOwned, ) -> Result<(), Error> { + let permitted = ns.permitted(path); let Namespaces { peer, target, watched, requests, + .. } = ns; let Some(watch) = watched.get(suffix) else { return Ok(()); }; - let advert = self.select(&watch.route, peer); + // Nothing our grant does not cover reaches the wire, and a shrink withdraws it. + let advert = match permitted { + true => self.select(&watch.route, peer), + false => Advert::None, + }; let refused = watch.refused; let wanted = advert.wanted(); let held = watch.sent.wanted(); @@ -1811,6 +1861,12 @@ where ) -> Result<(), Error> { let mut announced = origin.announced(); + // With MoQ Auth, wait for the answer to the credential we presented at setup, so + // the first advertisement is already checked against our grant. + if self.peer_setup.get().await.auth { + kio::wait(|waiter| self.auth.poll_setup_answered(waiter)).await; + } + // When to re-offer whatever the peer should hold and doesn't, and how long to wait // the next time that fails. Jittered so a relay's namespaces don't all come back on // the same tick. @@ -1833,12 +1889,17 @@ where retry.set(retry_at); let event = { - let Namespaces { target, .. } = &mut ns; + let Namespaces { target, epoch, .. } = &mut ns; kio::wait(|waiter| { let mut cx = std::task::Context::from_waker(waiter.waker()); if let Poll::Ready(res) = target.poll_closed(&mut cx) { return Poll::Ready(NamespaceEvent::Closed(res)); } + // A grant change applies before the next update, so a namespace it no + // longer covers is withdrawn rather than re-sent. + if let Poll::Ready(union) = self.auth.poll_union(epoch, waiter) { + return Poll::Ready(NamespaceEvent::Regrant(union)); + } if let Poll::Ready(update) = announced.poll_next(waiter) { return Poll::Ready(NamespaceEvent::Update(update)); } @@ -1871,6 +1932,14 @@ where self.sync_namespace(&mut ns, &suffix, &path).await?; } } + NamespaceEvent::Regrant(union) => { + ns.permit = union.map(|grant| grant.publish); + let suffixes: Vec = ns.watched.keys().cloned().collect(); + for suffix in suffixes { + let path = prefix.join(&suffix); + self.sync_namespace(&mut ns, &suffix, &path).await?; + } + } NamespaceEvent::Update(None) => { // The origin is gone: withdraw everything, then finish the // stream and wait for delivery. @@ -4338,6 +4407,7 @@ mod tests { }, solicit, hidden: false, + auth: false, }); slot } diff --git a/rs/moq-net/src/ietf/session.rs b/rs/moq-net/src/ietf/session.rs index f1c7258608..af424c9227 100644 --- a/rs/moq-net/src/ietf/session.rs +++ b/rs/moq-net/src/ietf/session.rs @@ -8,8 +8,8 @@ use crate::{ }; use super::{ - Control, Message, Publisher, Subscriber, Version, adapter::ControlStreamAdapter, cluster, hidden, peer, solicit, - subscriber::is_protocol_violation, + Control, Message, Publisher, Subscriber, Version, adapter::ControlStreamAdapter, auth, cluster, hidden, peer, + solicit, subscriber::is_protocol_violation, }; /// Everything one moq-transport session needs to start. @@ -62,6 +62,11 @@ pub struct Config { /// What that pre-read SETUP declared, so the session does not have to parse it /// twice. `None` when [`Self::peer_setup_stream`] is. pub peer_declared: Option, + + /// The session's auth handle, created before [`start`] so a server can take the + /// peer's token requests during its handshake. Supports AUTH exactly when the + /// version can negotiate it; the peer's SETUP decides whether it does. + pub auth: crate::auth::Handle, } pub fn start(config: Config) -> Result<(MaybeSendBox<'static, Result<(), Error>>, crate::goaway::Handle), Error> @@ -82,6 +87,7 @@ where path, peer_setup_stream, peer_declared, + auth, } = config; // GOAWAY wiring: the public Session holds one half (drain trigger, received @@ -90,7 +96,27 @@ where // server to open connections (draft-19 sect 10.4). let (goaway_handle, goaway) = crate::goaway::Handle::new(!client); + // What the peer's connection credential earns by default, from the caller's real + // handles before the empty-half defaulting below. + let peer_grant = auth::peer_grant(publish.as_ref(), subscribe.as_ref()); + + // Present the connection's own credential (the empty token) right away, so both + // sides learn their grant without waiting on the app. Draft-17+ only; the peer's + // SETUP then decides whether it is ever sent. + // A handle that already knows the peer declined (a gated server accept) refuses it. + let setup_token = match auth::supported(version) { + true => auth.present(bytes::Bytes::new(), true).ok(), + false => None, + }; + let driver = async move { + // Held for the life of the session. + let _setup_token = setup_token; + // Released on any exit, so nothing waits on a token the session will never answer. + let _auth_close = AuthClose(auth.clone()); + // Decided once, before any Auth request can be accepted: the app took the requests + // before running the driver, or the session answers itself. + let _ = auth.acceptor(); // Our own Hop ID, taken from whichever origin the caller actually supplied so // every session out of this process stamps the same one and cross-session loop // detection works. Read BEFORE the placeholders below: their ids are random and @@ -198,6 +224,8 @@ where dispatch_session, publisher.clone(), subscriber.clone(), + peer_setup.clone(), + None, version ))); // Unsolicited PUBLISH_NAMESPACE unless the peer requires solicitation; @@ -278,6 +306,20 @@ where }; let control = Control::new(None, client); + // Only the dialing side fails loud on a publication outside its grant: a + // server's publish origin is everything the peer may read, not what it + // intends to push. + let enforce = { + let auth = auth.clone(); + let origin = publish.clone(); + let session = session.clone(); + async move { + match client { + true => enforce_grant(auth, origin, session).await, + false => std::future::pending().await, + } + } + }; let publisher = Publisher::new( runtime.clone(), session.clone(), @@ -286,13 +328,14 @@ where peer_hop, peer_setup.clone(), version, - ); + ) + .with_auth(auth.clone()); let (tasks, mut task_set) = TaskSet::new(); let subscriber = Subscriber::new( runtime.clone(), session.clone(), subscribe, - control, + control.clone(), peer_hop, peer_setup.clone(), self_origin, @@ -300,7 +343,24 @@ where version, tasks, goaway.going_away.clone(), + ) + .with_auth(auth.clone()); + + // Our tokens, one Auth request each, once the peer's SETUP negotiates it. + let present = auth::run_present( + runtime.clone(), + session.clone(), + control.clone(), + auth.clone(), + peer_setup.clone(), + version, + goaway.going_away.clone(), ); + let serve = auth::Serve { + runtime: runtime.clone(), + handle: auth.clone(), + peer_grant, + }; let sub_ns_session = session.clone(); let sub_ns = subscriber.clone(); @@ -333,9 +393,13 @@ where session.clone(), publisher.clone(), subscriber.clone(), + peer_setup.clone(), + Some(serve), version ))); let mut goaway_recv = std::pin::pin!(err_only(goaway_recv)); + let mut present = std::pin::pin!(present); + let mut enforce = std::pin::pin!(err_only(enforce)); let mut setup = std::pin::pin!(setup); // Unsolicited PUBLISH_NAMESPACE unless the peer requires solicitation; // see `Publisher::run_publish_namespaces`. @@ -377,6 +441,11 @@ where if let Poll::Ready(err) = waiter.poll_future(goaway_recv.as_mut()) { return Poll::Ready(Err(err)); } + // Presenting tokens never ends the session. + let _ = waiter.poll_future(present.as_mut()); + if let Poll::Ready(err) = waiter.poll_future(enforce.as_mut()) { + return Poll::Ready(Err(err)); + } if waiter.poll_future(setup.as_mut()).is_ready() { return Poll::Ready(Ok(())); } @@ -395,6 +464,11 @@ where } }; + auth.close(match &res { + Ok(()) => Error::Cancel, + Err(err) => err.clone(), + }); + match &res { Err(Error::Transport(_)) => { tracing::info!("session terminated"); @@ -499,6 +573,7 @@ fn peer_from_params(params: &ietf::Parameters, version: Version) -> Result( cluster::peer_into_setup(&mut parameters, self_origin, cost, version); solicit::into_setup(&mut parameters, version); hidden::into_setup(&mut parameters, version); + auth::into_setup(&mut parameters, version); let parameters = parameters.encode_bytes(version)?; writer.encode(&setup::Setup { parameters }).await?; @@ -730,6 +806,9 @@ async fn run_dispatch( session: S, publisher: Publisher, mut subscriber: Subscriber, + peer_setup: peer::PeerSetup, + // Answers the peer's Auth requests, on the versions that can negotiate them. + serve: Option, version: Version, ) -> Result<(), Error> where @@ -741,6 +820,13 @@ where // costs a handshake round rather than blocking. let peer = subscriber.peer().await; + // An AUTH from a peer that did not negotiate MoQ Auth is an unknown request, which + // falls through to the protocol violation below. + let serve = match peer_setup.get().await.auth { + true => serve, + false => None, + }; + // From the same slot, so this costs nothing extra: it decides whether an unsolicited // advertisement is the peer ignoring our own SETUP (MoQ Solicit). let declared = subscriber.solicit().await; @@ -800,6 +886,14 @@ where ietf::Publish::ID | ietf::PublishNamespace::ID => { tasks.push(subscriber.handle_stream(id, data, stream, peer, declared)?); } + auth::Auth::ID if let Some(serve) = &serve => { + let mut data = data; + let msg = auth::Auth::decode_msg(&mut data, version)?; + if !data.is_empty() { + return Err(Error::WrongSize); + } + tasks.push(serve.clone().run(stream, msg, version)); + } _ => { tracing::warn!(id, "unexpected bidi stream type"); return Err(Error::UnexpectedStream); @@ -869,6 +963,38 @@ async fn run_goaway( } } +/// Closes the auth handle when the session driver ends without finishing, releasing +/// anything still waiting on a token. +struct AuthClose(crate::auth::Handle); + +impl Drop for AuthClose { + fn drop(&mut self) { + self.0.close(Error::Cancel); + } +} + +/// Close the session when our origin announces a broadcast our grant never covered, +/// instead of leaving it to wait for a subscription that never comes. See +/// [`crate::auth::Enforce`]. +async fn enforce_grant( + auth: crate::auth::Handle, + origin: origin::Consumer, + mut session: S, +) -> Result<(), Error> { + let mut announced = origin.announced(); + let mut check = crate::auth::Enforce::default(); + let Some(path) = kio::wait(|waiter| check.poll(&auth, &mut announced, waiter)).await else { + return Ok(()); + }; + tracing::error!(broadcast = %origin.absolute(&path), "publishing outside our grant; closing the session"); + let err = Error::Unauthorized; + session.close( + SessionError::from(&err).to_code(), + &crate::auth::unauthorized_reason(&path), + ); + Err(err) +} + #[cfg(test)] mod tests { use super::*; @@ -941,6 +1067,7 @@ mod tests { }, ..Default::default() }), + auth: crate::auth::Handle::new(false), }) .expect("start the session"); @@ -992,6 +1119,7 @@ mod tests { peer_setup_stream: None, // The requests wait on the peer's SETUP (MoQ Hidden). peer_declared: Some(peer::Peer::default()), + auth: crate::auth::Handle::new(false), }) .expect("start the session"); let _driver = tokio::spawn(driver); @@ -1042,6 +1170,7 @@ mod tests { path: None, peer_setup_stream: None, peer_declared, + auth: crate::auth::Handle::new(false), }) .expect("start the session"); let _driver = tokio::spawn(driver); @@ -1096,6 +1225,108 @@ mod tests { ); } + /// The AUTH message type as it leads an Auth request on the draft-18 wire. + fn auth_type() -> Vec { + let mut buf = Vec::new(); + auth::Auth::ID.encode(&mut buf, Version::Draft18).unwrap(); + buf + } + + /// A publishing draft-18 session with AUTH available locally, against a peer that + /// declared `auth`. Everything the session needs to keep running is held here. + struct AuthSession { + handle: crate::auth::Handle, + log: crate::lite::test_transport::Log, + _origin: crate::origin::Producer, + _cam: crate::AnnounceProducer, + _gate: kio::Producer, + _goaway: crate::goaway::Handle, + _driver: tokio::task::JoinHandle>, + } + + fn auth_session(auth: bool) -> AuthSession { + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let cam = origin.announce("solo-cam", crate::origin::Route::default()).unwrap(); + + let gate = kio::Producer::new(true); + let session = crate::lite::test_transport::SinkSession::gated_bi(gate.consume()); + let log = session.log.clone(); + + let handle = crate::auth::Handle::new(true); + let (driver, goaway) = start(Config { + runtime: crate::time::Clock::tokio(), + session, + setup: None, + request_id_max: None, + client: true, + publish: Some(origin.consume()), + subscribe: None, + peer_hop: None, + cost: None, + version: Version::Draft18, + path: None, + peer_setup_stream: None, + peer_declared: Some(peer::Peer { + auth, + ..Default::default() + }), + auth: handle.clone(), + }) + .expect("start the session"); + AuthSession { + handle, + log, + _origin: origin, + _cam: cam, + _gate: gate, + _goaway: goaway, + _driver: tokio::spawn(driver), + } + } + + /// A peer that never offered MoQ Auth sees no Auth request, the session keeps + /// working, and every token fails as unsupported rather than hanging. + #[tokio::test(start_paused = true)] + async fn a_peer_without_auth_sees_no_auth_request() { + let session = auth_session(false); + let (handle, log) = (&session.handle, &session.log); + + let err = tokio::time::timeout(std::time::Duration::from_secs(1), handle.add("token")) + .await + .expect("unsupported promptly") + .err() + .expect("no AUTH on this session"); + assert!(matches!(err, Error::Unsupported), "{err:?}"); + assert_eq!(handle.grant().peek(), None, "no grant without the extension"); + + for _ in 0..ANNOUNCE_TURNS { + if occurrences(log, b"solo-cam") > 0 { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(1)).await; + } + assert_eq!(occurrences(log, b"solo-cam"), 1, "the session stopped advertising"); + assert_eq!( + occurrences(log, &auth_type()), + 0, + "sent AUTH to a peer that never offered it" + ); + } + + /// A peer that offered it gets the connection's own credential right away. + #[tokio::test(start_paused = true)] + async fn a_negotiating_peer_gets_the_setup_token() { + let session = auth_session(true); + let log = &session.log; + for _ in 0..ANNOUNCE_TURNS { + if occurrences(log, &auth_type()) > 0 { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(1)).await; + } + assert_eq!(occurrences(log, &auth_type()), 1, "one AUTH for the setup token"); + } + /// The declared Hop ID must be the caller's own origin, whichever half carries it. /// /// A subscribe-only session (an ingest that publishes nothing) still routes, so @@ -1151,6 +1382,7 @@ mod tests { // Pre-settled, so nothing waits on a SETUP the dead stream will never // carry and the dispatch loop actually runs. peer_declared: Some(peer::Peer::default()), + auth: crate::auth::Handle::new(false), }) .expect("start the session"); @@ -1341,6 +1573,7 @@ mod tests { path: None, peer_setup_stream: None, peer_declared: None, + auth: crate::auth::Handle::new(false), }) .expect("start the session"); let driver = tokio::spawn(driver); diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index da755483d9..88274f7b00 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -426,6 +426,8 @@ pub(super) struct Subscriber { // Set once the peer sends a GOAWAY; new SUBSCRIBEs are then rejected with // Error::GoingAway (the peer told us to stop opening streams). going_away: crate::goaway::GoingAway, + // Our grant (MoQ Auth): a subscription it stops covering is cancelled. + auth: crate::auth::Handle, } /// Resolve the subscription a data stream belongs to. @@ -491,9 +493,16 @@ where tasks, version, going_away, + auth: crate::auth::Handle::new(false), } } + /// Bound what we subscribe to by the grant this session's tokens earn (MoQ Auth). + pub fn with_auth(mut self, auth: crate::auth::Handle) -> Self { + self.auth = auth; + self + } + /// Leave `alias` in the state a cancelled subscription leaves behind: bound to a /// subscription, then retired. /// @@ -1563,6 +1572,21 @@ where return; } + // Subscribe only to what our grant covers (MoQ Auth), and cancel once it no longer + // does, leaving the rest of the session alone. + if !self + .auth + .allows(crate::auth::Direction::Subscribe, broadcast_path.as_str()) + { + request.reject(Error::Unauthorized); + return; + } + let mut gate = crate::auth::Gate::new( + self.auth.clone(), + broadcast_path.to_owned(), + crate::auth::Direction::Subscribe, + ); + let subscription = request.subscription(); let join = match subscribe_join( subscription.as_ref().and_then(|s| s.start), @@ -1738,51 +1762,62 @@ where } // One event ends the subscription: the last consumer leaving, or the - // subscribe stream closing. The broadcast ending does not: a retraction + // publisher's PUBLISH_DONE. The broadcast ending does not: a retraction // does not disturb subscriptions already in flight. enum End { Unused, - StreamClosed(Result<(), Error>), + Revoked, + Done(Result<(), Error>), } let mut fetch_done = fetching.is_none(); - let cancelled = loop { - let end = kio::wait(|waiter| { - if !fetch_done - && let Some(fut) = fetching.as_mut() - && waiter.poll_future(fut.as_mut()).is_ready() - { - fetch_done = true; - } - if track.poll_unused(waiter).is_ready() { - return Poll::Ready(End::Unused); - } - let mut cx = std::task::Context::from_waker(waiter.waker()); - stream.reader.poll_closed(&mut cx).map(End::StreamClosed) - }) - .await; - - match end { - End::Unused => match track.abort_unused(Error::Cancel) { - Ok(()) => { - tracing::info!(broadcast = %self.origin.absolute(&broadcast_path), track = %track_name, "subscribe cancelled"); - break true; + let cancelled = { + let mut done = std::pin::pin!(Self::read_publish_done(&mut stream.reader, self.version)); + loop { + let end = kio::wait(|waiter| { + if !fetch_done + && let Some(fut) = fetching.as_mut() + && waiter.poll_future(fut.as_mut()).is_ready() + { + fetch_done = true; } - Err(used) => track = used, - }, - End::StreamClosed(res) => { - match res { + if gate.poll_denied(waiter).is_ready() { + return Poll::Ready(End::Revoked); + } + if track.poll_unused(waiter).is_ready() { + return Poll::Ready(End::Unused); + } + waiter.poll_future(done.as_mut()).map(End::Done) + }) + .await; + + match end { + End::Unused => match track.abort_unused(Error::Cancel) { Ok(()) => { - tracing::info!(broadcast = %self.origin.absolute(&broadcast_path), track = %track_name, "subscribe complete"); - let _ = track.finish(); + tracing::info!(broadcast = %self.origin.absolute(&broadcast_path), track = %track_name, "subscribe cancelled"); + break true; } - Err(err) => { - tracing::debug!(%err, "subscribe stream closed with error"); - let _ = track.abort(err); + Err(used) => track = used, + }, + End::Revoked => { + tracing::info!(broadcast = %self.origin.absolute(&broadcast_path), track = %track_name, "subscription no longer authorized"); + let _ = track.abort(Error::Unauthorized); + break true; + } + End::Done(res) => { + match res { + Ok(()) => { + tracing::info!(broadcast = %self.origin.absolute(&broadcast_path), track = %track_name, "subscribe complete"); + let _ = track.finish(); + } + Err(err) => { + tracing::debug!(%err, "subscribe ended with error"); + let _ = track.abort(err); + } } + // The publisher already ended the request, so there is nothing to cancel. + break false; } - // The publisher already ended the request, so there is nothing to cancel. - break false; } } }; @@ -1799,6 +1834,21 @@ where } } + /// Read the PUBLISH_DONE that ends an Established subscription, as the end it reports. + /// + /// The publisher must send it before its FIN (draft-19 section 3.3.2), so a FIN + /// without one is a failed request, not a clean end. + async fn read_publish_done(reader: &mut Reader, version: Version) -> Result<(), Error> { + match reader.decode_maybe::().await? { + Some(ietf::PublishDone::ID) => {} + Some(_) => return Err(Error::UnexpectedMessage), + None => return Err(Error::ProtocolViolation), + } + let msg: ietf::PublishDone = reader.decode().await?; + tracing::debug!(message = ?msg, "received publish done"); + msg.end(version) + } + /// Tell the publisher to stop serving a subscription we are walking away from. /// /// Every path that abandons an Established subscription goes through here, because diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index 25e194c43a..12039ab3bf 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -495,21 +495,12 @@ impl AuthServe { } /// Aborts the session when our origin announces a broadcast our grant does not -/// cover, instead of leaving it to wait for a subscription that never comes. -/// -/// Waits until the tokens the session presented at setup are answered, then -/// checks each broadcast when it is first announced. A grant that later shrinks -/// withdraws what it no longer covers (see [`AnnounceRun`]) without aborting: -/// the union is processed before new announcements, so a revocation is never -/// mistaken for a new unauthorized publication. +/// cover, instead of leaving it to wait for a subscription that never comes. See +/// [`crate::auth::Enforce`]. #[derive(Default)] struct Enforce { - epoch: u64, - permit: Option, + check: crate::auth::Enforce, announced: Option, - /// Every broadcast admitted so far, still announced. - live: std::collections::HashSet, - setup: bool, } impl Enforce { @@ -518,18 +509,6 @@ impl Enforce { shared: &Shared, waiter: &kio::Waiter, ) -> Poll> { - if !self.setup { - ready!(shared.auth.poll_setup_answered(waiter)); - self.setup = true; - } - while let Poll::Ready(union) = shared.auth.poll_union(&mut self.epoch, waiter) { - self.permit = union.map(|grant| grant.publish); - } - // No grant yet (the peer never answered with one): nothing to check against. - let Some(permit) = &self.permit else { - return Poll::Pending; - }; - let announced = match &mut self.announced { Some(announced) => announced, None => { @@ -537,36 +516,19 @@ impl Enforce { self.announced.insert(origin.announced()) } }; - - loop { - let Some(update) = ready!(announced.poll_next(waiter)) else { - return Poll::Ready(Ok(())); - }; - match update.kind { - announce::Kind::Announced if !self.live.contains(&update.prefix) => { - if !permit.matches(update.prefix.as_str()) { - tracing::error!( - broadcast = %shared.origin.absolute(&update.prefix), - "publishing outside our grant; closing the session" - ); - // `Error` carries no payload, so the path travels in the close - // reason, in the session's own terms. - let err = Error::Unauthorized; - let reason = format!("unauthorized: {}", update.prefix); - shared - .session - .clone() - .close(SessionError::from(&err).to_code(), &reason); - return Poll::Ready(Err(err)); - } - self.live.insert(update.prefix); - } - announce::Kind::Retracted => { - self.live.remove(&update.prefix); - } - _ => {} - } - } + let Some(path) = ready!(self.check.poll(&shared.auth, announced, waiter)) else { + return Poll::Ready(Ok(())); + }; + tracing::error!( + broadcast = %shared.origin.absolute(&path), + "publishing outside our grant; closing the session" + ); + let err = Error::Unauthorized; + shared.session.clone().close( + SessionError::from(&err).to_code(), + &crate::auth::unauthorized_reason(&path), + ); + Poll::Ready(Err(err)) } } @@ -1133,12 +1095,13 @@ impl AnnounceRun { let mut cx = Context::from_waker(waiter.waker()); if matches!(self.phase, AnnouncePhase::Init) { - // Start from whatever grant we already hold, so the initial set never - // advertises something it would withdraw a moment later. - if let Some(auth) = &self.auth - && let Poll::Ready(union) = auth.poll_union(&mut self.epoch, waiter) - { - self.permit = union.map(|grant| grant.publish); + // Start from the grant the setup token earns, so the initial set never + // advertises something it would withdraw, or abort over, a moment later. + if let Some(auth) = &self.auth { + ready!(auth.poll_setup_answered(waiter)); + if let Poll::Ready(union) = auth.poll_union(&mut self.epoch, waiter) { + self.permit = union.map(|grant| grant.publish); + } } self.init(stream, origin, announced)?; self.phase = AnnouncePhase::Running; diff --git a/rs/moq-net/src/server.rs b/rs/moq-net/src/server.rs index 98146774d1..cd404d4dbf 100644 --- a/rs/moq-net/src/server.rs +++ b/rs/moq-net/src/server.rs @@ -396,8 +396,8 @@ impl Server { // Cluster extension and declared a non-zero Hop ID. origin: peer_setup.declared.cluster.hop.filter(|h| *h != crate::Hop::UNKNOWN), assigned_hop: crate::Hop::random(), - // moq-transport carries no AUTH yet. - auth: crate::auth::Handle::new(false), + // The client's SETUP already settled whether MoQ Auth is negotiated. + auth: crate::auth::Handle::new(peer_setup.declared.auth), inner: Some(RequestInner { server: self.clone(), runtime, @@ -532,6 +532,7 @@ where path: None, peer_setup_stream: Some(peer_setup.stream), peer_declared: Some(peer_setup.declared), + auth: auth.clone(), })?; tracing::debug!(?version, "connected"); Ok(Session::new( @@ -649,6 +650,7 @@ where path: None, peer_setup_stream: None, peer_declared: Some(peer_declared), + auth: auth.clone(), })?; (None, crate::driver::Protocol::Ietf(protocol), goaway, auth) } diff --git a/rs/moq-net/src/session.rs b/rs/moq-net/src/session.rs index 0d9a234c11..e6cdd0ea7a 100644 --- a/rs/moq-net/src/session.rs +++ b/rs/moq-net/src/session.rs @@ -201,8 +201,9 @@ impl Session { /// The tokens this side presented and the grant they earned, plus the tokens /// the peer presents. See [`auth`]. /// - /// On moq-lite-06 each side presents its connection's credential right after - /// setup; on every other version the grant stays `None`. + /// On moq-lite-06, and on moq-transport draft-17+ when both sides negotiate the + /// MoQ Auth extension, each side presents its connection's credential right after + /// setup. Older versions, and peers that do not negotiate it, leave the grant `None`. pub fn auth(&self) -> auth::Handle { self.auth.clone() } diff --git a/rs/moq-net/tests/announce_to_serve.rs b/rs/moq-net/tests/announce_to_serve.rs index dd435e6d6e..6eb4b57eb6 100644 --- a/rs/moq-net/tests/announce_to_serve.rs +++ b/rs/moq-net/tests/announce_to_serve.rs @@ -222,12 +222,19 @@ async fn remote_lite_consumer_sees_what_a_local_one_does() { #[tokio::test] async fn remote_ietf_consumer_sees_what_a_local_one_does() { tokio::time::pause(); - let mut seen = lifecycle(Observer::Remote("moq-transport-19")).await; - // Every IETF subscription ends in error today, announced or not: the subscriber - // reads the publisher's PUBLISH_DONE as trailing bytes (/quest/m1/ietf-publish-done.md). - // The track still carries on across the retraction; only its end differs. - assert_eq!(seen.remove(4), "draining: after then error dropped"); - let mut expected = EXPECTED.to_vec(); - expected.remove(4); - assert_eq!(seen, expected); + // Drafts 14 to 16 carry each request over the control stream; 17 and later give it + // its own stream. Both have to end a finished track the way moq-lite does. + for version in [ + "moq-transport-14", + "moq-transport-15", + "moq-transport-16", + "moq-transport-17", + "moq-transport-18", + "moq-transport-19", + "moq-transport-20", + "moq-transport-21", + "moq-transport-22", + ] { + assert_eq!(lifecycle(Observer::Remote(version)).await, EXPECTED, "{version}"); + } } diff --git a/rs/moq-net/tests/auth.rs b/rs/moq-net/tests/auth.rs index d4dd6b3be9..f682dcd2c9 100644 --- a/rs/moq-net/tests/auth.rs +++ b/rs/moq-net/tests/auth.rs @@ -1,5 +1,6 @@ //! In-band AUTH over the in-memory mock transport: both sides learn their grant, -//! tokens union, and a publication outside the grant fails loud. +//! tokens union, and a publication outside the grant fails loud. Every case runs on +//! moq-lite-06 and on moq-transport with the MoQ Auth extension. mod support; @@ -17,6 +18,54 @@ use support::mock::{MockSession, create_mock_session_pair}; const TEST_TIMEOUT: Duration = Duration::from_secs(10); const LITE_06: &str = "moq-lite-06"; +/// The first draft that negotiates MoQ Auth, and the newest. +const MOQT_17: &str = "moq-transport-17"; +const MOQT_22: &str = "moq-transport-22"; + +/// Run each case on every version that exchanges AUTH. +macro_rules! cases { + ($($case:ident),* $(,)?) => { + mod lite_06 { + $(#[tokio::test] async fn $case() { super::$case(super::LITE_06).await })* + } + mod moqt_17 { + $(#[tokio::test] async fn $case() { super::$case(super::MOQT_17).await })* + } + mod moqt_22 { + $(#[tokio::test] async fn $case() { super::$case(super::MOQT_22).await })* + } + }; +} + +cases!( + both_sides_learn_their_grant_from_scoped_origins, + a_publish_only_session_grants_no_subscribe, + an_out_of_scope_announce_aborts_with_the_path, + a_broadcast_published_before_the_grant_is_checked, + an_unanswered_token_does_not_suspend_the_check, + tokens_union_and_withdrawing_one_shrinks_it, + an_update_replaces_one_tokens_grant, + a_revoked_grant_withdraws_and_can_be_restored, + a_refused_token_reports_the_code, + a_refused_setup_token_grants_nothing, + dropping_the_requests_refuses_queued_tokens, + a_closed_session_holds_no_grant, + a_reset_auth_stream_reports_unsupported, + a_revoked_grant_cancels_its_subscriptions, + an_unrepresentable_grant_is_unsupported, + an_unrepresentable_update_revokes_only_its_token, + nothing_outside_the_grant_reaches_the_peer, +); + +#[tokio::test] +async fn lite_05_has_no_grant() { + older_versions_have_no_grant("moq-lite-05").await +} + +#[tokio::test] +async fn moqt_16_has_no_grant() { + older_versions_have_no_grant("moq-transport-16").await +} /// Build an origin producer, spawning its driver on the ambient runtime. fn produce_origin(hop: u64) -> origin::Producer { @@ -168,11 +217,11 @@ fn within(f: F) -> tokio::time::Timeout { /// Each side's default grant is what the other side's origin handles allow: /// its subscribe half bounds what we may publish, its publish half what we may /// subscribe to. -#[tokio::test] -async fn both_sides_learn_their_grant_from_scoped_origins() { +async fn both_sides_learn_their_grant_from_scoped_origins(version: &'static str) { within(async { let relay = produce_origin(1); let pair = connect(Options { + version: Some(version), client_publish: Some(produce_origin(2)), client_subscribe: Some(produce_origin(3)), server_publish: Some(relay.scope("", &patterns(&["room"])).unwrap()), @@ -190,10 +239,10 @@ async fn both_sides_learn_their_grant_from_scoped_origins() { } /// A missing half grants nothing: the empty list, distinct from the empty prefix. -#[tokio::test] -async fn a_publish_only_session_grants_no_subscribe() { +async fn a_publish_only_session_grants_no_subscribe(version: &'static str) { within(async { let pair = connect(Options { + version: Some(version), client_publish: Some(produce_origin(2)), server_subscribe: Some(produce_origin(1)), ..Default::default() @@ -211,12 +260,12 @@ async fn a_publish_only_session_grants_no_subscribe() { /// A broadcast outside the grant aborts the session and names the path, where it /// used to wait forever for a solicitation that never comes. -#[tokio::test] -async fn an_out_of_scope_announce_aborts_with_the_path() { +async fn an_out_of_scope_announce_aborts_with_the_path(version: &'static str) { within(async { let publisher = produce_origin(2); let relay = produce_origin(1); let pair = connect(Options { + version: Some(version), client_publish: Some(publisher.clone()), server_subscribe: Some(relay.scope("", &patterns(&["baz"])).unwrap()), ..Default::default() @@ -245,14 +294,14 @@ async fn an_out_of_scope_announce_aborts_with_the_path() { } /// A broadcast published before the grant arrives is checked at admission too. -#[tokio::test] -async fn a_broadcast_published_before_the_grant_is_checked() { +async fn a_broadcast_published_before_the_grant_is_checked(version: &'static str) { within(async { let publisher = produce_origin(2); let early = publisher.create_broadcast("foo/bar").unwrap(); early.announce(Default::default()).unwrap(); let pair = connect(Options { + version: Some(version), client_publish: Some(publisher.clone()), server_subscribe: Some(produce_origin(1).scope("", &patterns(&["baz"])).unwrap()), ..Default::default() @@ -274,11 +323,11 @@ async fn a_broadcast_published_before_the_grant_is_checked() { /// Enforcement waits only for the tokens the session presented itself: a peer that /// never answers an app-added token cannot suspend it. -#[tokio::test] -async fn an_unanswered_token_does_not_suspend_the_check() { +async fn an_unanswered_token_does_not_suspend_the_check(version: &'static str) { within(async { let publisher = produce_origin(2); let mut pair = connect(Options { + version: Some(version), client_publish: Some(publisher.clone()), server_subscribe: Some(produce_origin(1)), server_requests: true, @@ -308,12 +357,12 @@ async fn an_unanswered_token_does_not_suspend_the_check() { /// Two tokens union; closing one shrinks the union and withdraws only what it alone /// covered, without disconnecting. -#[tokio::test] -async fn tokens_union_and_withdrawing_one_shrinks_it() { +async fn tokens_union_and_withdrawing_one_shrinks_it(version: &'static str) { within(async { let publisher = produce_origin(2); let relay = produce_origin(1); let mut pair = connect(Options { + version: Some(version), client_publish: Some(publisher.clone()), server_subscribe: Some(relay.clone()), server_requests: true, @@ -353,10 +402,10 @@ async fn tokens_union_and_withdrawing_one_shrinks_it() { } /// An update replaces one token's grant and leaves the other alone. -#[tokio::test] -async fn an_update_replaces_one_tokens_grant() { +async fn an_update_replaces_one_tokens_grant(version: &'static str) { within(async { let mut pair = connect(Options { + version: Some(version), client_publish: Some(produce_origin(2)), server_subscribe: Some(produce_origin(1)), server_requests: true, @@ -382,12 +431,12 @@ async fn an_update_replaces_one_tokens_grant() { /// A revocation withdraws what the grant covered, even though the broadcast stays in /// the shared origin, and an empty union can be authorized again. -#[tokio::test] -async fn a_revoked_grant_withdraws_and_can_be_restored() { +async fn a_revoked_grant_withdraws_and_can_be_restored(version: &'static str) { within(async { let publisher = produce_origin(2); let relay = produce_origin(1); let mut pair = connect(Options { + version: Some(version), client_publish: Some(publisher.clone()), server_subscribe: Some(relay.clone()), server_requests: true, @@ -420,10 +469,10 @@ async fn a_revoked_grant_withdraws_and_can_be_restored() { } /// A refused token surfaces the acceptor's code. -#[tokio::test] -async fn a_refused_token_reports_the_code() { +async fn a_refused_token_reports_the_code(version: &'static str) { within(async { let mut pair = connect(Options { + version: Some(version), client_publish: Some(produce_origin(2)), server_requests: true, ..Default::default() @@ -450,10 +499,10 @@ async fn a_refused_token_reports_the_code() { /// Refusing the setup token grants nothing: the union becomes empty rather than /// staying unknown, which the gates would read as unrestricted. -#[tokio::test] -async fn a_refused_setup_token_grants_nothing() { +async fn a_refused_setup_token_grants_nothing(version: &'static str) { within(async { let mut pair = connect(Options { + version: Some(version), client_publish: Some(produce_origin(2)), server_requests: true, ..Default::default() @@ -473,10 +522,10 @@ async fn a_refused_setup_token_grants_nothing() { } /// Dropping the requests refuses tokens already queued, not only later ones. -#[tokio::test] -async fn dropping_the_requests_refuses_queued_tokens() { +async fn dropping_the_requests_refuses_queued_tokens(version: &'static str) { within(async { let mut pair = connect(Options { + version: Some(version), client_publish: Some(produce_origin(2)), server_requests: true, ..Default::default() @@ -500,10 +549,10 @@ async fn dropping_the_requests_refuses_queued_tokens() { } /// A closed session holds no grant: every token ended with it. -#[tokio::test] -async fn a_closed_session_holds_no_grant() { +async fn a_closed_session_holds_no_grant(version: &'static str) { within(async { let pair = connect(Options { + version: Some(version), client_publish: Some(produce_origin(2)), server_subscribe: Some(produce_origin(1)), ..Default::default() @@ -519,12 +568,13 @@ async fn a_closed_session_holds_no_grant() { .expect("timed out"); } -/// A peer that takes no tokens in band resets the stream, which reads as -/// unsupported rather than a refusal: the same as a peer that predates AUTH. -#[tokio::test] -async fn a_reset_auth_stream_reports_unsupported() { +/// A peer that takes no tokens in band says so (lite resets the stream, moq-transport +/// answers NOT_SUPPORTED), which reads as unsupported rather than a refusal: the same as +/// a peer that predates AUTH. +async fn a_reset_auth_stream_reports_unsupported(version: &'static str) { within(async { let pair = connect(Options { + version: Some(version), client_publish: Some(produce_origin(2)), server_subscribe: Some(produce_origin(1)), ..Default::default() @@ -540,14 +590,13 @@ async fn a_reset_auth_stream_reports_unsupported() { .expect("timed out"); } -/// Older versions never open the stream: no grant, and no way to add a token. -#[tokio::test] -async fn older_versions_have_no_grant() { +/// Versions without AUTH never open the stream: no grant, and no way to add a token. +async fn older_versions_have_no_grant(version: &'static str) { within(async { let pair = connect(Options { + version: Some(version), client_publish: Some(produce_origin(2)), server_subscribe: Some(produce_origin(1)), - version: Some("moq-lite-05"), ..Default::default() }) .await; @@ -560,8 +609,7 @@ async fn older_versions_have_no_grant() { /// Losing a grant cancels the subscriptions it covered, in both directions, and /// leaves the session up. -#[tokio::test] -async fn a_revoked_grant_cancels_its_subscriptions() { +async fn a_revoked_grant_cancels_its_subscriptions(version: &'static str) { within(async { let ts = |ms| moq_net::Timestamp::from_millis(ms).unwrap(); let prefs = || moq_net::track::Subscription::default().with_max_age(Duration::from_secs(10)); @@ -579,12 +627,12 @@ async fn a_revoked_grant_cancels_its_subscriptions() { let received = produce_origin(3); let mut pair = connect(Options { + version: Some(version), client_publish: Some(client_origin.clone()), client_subscribe: Some(received.clone()), server_publish: Some(server_origin.clone()), server_subscribe: Some(server_origin.clone()), server_requests: true, - ..Default::default() }) .await; let mut issued = serve(pair.requests.take().unwrap(), |token| { @@ -620,3 +668,129 @@ async fn a_revoked_grant_cancels_its_subscriptions() { .await .expect("timed out"); } + +/// A grant the wire cannot carry as prefixes is never widened: the token is refused as +/// unsupported, promptly, and the union stays unknown rather than empty. +async fn an_unrepresentable_grant_is_unsupported(version: &'static str) { + within(async { + let mut pair = connect(Options { + version: Some(version), + client_publish: Some(produce_origin(2)), + server_subscribe: Some(produce_origin(1)), + server_requests: true, + ..Default::default() + }) + .await; + let mut issued = serve(pair.requests.take().unwrap(), |token| match token { + b"" => Some(grant(&["a"], &[])), + b"exact" => Some(Grant { + publish: Patterns::from(Pattern::try_from("room/alice").unwrap()), + subscribe: Patterns::new(), + expires: None, + }), + b"mixed" => Some(Grant { + publish: ["room/**", "lobby"] + .into_iter() + .map(|p| Pattern::try_from(p).unwrap()) + .collect(), + subscribe: Patterns::new(), + expires: None, + }), + b"wildcard" => Some(Grant { + publish: Patterns::from(Pattern::try_from("room/*/cam").unwrap()), + subscribe: Patterns::new(), + expires: None, + }), + _ => None, + }); + let (_, _setup) = issued.recv().await.unwrap(); + assert_eq!(granted(&pair.client).await, grant(&["a"], &[])); + + for token in ["exact", "mixed", "wildcard"] { + let err = pair.client.auth().add(token).await.err().expect("not representable"); + assert!(matches!(err, Error::Unsupported), "{token}: {err:?}"); + } + // The other token is untouched, and so is the session. + assert_eq!(pair.client.auth().grant().peek(), Some(grant(&["a"], &[]))); + assert_eq!(pair.client_transport.close_reason(), None); + }) + .await + .expect("timed out"); +} + +/// An update the wire cannot carry revokes that token's earlier grant, and only that +/// token's: the rest of the union and the session stay. +async fn an_unrepresentable_update_revokes_only_its_token(version: &'static str) { + within(async { + let mut pair = connect(Options { + version: Some(version), + client_publish: Some(produce_origin(2)), + server_subscribe: Some(produce_origin(1)), + server_requests: true, + ..Default::default() + }) + .await; + let mut issued = serve(pair.requests.take().unwrap(), |token| match token { + b"" => Some(grant(&["a"], &[])), + b"t1" => Some(grant(&["b"], &[])), + _ => None, + }); + let (_, _setup) = issued.recv().await.unwrap(); + let t1 = pair.client.auth().add("t1").await.unwrap(); + let (_, t1_issued) = issued.recv().await.unwrap(); + assert_eq!(granted(&pair.client).await, grant(&["a", "b"], &[])); + + t1_issued.update(Grant { + publish: Patterns::from(Pattern::try_from("b/exact").unwrap()), + subscribe: Patterns::new(), + expires: None, + }); + // Lite resets the stream and moq-transport answers NOT_SUPPORTED; either ends it. + t1.closed().await; + assert_eq!(t1.grant().peek(), None); + wait_for(pair.client.auth().grant(), |g| g == &Some(grant(&["a"], &[]))).await; + assert_eq!(pair.client_transport.close_reason(), None); + }) + .await + .expect("timed out"); +} + +/// The session closes before the peer ever hears of a broadcast outside the grant, +/// even one published before the grant arrived: nothing is advertised until the +/// setup token is answered. +async fn nothing_outside_the_grant_reaches_the_peer(version: &'static str) { + within(async { + let publisher = produce_origin(2); + let early = publisher.create_broadcast("foo/bar").unwrap(); + early.announce(Default::default()).unwrap(); + + // The relay accepts anything; only the grant it tells the client is narrow. + let relay = produce_origin(1); + let mut pair = connect(Options { + version: Some(version), + client_publish: Some(publisher.clone()), + server_subscribe: Some(relay.clone()), + server_requests: true, + ..Default::default() + }) + .await; + + // Hold the answer, so the peer's discovery request is in long before the grant. + let mut requests = pair.requests.take().unwrap(); + let setup = requests.next().await.expect("setup token"); + let leaked = tokio::time::timeout( + Duration::from_millis(100), + wait_announced(&relay.consume(), "foo/bar", true), + ) + .await; + assert!(leaked.is_err(), "advertised before the grant was known"); + + let _issued = setup.accept(grant(&["baz"], &[])); + assert!(matches!( + pair.server.closed().await, + Error::Session(SessionError::Unauthorized) + )); + }) + .await + .expect("timed out"); +} diff --git a/rs/moq-tokio/src/client.rs b/rs/moq-tokio/src/client.rs index 04358722bb..e9133a8e89 100644 --- a/rs/moq-tokio/src/client.rs +++ b/rs/moq-tokio/src/client.rs @@ -1158,6 +1158,79 @@ mod tests { assert_eq!(value, super::TransportRace::Quic(3)); } + /// A WebTransport-only endpoint answers the WebSocket fallback with 403 while the + /// QUIC dial is still in flight. One transport being refused is not the connect's + /// verdict: QUIC finishes the race and the session comes up. + /// + /// Inline rather than in `tests/` so each arm dials its own ephemeral port: the + /// public connect sends the fallback to the QUIC port, which nothing reserves over + /// TCP as well. + #[cfg(all(feature = "websocket", feature = "noq"))] + #[tokio::test] + async fn websocket_forbidden_does_not_end_a_quic_connect() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let listener = tokio::net::TcpListener::bind("[::]:0").await.unwrap(); + let ws_port = listener.local_addr().unwrap().port(); + let mut forbid = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await?; + let mut buf = [0; 1024]; + let _ = stream.read(&mut buf).await?; + stream + .write_all(b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + .await?; + Ok::<_, std::io::Error>(()) + }); + + let mut listen = crate::listen::Config { + bind: Some("[::]:0".parse().unwrap()), + ..Default::default() + }; + listen.tls.generate = vec!["localhost".into()]; + let mut server = listen.init(Default::default()).unwrap().listen().await.unwrap(); + let quic_port = server.local_addr().unwrap().port(); + let origin = crate::origin::spawn(); + let accepted = tokio::spawn(async move { + let request = server.accept().await.expect("no incoming connection"); + request + .with_publisher(&origin) + .ok() + .await + .map(|session| (server, session)) + }); + + let mut config = crate::connect::Config::default(); + config.tls.insecure = Some(true); + // No head start, so the fallback dials while QUIC waits on the 403. + config.websocket.delay = std::time::Duration::ZERO; + let client = config.init(Default::default()).unwrap(); + + // The same race `connect_inner` runs, except the fallback dials its own port, + // as plain ws:// so the listener above can answer without TLS. + let noq = client.noq.as_ref().unwrap(); + let quic_addr: crate::connect::Addr = Url::parse(&format!("https://localhost:{quic_port}")).unwrap().into(); + let ws_addr: crate::connect::Addr = Url::parse(&format!("http://localhost:{ws_port}")).unwrap().into(); + // Hold QUIC until the fallback has been refused, so the 403 is always exercised. + let quic = async { + (&mut forbid).await.unwrap().expect("fallback listener failed"); + noq.connect(&client.tls, quic_addr, &client.versions) + .await + .map(crate::transport::Session::new) + .map_err(Error::from) + }; + + let session = tokio::time::timeout( + std::time::Duration::from_secs(10), + client.race_moq_connect(&client.moq, ws_addr, quic), + ) + .await + .expect("client connect timed out") + .expect("a fallback refused on auth must not end a connect whose QUIC arm succeeds"); + + drop(session); + accepted.await.unwrap().expect("server handshake failed"); + } + #[cfg(all(feature = "websocket", feature = "noq"))] #[tokio::test] async fn race_transport_connect_reports_auth_when_both_refuse() { diff --git a/rs/moq-tokio/src/server.rs b/rs/moq-tokio/src/server.rs index 3eff0a194f..d71a05f658 100644 --- a/rs/moq-tokio/src/server.rs +++ b/rs/moq-tokio/src/server.rs @@ -737,6 +737,12 @@ impl Listener { self.server.websocket_local_addr() } + /// The address the plain TCP (qmux) listener bound to, if one was configured. + #[cfg(feature = "tcp")] + pub fn tcp_local_addr(&self) -> Option { + self.server.streams.tcp_local_addr + } + /// A live handle to the certificates this server is serving. /// /// See [`Server::certificates`], which is also readable before listening. @@ -833,6 +839,9 @@ struct StreamListeners { versions: moq_net::Versions, #[cfg(all(feature = "uds", unix))] unix_allow: Option, + /// The address the TCP listener bound, once [`Self::start`] has run. + #[cfg(feature = "tcp")] + tcp_local_addr: Option, rx: Option>, tasks: Vec>, } @@ -854,6 +863,8 @@ impl StreamListeners { versions, #[cfg(all(feature = "uds", unix))] unix_allow, + #[cfg(feature = "tcp")] + tcp_local_addr: None, rx: None, tasks: Vec::new(), } @@ -886,7 +897,9 @@ impl StreamListeners { .await? .with_protocols(alpns) .with_accept_health(health); - tracing::info!(%addr, "listening (tcp)"); + let local = listener.local_addr()?; + tracing::info!(addr = %local, "listening (tcp)"); + self.tcp_local_addr = Some(local); bound.push(BoundListener::Tcp(listener)); } #[cfg(all(feature = "uds", unix))] diff --git a/rs/moq-tokio/tests/broadcast.rs b/rs/moq-tokio/tests/broadcast.rs index 3ae72b0740..5b7f244665 100644 --- a/rs/moq-tokio/tests/broadcast.rs +++ b/rs/moq-tokio/tests/broadcast.rs @@ -2480,69 +2480,6 @@ async fn reconnect_stops_on_websocket_unauthorized() { .expect("server task failed"); } -/// A WebTransport-only endpoint answers the WebSocket fallback with 403 while the -/// QUIC dial is still in flight. One transport being refused is not the connect's -/// verdict: QUIC finishes the race and the session comes up. -#[tracing_test::traced_test] -#[tokio::test] -async fn websocket_forbidden_does_not_end_a_quic_connect() { - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - - // The fallback dials the same port over TCP. Nothing reserves a port for both - // UDP and TCP at once, and the ephemeral UDP port may already be taken over TCP, - // so pick again until both bind. - let (mut server, addr, listener) = 'bind: { - for _ in 0..20 { - let (server, addr) = test_server().await; - match tokio::net::TcpListener::bind(("::", addr.port())).await { - Ok(listener) => break 'bind (server, addr, listener), - Err(err) if err.kind() == std::io::ErrorKind::AddrInUse => continue, - Err(err) => panic!("failed to bind TCP listener: {err}"), - } - } - panic!("no port was free over both UDP and TCP"); - }; - let forbid = tokio::spawn(async move { - let (mut stream, _) = listener.accept().await?; - let mut buf = [0; 1024]; - let _ = stream.read(&mut buf).await?; - stream - .write_all(b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") - .await?; - Ok::<_, anyhow::Error>(()) - }); - - let pub_origin = moq_tokio::origin::spawn(); - let server_handle = tokio::spawn(async move { - let request = server.accept().await.expect("no incoming connection"); - let session = request.with_publisher(&pub_origin).ok().await?; - session.closed().await; - Ok::<_, anyhow::Error>(()) - }); - - let mut client_config = moq_tokio::connect::Config::default(); - client_config.tls.insecure = Some(true); - // No head start, so the 403 lands before the QUIC handshake completes. - client_config.websocket.delay = Duration::ZERO; - let client = client_config.init(Default::default()).expect("failed to init client"); - // http:// dials QUIC as https:// and the fallback as plain ws://, which the listener - // above can answer without TLS. - let url: url::Url = format!("http://localhost:{}", addr.port()).parse().unwrap(); - - let (_client, connection) = tokio::time::timeout(TIMEOUT, connect_once(client, url)) - .await - .expect("client connect timed out") - .expect("a fallback refused on auth must not end a connect whose QUIC arm succeeds"); - - drop(connection); - server_handle - .await - .expect("server task panicked") - .expect("server task failed"); - // QUIC may win before the fallback ever dials, leaving the listener waiting. - forbid.abort(); -} - /// A GOAWAY ends a one-shot connection instead of being ignored. /// /// The peer here sends a GOAWAY naming no deadline and then waits, which is the diff --git a/rs/moq-tokio/tests/reconnect.rs b/rs/moq-tokio/tests/reconnect.rs index 4eef51b3eb..a3532e354c 100644 --- a/rs/moq-tokio/tests/reconnect.rs +++ b/rs/moq-tokio/tests/reconnect.rs @@ -6,7 +6,6 @@ #![cfg(feature = "tcp")] -use std::net::TcpListener; use std::time::Duration; use moq_tokio::moq_net; @@ -103,45 +102,35 @@ async fn monitor_is_cloneable_without_keeping_the_connection_alive() { assert!(monitor.snapshot().is_none()); } -/// A stream-only moq server on a free loopback TCP port. +/// A stream-only moq server on an ephemeral loopback TCP port. /// /// Returns the port, a receiver yielding every accepted session (so a test can -/// drain one), and the listener task. The free-port probe can lose a race with -/// another test between the probe closing and the real bind, so retry rather -/// than panicking in `listen`. +/// drain one), and the listener task. async fn spawn_server() -> ( u16, tokio::sync::mpsc::UnboundedReceiver, tokio::task::JoinHandle<()>, ) { - for _ in 0..20 { - let probe = TcpListener::bind("127.0.0.1:0").expect("bind probe"); - let port = probe.local_addr().expect("local addr").port(); - drop(probe); - - let mut config = moq_tokio::listen::Config::default(); - config.tcp.bind = Some(format!("127.0.0.1:{port}").parse().expect("parse addr")); - let server = config.init(Default::default()).expect("init server"); - let Ok(mut server) = server.listen().await else { - continue; - }; - - let (accepted, sessions) = tokio::sync::mpsc::unbounded_channel(); - let handle = tokio::spawn(async move { - while let Some(request) = server.accept().await { - let origin = moq_tokio::origin::spawn(); - match request.with_publisher(&origin).ok().await { - Ok(session) => { - let _ = accepted.send(session); - } - Err(err) => tracing::warn!(%err, "accept failed"), + let mut config = moq_tokio::listen::Config::default(); + config.tcp.bind = Some("127.0.0.1:0".parse().expect("parse addr")); + let server = config.init(Default::default()).expect("init server"); + let mut server = server.listen().await.expect("bind tcp listener"); + let port = server.tcp_local_addr().expect("tcp listener bound").port(); + + let (accepted, sessions) = tokio::sync::mpsc::unbounded_channel(); + let handle = tokio::spawn(async move { + while let Some(request) = server.accept().await { + let origin = moq_tokio::origin::spawn(); + match request.with_publisher(&origin).ok().await { + Ok(session) => { + let _ = accepted.send(session); } + Err(err) => tracing::warn!(%err, "accept failed"), } - }); + } + }); - return (port, sessions, handle); - } - panic!("could not bind a free TCP port after 20 attempts"); + (port, sessions, handle) } /// A client that redials fast, so a refused redirect lands back on the original diff --git a/rs/moq-tokio/tests/worker.rs b/rs/moq-tokio/tests/worker.rs index f35ce6641d..70b727d005 100644 --- a/rs/moq-tokio/tests/worker.rs +++ b/rs/moq-tokio/tests/worker.rs @@ -5,7 +5,7 @@ //! the group. #![cfg(all(target_os = "linux", feature = "noq"))] -use std::net::{SocketAddr, UdpSocket}; +use std::net::UdpSocket; use moq_tokio::worker::{self, Workers}; @@ -13,8 +13,8 @@ const WORKERS: u16 = 4; /// A UDP port nothing is bound to. /// -/// Named rather than ephemeral because these tests rebind the port, or probe it -/// while the group holds it, which needs a port known before the group starts. +/// Only for the port-lock tests: an ephemeral group takes no lock, so the first +/// group has to name its port. Everything else binds `:0` and reads it back. fn free_udp_port() -> u16 { let probe = UdpSocket::bind("127.0.0.1:0").expect("bind probe"); let port = probe.local_addr().expect("local addr").port(); @@ -70,10 +70,9 @@ async fn dropping_the_workers_releases_the_port() { let dir = tempfile::tempdir().expect("tempdir"); let (cert, key) = certificate(dir.path()); - let port = free_udp_port(); - let workers = - bind_workers(listen_config(&cert, &key, port), Default::default(), config(WORKERS)).expect("bind workers"); + bind_workers(listen_config(&cert, &key, 0), Default::default(), config(WORKERS)).expect("bind workers"); + let addr = workers.local_addr(); assert_eq!(workers.len(), usize::from(WORKERS)); // Serving first is the case that used to strand the threads. The accept @@ -89,7 +88,6 @@ async fn dropping_the_workers_releases_the_port() { // A plain bind refuses a port any socket still holds, reuseport or not, so this // succeeds only if every worker's socket is really gone. - let addr: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap(); moq_tokio::bind::udp(moq_tokio::bind::Udp::new(addr)).expect("workers left the port bound"); } @@ -226,13 +224,11 @@ async fn dropping_unserved_workers_releases_the_port() { let dir = tempfile::tempdir().expect("tempdir"); let (cert, key) = certificate(dir.path()); - let port = free_udp_port(); - let workers = - bind_workers(listen_config(&cert, &key, port), Default::default(), config(WORKERS)).expect("bind workers"); + bind_workers(listen_config(&cert, &key, 0), Default::default(), config(WORKERS)).expect("bind workers"); + let addr = workers.local_addr(); drop(workers); - let addr: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap(); moq_tokio::bind::udp(moq_tokio::bind::Udp::new(addr)).expect("workers left the port bound"); } @@ -397,7 +393,7 @@ async fn generated_certificates_are_refused() { let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); let mut listen = moq_tokio::listen::Config::default(); - listen.bind = Some(format!("127.0.0.1:{}", free_udp_port()).parse().unwrap()); + listen.bind = Some("127.0.0.1:0".parse().unwrap()); listen.tls.generate = vec!["localhost".to_string()]; let err = @@ -446,9 +442,8 @@ async fn dropping_a_server_keeps_its_socket() { let dir = tempfile::tempdir().expect("tempdir"); let (cert, key) = certificate(dir.path()); - let port = free_udp_port(); - - let workers = bind_workers(listen_config(&cert, &key, port), Default::default(), config(2)).expect("bind workers"); + let workers = bind_workers(listen_config(&cert, &key, 0), Default::default(), config(2)).expect("bind workers"); + let port = workers.local_addr().port(); let mut group = workers.split(); let sockets = udp_sockets_on(port); assert!(sockets >= 2, "every member holds at least one socket"); @@ -489,9 +484,8 @@ async fn completing_a_member_stops_its_siblings() { let dir = tempfile::tempdir().expect("tempdir"); let (cert, key) = certificate(dir.path()); - let port = free_udp_port(); - - let workers = bind_workers(listen_config(&cert, &key, port), Default::default(), config(2)).expect("bind workers"); + let workers = bind_workers(listen_config(&cert, &key, 0), Default::default(), config(2)).expect("bind workers"); + let addr = workers.local_addr(); let mut group = workers.split(); let mut members = group.members(); assert_eq!(members.len(), 2); @@ -529,7 +523,6 @@ async fn completing_a_member_stops_its_siblings() { .expect("a stopped sibling must not hang"); group.shutdown().await; - let addr: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap(); moq_tokio::bind::udp(moq_tokio::bind::Udp::new(addr)).expect("the group must release its port"); } @@ -540,9 +533,8 @@ async fn cancelling_a_member_stops_its_siblings() { let dir = tempfile::tempdir().expect("tempdir"); let (cert, key) = certificate(dir.path()); - let port = free_udp_port(); - - let workers = bind_workers(listen_config(&cert, &key, port), Default::default(), config(2)).expect("bind workers"); + let workers = bind_workers(listen_config(&cert, &key, 0), Default::default(), config(2)).expect("bind workers"); + let addr = workers.local_addr(); let mut group = workers.split(); let mut members = group.members(); @@ -574,7 +566,6 @@ async fn cancelling_a_member_stops_its_siblings() { .expect("a cancelled sibling must not hang"); group.shutdown().await; - let addr: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap(); moq_tokio::bind::udp(moq_tokio::bind::Udp::new(addr)).expect("the group must release its port"); } @@ -586,9 +577,8 @@ async fn a_panicking_member_stops_its_siblings() { let dir = tempfile::tempdir().expect("tempdir"); let (cert, key) = certificate(dir.path()); - let port = free_udp_port(); - - let workers = bind_workers(listen_config(&cert, &key, port), Default::default(), config(2)).expect("bind workers"); + let workers = bind_workers(listen_config(&cert, &key, 0), Default::default(), config(2)).expect("bind workers"); + let addr = workers.local_addr(); let mut group = workers.split(); let mut members = group.members(); @@ -620,7 +610,6 @@ async fn a_panicking_member_stops_its_siblings() { .expect("a panicking sibling must not hang"); group.shutdown().await; - let addr: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap(); moq_tokio::bind::udp(moq_tokio::bind::Udp::new(addr)).expect("the group must release its port"); } @@ -631,9 +620,8 @@ async fn shutdown_with_work_in_flight_joins() { let dir = tempfile::tempdir().expect("tempdir"); let (cert, key) = certificate(dir.path()); - let port = free_udp_port(); - - let workers = bind_workers(listen_config(&cert, &key, port), Default::default(), config(2)).expect("bind workers"); + let workers = bind_workers(listen_config(&cert, &key, 0), Default::default(), config(2)).expect("bind workers"); + let addr = workers.local_addr(); let mut group = workers.split(); let mut tasks = Vec::new(); for member in group.members() { @@ -650,7 +638,6 @@ async fn shutdown_with_work_in_flight_joins() { .expect("shutdown must stop every member"); } - let addr: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap(); moq_tokio::bind::udp(moq_tokio::bind::Udp::new(addr)).expect("shutdown must release the port"); } @@ -662,9 +649,8 @@ async fn dropping_the_group_with_work_in_flight_stops() { let dir = tempfile::tempdir().expect("tempdir"); let (cert, key) = certificate(dir.path()); - let port = free_udp_port(); - - let workers = bind_workers(listen_config(&cert, &key, port), Default::default(), config(2)).expect("bind workers"); + let workers = bind_workers(listen_config(&cert, &key, 0), Default::default(), config(2)).expect("bind workers"); + let addr = workers.local_addr(); let mut group = workers.split(); let mut tasks = Vec::new(); { @@ -684,6 +670,5 @@ async fn dropping_the_group_with_work_in_flight_stops() { .expect("dropping the group must stop every member"); } - let addr: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap(); moq_tokio::bind::udp(moq_tokio::bind::Udp::new(addr)).expect("dropping the group must release the port"); } diff --git a/swift/Sources/Moq/Broadcast.swift b/swift/Sources/Moq/Broadcast.swift index 7fa4873d25..cf52ddba1b 100644 --- a/swift/Sources/Moq/Broadcast.swift +++ b/swift/Sources/Moq/Broadcast.swift @@ -172,25 +172,33 @@ public final class BroadcastProducer: Sendable { /// are interpreted (e.g. `"opus"`, `"avc3"`). `video` seeds catalog fields /// that the stream cannot reveal before its first keyframe. /// Publish one audio codec as a new track. `initData` is required: audio resolves its whole - /// rendition from those bytes. + /// rendition from those bytes. `track` names the track; otherwise a unique name is derived from + /// the format. public func publishAudio( format: AudioFormat, initData: Data, - label: String? = nil + label: String? = nil, + track: String? = nil ) throws -> MediaProducer { - MediaProducer(try ffi.publishAudio(init: MoqAudioInit(format: format, data: initData, label: label))) + MediaProducer( + try ffi.publishAudio(init: MoqAudioInit(format: format, data: initData, label: label, track: track)) + ) } /// Publish one video codec as a new track. `initData` may be empty for a format that resolves - /// in band; `hint` seeds catalog fields the stream can't reveal. + /// in band; `hint` seeds catalog fields the stream can't reveal. `track` names the track; + /// otherwise a unique name is derived from the format. public func publishVideo( format: VideoFormat, initData: Data = Data(), label: String? = nil, - hint: VideoHint? = nil + hint: VideoHint? = nil, + track: String? = nil ) throws -> MediaProducer { MediaProducer( - try ffi.publishVideo(init: MoqVideoInit(format: format, data: initData, label: label, hint: hint)) + try ffi.publishVideo( + init: MoqVideoInit(format: format, data: initData, label: label, hint: hint, track: track) + ) ) } @@ -240,10 +248,13 @@ public final class BroadcastProducer: Sendable { public func publishVideoStream( format: VideoFormat, label: String? = nil, - hint: VideoHint? = nil + hint: VideoHint? = nil, + track: String? = nil ) throws -> MediaStreamProducer { MediaStreamProducer( - try ffi.publishVideoStream(init: MoqVideoInit(format: format, data: Data(), label: label, hint: hint)) + try ffi.publishVideoStream( + init: MoqVideoInit(format: format, data: Data(), label: label, hint: hint, track: track) + ) ) }