diff --git a/quest/m1/README.md b/quest/m1/README.md index 64c978f6c8..2cddfac6c1 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -33,9 +33,9 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Ingest source](/quest/m1/net-ingest-source.md) - an announce says whether the broadcast entered on this relay or a peer - [Publisher clocks](/quest/m1/publisher-clock.md) - wire the shared clock through native and browser publisher restarts - [Broadcast route](/quest/m1/js-broadcast-route.md) - JS Announce.Broadcast goes live on any claim matching its path, like Rust routed() -- [io_uring handshake flush](/quest/m1/uring-handshake-flush.md) - a dial resolves only once its last handshake flight is on the wire, never stranding the peer - [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 - [IETF announce count](/quest/m1/ietf-announce-count.md) - an opt-in moq-transport extension carries the replay count, so IETF announce consumers go live without a timer - [Jitter clock](/quest/m1/jitter-flush-clock.md) - moq-mux: catalog jitter measures how far behind the media clock an encoder flushes, fed by encoders only, and never decreases @@ -53,6 +53,8 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [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 - [Stats retier](/quest/m1/stats-retier.md) - a re-checked tier retags a live session's stats in place instead of waiting for its next connection +- [Listener close](/quest/m1/listener-close.md) - closing a listener releases its UDP socket before returning, so a restart can rebind the port +- [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 diff --git a/quest/m1/announce-live-bindings.md b/quest/m1/announce-live-bindings.md new file mode 100644 index 0000000000..fe2464bbab --- /dev/null +++ b/quest/m1/announce-live-bindings.md @@ -0,0 +1,33 @@ +# [M] Bindings see when an announce consumer has caught up + +## Goal + +moq-ffi, libmoq, and every wrapper (`py`, `swift`, `kt`, `go`, `dart`) yield +the same flat announce event Rust does: `Announced`, `Updated`, or `Retracted` +carrying the announce, or `Live` once every route live at subscribe time has +been delivered. A binding app can list what is live and stop, with no timer. + +## Plan + +Mirror the Rust `announce::Event` from +[Caught up](/quest/m1/cli-inspect/caught-up.md) one to one: + +- moq-ffi: `MoqAnnounceConsumer::next` returns `MoqAnnounceEvent`, a uniffi + enum with the four variants, replacing `MoqAnnounceUpdate::active()`. Stop + filtering `Live` in `rs/moq-ffi/src/origin.rs`. +- libmoq: the `on_announce` handle's `moq_announce_update.kind` gains a LIVE + value with no route fields, replacing the active flag. Stop skipping + `Event::Live` in `rs/libmoq/src/origin.rs`. +- Hand-written wrappers and `doc/lib/{py,swift,kt,go,dart,c}` follow, with a + test per binding that an empty origin still yields `Live`. + +Public API: breaking in moq-ffi, libmoq's C ABI, and every wrapper, so it +retargets to `dev` with the Rust break. Wire: none. + +## Required + +- [Caught up](/quest/m1/cli-inspect/caught-up.md) - settles the event shape this mirrors + +## Related + +- [JS caught up](/quest/m1/js-announce-caught-up.md) - the same event in `@moq/net` diff --git a/quest/m1/auth/README.md b/quest/m1/auth/README.md index 9ffe7a6664..a7de50b497 100644 --- a/quest/m1/auth/README.md +++ b/quest/m1/auth/README.md @@ -92,6 +92,10 @@ existing lite-06 ALPN. - [Lite stream](/quest/m1/auth/lite.md) - both sides of a lite-06 session exchange grants over AUTH streams, exposed as `Session::auth()`, and an out-of-scope announce aborts the session +- [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 +- [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 diff --git a/quest/m1/auth/bindings.md b/quest/m1/auth/bindings.md index 596e8437d9..6bd1a36ab2 100644 --- a/quest/m1/auth/bindings.md +++ b/quest/m1/auth/bindings.md @@ -24,6 +24,9 @@ mid-stream can be handed a new one without the plugin reconnecting. async calls use, in `rs/libmoq/src/api.rs` and the session table; regenerate `moq.h`, and update `cpp/obs/src` only if the plugin surfaces a token field, otherwise leave it. +- Interop: the Python, Go, and C interop clients print their grant and join + the assertion [Interop grants](/quest/m1/auth/interop.md) adds for Rust + and JS. - Wrappers: `py/moq-rs/moq/session.py`, `swift/Sources/Moq`, `kt/.../Flows.kt` (a `Flow` over `grant_changed`), `go/wrapper/moq/session.go` (context-cancellable like the rest), and `dart/moq/lib/moq.dart`. Kotlin diff --git a/quest/m1/auth/interop.md b/quest/m1/auth/interop.md new file mode 100644 index 0000000000..9d9ecf1072 --- /dev/null +++ b/quest/m1/auth/interop.md @@ -0,0 +1,24 @@ +# [S] Interop asserts the AUTH grant + +## Goal + +`just test interop --all` fails a lite-06 cell 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 +with no grant passes silently. + +## Plan + +The Rust and JS interop clients each print the grant they received in one +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 +[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. + +## Required + +- [Lite stream](/quest/m1/auth/lite.md) - the AUTH exchange being asserted diff --git a/quest/m1/auth/relay-refresh.md b/quest/m1/auth/relay-refresh.md index dbfb2b92e0..36d14f7c07 100644 --- a/quest/m1/auth/relay-refresh.md +++ b/quest/m1/auth/relay-refresh.md @@ -38,16 +38,17 @@ expiry, an expiry that leaves the union intact ends only that token, and - Expiry: the deadline today is the admitted lease's `closed()` inside one `tokio::select!` arm. Select on one lease per token in the set instead, and on any firing recompute the union without it: unchanged means `AUTH_ERROR { Expired }` on that token's stream and the session - continues, shrunk means `session.abort(Unauthorized)` as today. A token + continues, shrunk narrows the origin and resets each stream that lost + access with `UNAUTHORIZED`. A token withdrawn by the client (its stream closed or reset) recomputes the union the same way with nothing written back, since the stream is gone: unchanged - means the session continues, shrunk means abort. Each lease revalidates on + means the session continues, shrunk narrows the same way. Each lease revalidates on its own cadence, and every re-check that changes the grant, a lower `expires` or a different `publish` or `subscribe`, writes the complete replacement grant as an update AUTH_OK on that token's stream and recomputes the union from the latest grant of every token, so the client can present a replacement in time and a narrowed token never keeps its old - scope; a shrunk union aborts as above until Origin scopes resizes. + scope; a shrunk union narrows as above. - Refusals map as connect-time ones do and never expose a status: every refusal `Client::attach` returns for a new token, a `403`, an empty or invalid grant, an unparseable body, or a `5xx`, is @@ -71,7 +72,8 @@ expiry, an expiry that leaves the union intact ends only that token, and an expired or invalid token is refused and nothing changes; a token with a different root is refused naming it; a token adding a prefix rebuilds the origin scope and the prefix is now solicited; withdrawing the only token - covering a prefix closes the session; an mTLS session refuses a token; a + covering a prefix resets that prefix's streams with `UNAUTHORIZED` and + the session stays connected; an mTLS session refuses a token; a proxy-mode outage on re-check keeps the token. Additive. @@ -82,3 +84,5 @@ Additive. - [Pattern interest](/quest/m1/path-patterns.md) - AUTH can represent the complete grants relay revalidation returns - [Lite stream](/quest/m1/auth/lite.md) - supplies the AUTH stream and `auth::Request` this consumes +- [Unauthorized reset](/quest/m1/auth/unauthorized.md) - the code this + relay's revocations reset with diff --git a/quest/m1/auth/unauthorized.md b/quest/m1/auth/unauthorized.md new file mode 100644 index 0000000000..cbd1c255e2 --- /dev/null +++ b/quest/m1/auth/unauthorized.md @@ -0,0 +1,25 @@ +# [S] A subscription that loses access resets with UNAUTHORIZED + +## Goal + +When a grant shrinks and a subscription, fetch, or announce loses access, +its stream resets with a dedicated `UNAUTHORIZED` stream code, so the peer +tells revocation apart from a session closing. Today it resets with +`0x3 SESSION_CLOSED` (`StreamError::Session(Unauthorized)`). + +## Plan + +Assign `0x3A UNAUTHORIZED` in the lite draft's stream error table, the next +code in the application block after `TIMESTAMP_MISMATCH`, on lite-06: a +published lite-06 peer already maps an unknown stream code to a generic +error, so the addition is compatible. Add the matching `StreamError` +variant (the enum is non-exhaustive) in Rust and JS, send it from every +revocation path the lite stream added, and cover it in the Rust and JS +tests. Map it to the existing `Unauthorized` protocol kind in +`stream_kind` in `rs/moq-ffi/src/error.rs` and `rs/libmoq/src/error.rs`, +whose wildcard arms would otherwise report `Unknown`, with a test for each. +Update `drafts/draft-lcurley-moq-lite.md` and run `just drafts check`. + +## Required + +- [Lite stream](/quest/m1/auth/lite.md) - the revocation paths that send it diff --git a/quest/m1/js-announce-caught-up.md b/quest/m1/js-announce-caught-up.md index 46785d11e4..c568f1fdbb 100644 --- a/quest/m1/js-announce-caught-up.md +++ b/quest/m1/js-announce-caught-up.md @@ -9,7 +9,9 @@ broadcasts" instead of a spinner that never resolves. ## Plan -Mirror the Rust shape, name, and marker-less fallback. Test the same cases +Mirror the Rust shape, name, and marker-less fallback: one flat event, +`Announced`, `Updated`, `Retracted` (each carrying the announce), or `Live`, +replacing the update's `kind` field. Test the same cases in JS. Public API: the announce consumer's yield type changes, so it lands with the Rust break. Wire: none. diff --git a/quest/m1/listener-close.md b/quest/m1/listener-close.md new file mode 100644 index 0000000000..e765fe2d6d --- /dev/null +++ b/quest/m1/listener-close.md @@ -0,0 +1,20 @@ +# [S] Closing a listener releases its socket before returning + +## Goal + +Closing a moq-tokio server, and moq-ffi's `Listener::close` on top of it, +returns only after the UDP socket is released, so a restart can rebind the +same port at once. Go's `TestReconnectAcrossRelayRestart` +(`go/wrapper/reconnect_test.go`) fails on main today because the rebind +races the old socket. + +## Plan + +`Server::shutdown` (`rs/moq-tokio/src/server.rs`) calls `noq.close()` and +sleeps 100 ms without waiting for the endpoint driver to drop the socket. +Await the driver's exit instead and delete the sleep; moq-ffi's +`Listener::close` already documents a synchronous release, so make it true +rather than change the doc. Check the io_uring and WebSocket listeners for +the same gap. Add a Rust test that closes a server and rebinds its port +immediately; the Go test becomes the cross-language regression. No wire +change; the only API change is that close takes as long as the release. diff --git a/quest/m1/tokio-test-ports.md b/quest/m1/tokio-test-ports.md new file mode 100644 index 0000000000..f94afdb374 --- /dev/null +++ b/quest/m1/tokio-test-ports.md @@ -0,0 +1,17 @@ +# [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/m1/uring-handshake-flush.md b/quest/m1/uring-handshake-flush.md deleted file mode 100644 index b4e1614a1a..0000000000 --- a/quest/m1/uring-handshake-flush.md +++ /dev/null @@ -1,34 +0,0 @@ -# [M] An io_uring dial resolves once its last handshake flight is sent - -## Goal - -`moq_uring::quic::client::connect` hands back a connection only after the -handshake's final flight has been written to the socket, so a worker that -stops right after the dial resolves cannot strand a peer that is still waiting -for it. A worker that stops before that point fails the dial loudly. No new -public API or wire format. - -## Plan - -Today the dial resolves when the handshake completes locally, while the -client's last flight is still queued behind pacing on the worker (#3865). If -the worker stops in that window nothing is sent, the server times the -connection out, and the client holds a connection that looks established. The -behaviour is documented but easy to hit from a short-lived task. - -Reproduce it first on Linux CI: dial, stop the worker immediately, and show -the server never accepts. Then keep the dial future pending until the driver -reports the handshake flight flushed, and return `quic::Error` when the worker -stops before then. The flush belongs in moq-uring's connect and establish -path; moq-tokio is unaffected. Do not add a timeout or a retry, and do not -drain sends from worker shutdown. - -Linux CI covers the stopped-worker dial (now refused or fully sent, never -stranded), a normal dial still resolving promptly, and a paced flight on a -slow link. Update the moq-uring docs where the window was described. - -Public API: none. Wire: none. - -## Related - -- [uring identity](https://github.com/moq-dev/moq/pull/3865) - where the window was found diff --git a/quest/m2/README.md b/quest/m2/README.md index db6dd0c524..028e2e95bf 100644 --- a/quest/m2/README.md +++ b/quest/m2/README.md @@ -27,6 +27,7 @@ upstream release waits in [m4](/quest/m4/README.md). - [Audio loss recovery](/quest/m2/audio-loss-recovery.md) - prove a useful Opus recovery policy before exposing another option - [Opus implementation](/quest/m2/audio-opus-backend.md) - compare current codec quality, CPU, and optional build costs - [Latency ledger](/quest/m2/latency-ledger.md) - a session reports where its end-to-end audio delay went, stage by stage +- [JS discontinuity](/quest/m2/js-discontinuity.md) - JS names its timeline break `discontinuity()` like Rust, so `cut` means the same group close in both - [Media Foundation decode](/quest/m2/audio-decode-mediafoundation.md) - Windows decodes HE-AAC, multichannel AAC, and what else the MFTs offer - [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 diff --git a/quest/m2/js-discontinuity.md b/quest/m2/js-discontinuity.md new file mode 100644 index 0000000000..a342c79b4e --- /dev/null +++ b/quest/m2/js-discontinuity.md @@ -0,0 +1,24 @@ +# [S] JS names the break discontinuity() + +## Goal + +`@moq/hang`'s container producer spells its two operations the way Rust +`moq_mux::container::Producer` does: `cut(end?)` closes the current group, +and `discontinuity()` closes it and writes the empty marker group that tells +subscribers to re-anchor. Today JS's public `cut()` writes the marker, so the +same name means a routine group close in Rust and a timeline break in JS. + +## Plan + +[#4045](https://github.com/moq-dev/moq/pull/4045) deletes +`quest/m1/js-publish-discontinuity.md`, since #3982 already put the marker +in `cut()`; this rename is the only remaining JS work. + +In `js/hang/src/container/legacy.ts`, rename the public `cut(end?)` to +`discontinuity()` (taking the same optional end) and keep the routine close +private until a caller needs it public. Move `js/publish/src/video/encoder.ts` +and any other caller over, and keep data tracks skipping a sequence the way +Rust's `discontinuity()` does if a JS data-track caller appears. Rust is +untouched. + +Public API: breaking in published `@moq/hang`, so it targets `dev`. Wire: none.