diff --git a/quest/README.md b/quest/README.md index 9e1b8d96ce..97c5ad7b97 100644 --- a/quest/README.md +++ b/quest/README.md @@ -19,9 +19,8 @@ kind, at its priority rank. - [m0: bug fixes](/quest/m0/README.md) - defects in what main ships today, security first -- [m1: the dev line](/quest/m1/README.md) - the thread-per-core runtime, net - model follow-ups, breaking bindings work, and the archive line that gates - the dev merge +- [m1: the dev line](/quest/m1/README.md) - the breaking API and wire + changes, the archive line that gates the dev merge, and the merge itself - [m2: features](/quest/m2/README.md) - new capabilities on stable surfaces, from wire extensions to E2EE to developer packages - [m3: prototypes](/quest/m3/README.md) - experiments, spikes, hardware diff --git a/quest/m0/3479-mux-jitter-flush-span.md b/quest/m0/3479-mux-jitter-flush-span.md index 934b32f05c..8b0697708c 100644 --- a/quest/m0/3479-mux-jitter-flush-span.md +++ b/quest/m0/3479-mux-jitter-flush-span.md @@ -62,4 +62,4 @@ The work: ## Related - [Auto latency](/quest/m0/3477-watch-auto-latency.md) - reads this field as its codec floor -- [#3208](/quest/m1/3208-make-2-5-ms-opus-frame-durations-work-across-bindings.md) - the same ceiling rule for Opus frame durations +- [#3208](https://github.com/moq-dev/moq/issues/3208) - the same ceiling rule for Opus frame durations diff --git a/quest/m0/3492-ietf-track-status-refusal.md b/quest/m0/3492-ietf-track-status-refusal.md index df9fd4b02f..d8668ccf40 100644 --- a/quest/m0/3492-ietf-track-status-refusal.md +++ b/quest/m0/3492-ietf-track-status-refusal.md @@ -18,7 +18,7 @@ answers PUBLISH with NOT_SUPPORTED and closes the writer explicitly (#3348): - Decode the request so the stream is consumed, reply with the per-draft refusal (TRACK_STATUS_ERROR on draft-14, REQUEST_ERROR from draft-15 on) using - the code type [IETF error codes](/quest/m0/ietf-error-codes.md) introduces, + the code type [IETF error codes](https://github.com/moq-dev/moq/pull/3531) introduces, and close the writer. On draft-14 and 15 the request rides a virtual stream over the control stream whose reset is a no-op, so the explicit reply is the only way bytes reach the peer. @@ -30,7 +30,7 @@ Branch from dev, where the error registry lands. ## Required -- [IETF error codes](/quest/m0/ietf-error-codes.md) - the registered NOT_SUPPORTED value per draft comes from its code type +- [IETF error codes](https://github.com/moq-dev/moq/pull/3531) - the registered NOT_SUPPORTED value per draft comes from its code type ## Closes diff --git a/quest/m0/3534-ietf-publisher-priority.md b/quest/m0/3534-ietf-publisher-priority.md index 80bd2d1a7f..92166b68e3 100644 --- a/quest/m0/3534-ietf-publisher-priority.md +++ b/quest/m0/3534-ietf-publisher-priority.md @@ -67,4 +67,4 @@ specifies the field. ## Related -- [IETF error codes](/quest/m0/ietf-error-codes.md) - the sibling sweep of the moq-transport registries +- [IETF error codes](https://github.com/moq-dev/moq/pull/3531) - the sibling sweep of the moq-transport registries diff --git a/quest/m0/README.md b/quest/m0/README.md index 3a4422063d..d5e343f93c 100644 --- a/quest/m0/README.md +++ b/quest/m0/README.md @@ -24,7 +24,6 @@ regression test per Root Cause First. - [TRACK_STATUS refusal](/quest/m0/3492-ietf-track-status-refusal.md) - moq-net: TRACK_STATUS gets a NOT_SUPPORTED refusal instead of a silent drop - [Connect auth race](/quest/m0/3532-connect-auth-race.md) - moq-native: a 403 on the WebSocket arm no longer fails a connect whose QUIC arm is still in flight; moq-ffi can disable the fallback - [Resume info](/quest/m0/resume-info-newest.md) - moq-net: resume reports segment zero's track info, so a replaced broadcast rescales timestamps on the predecessor's timescale -- [Failure artifacts](/quest/m0/qa-failure-artifacts.md) - retain inspectable traces, logs, and rerun commands when QA fails - [TS restart stall](/quest/m0/3533-ts-export-restart-stall.md) - moq export ts: a content restart on a continuous timeline no longer fences video and primary audio for good - [Failure artifacts](/quest/m0/qa-failure-artifacts.md) - a failing harness run keeps its run directory and a Playwright trace, and CI uploads them - [Harness drive-bys](/quest/m0/harness-drive-bys.md) - decide whether the relay listening kind field that came with #3509 stays diff --git a/quest/m0/resume-info-newest.md b/quest/m0/resume-info-newest.md index a9eaf7db4c..d6b068caa7 100644 --- a/quest/m0/resume-info-newest.md +++ b/quest/m0/resume-info-newest.md @@ -37,11 +37,11 @@ mismatch is reachable from any client. ### Not covered by an existing tracker -[#2991](/quest/m1/2991-net-coalesce-dynamic-tracks-and-preserve-sequences-across.md) +[#2991](/quest/m2/2991-net-coalesce-dynamic-tracks-and-preserve-sequences-across.md) is about sequence continuity across replacement, not info resolution. #2610's epoch remedy was removed from the draft by #3225, so there is no wire-level generation marker to lean on; the fix is local. ## Related -- [#2991](/quest/m1/2991-net-coalesce-dynamic-tracks-and-preserve-sequences-across.md) - sequence continuity across the same replacement +- [#2991](/quest/m2/2991-net-coalesce-dynamic-tracks-and-preserve-sequences-across.md) - sequence continuity across the same replacement diff --git a/quest/m1/2075-mirror-catalog-reservation-gating-in-moq-hang-js-hang.md b/quest/m1/2075-mirror-catalog-reservation-gating-in-moq-hang-js-hang.md deleted file mode 100644 index 38bdad71ad..0000000000 --- a/quest/m1/2075-mirror-catalog-reservation-gating-in-moq-hang-js-hang.md +++ /dev/null @@ -1,55 +0,0 @@ -# [M] Mirror catalog reservation gating in @moq/hang (js/hang) - -## Goal - -Implement and verify the behavior tracked in [#2075](https://github.com/moq-dev/moq/issues/2075) -within the issue's stated scope and boundaries. - -## Plan - -Rescoped during the 2026-08 grooming: the cited js/hang/src/catalog/producer.ts -no longer exists on dev. Redesign the first-snapshot reservation gate against -dev's js/publish CatalogProducer (mutate/serve, per-subscriber seeding). - -### Issue context - -#### Background - -[#2072](https://github.com/moq-dev/moq/pull/2072) added **publisher-side catalog reservation gating** to `moq-mux` (Rust), fixing the convergence race in #1979 where a one-shot muxer (fMP4, MPEG-TS) sees an incomplete, e.g. audio-only, catalog snapshot before a later rendition's config resolves. - -The mechanism, publisher-side only (wire and catalog schema unchanged): - -- `catalog::Producer::reserve()` returns a clonable `Reserved`. -- An importer reserves its rendition via `Reserved::init` (`.video()` / `.audio()`), getting a `Rendition` guard that **holds its own `Reserved` clone until its config is `set()`** (or it's dropped). -- The catalog is **withheld from the broadcast until every `Reserved` is gone**, so an unresolved rendition keeps the gate shut. When the last resolves, exactly one complete snapshot publishes. A `pending` flag avoids emitting an empty snapshot for an untouched catalog. -- Container importers own a reservation across track discovery and drop it once their initial set is declared, which also lets several importers compose into one broadcast under a single shared gate. - -#### Why mirror it in JS - -`@moq/hang`'s catalog `Producer` ([`js/hang/src/catalog/producer.ts`](https://github.com/moq-dev/moq/blob/dev/js/hang/src/catalog/producer.ts)) publishes incrementally as tracks are added, with no gate. A browser publisher that adds video and audio in separate ticks emits a partial catalog first, so any consumer that can't reinitialize (or just wants a stable first snapshot) hits the same race the Rust side just fixed. Parity keeps the two producers behaviourally aligned even though the wire doesn't force it. - -#### Proposed direction - -Add an equivalent reservation to the JS catalog `Producer` so the first published snapshot is complete: - -- A `reserve()` returning a `Reserved` handle, and a per-rendition guard that holds the reservation until its config is set (or it's dropped). -- Withhold the initial publish until all reservations resolve; publish incrementally afterwards. -- Producers that don't reserve keep publishing incrementally (opt-in, non-breaking for existing callers that never call `reserve()`). - -#### Design decisions to settle (why this is an issue, not a mechanical port) - -- **TS shape.** Rust uses `Reserved` + `Rendition` + a sealed `Kind`. TS has no importer-per-codec layer in the same shape, so decide the ergonomic surface: a `reserve()` + returned rendition handle, an options flag, or an explicit `producer.complete()` call. The `signals`/`Effect` lifecycle model may suggest a different idiom. -- **Where the gate lives.** `@moq/hang`'s publish side (`js/hang/publish`) is structured differently from Rust's container/codec importers; identify the analogous "declare all tracks, then publish" point. -- **Composition.** Whether JS needs the shared-gate-across-importers property at all, or only the single-producer complete-first-snapshot behaviour. - -#### Scope / logistics - -- Wire and catalog JSON schema **unchanged** - pure publisher-side timing. -- API addition to `@moq/hang`; if it changes existing shapes, target **`dev`** per branch-targeting rules. -- Cross-package sync: `demo/web` if it drives the catalog producer directly. - -*Follow-up to #2072 (Rust). Fixes the JS half of #1979-style convergence.* - -## Closes - -- [#2075](https://github.com/moq-dev/moq/issues/2075) - close this issue when the quest finishes diff --git a/quest/m1/2152-libmoq-c-abi-catch-up-with-the-moq-ffi-surface.md b/quest/m1/2152-libmoq-c-abi-catch-up-with-the-moq-ffi-surface.md index 594686bafe..0e624e44a2 100644 --- a/quest/m1/2152-libmoq-c-abi-catch-up-with-the-moq-ffi-surface.md +++ b/quest/m1/2152-libmoq-c-abi-catch-up-with-the-moq-ffi-surface.md @@ -1,40 +1,41 @@ -# [S] libmoq: C ABI catch-up with the moq-ffi surface +# [M] libmoq: dynamic track serving and server-side accept ## Goal -Implement and verify the behavior tracked in [#2152](https://github.com/moq-dev/moq/issues/2152) -within the issue's stated scope and boundaries. +A C embedder serves tracks on demand inside a broadcast it publishes, and +accepts sessions itself through the same two-phase SETUP the FFI exposes. +These are the two `moq-ffi` capabilities `rs/libmoq` still lacks that ride +the request records #3190 reshapes. ## Plan -Rescoped during the 2026-08 grooming: subscription options, track info, abort -codes, and client TLS roots landed in dev's rs/libmoq. Remaining gaps: -fetch_group, dynamic track serving within a broadcast, server-side accept, and -datagrams, tracked against the dev FFI surface. Broadcast serving is not in -this quest: the announce handle that advertises a prefix and yields its -requests reaches C through the bindings quest linked below, so do not add a -separate `requested_broadcast` path here. +Most of the catch-up #2152 lists has landed in dev's `rs/libmoq/src/api.rs`: +subscription options, track info, abort codes, client TLS roots, datagrams +(`moq_datagram` :462-468, `moq_publish_track_datagram` :1948, and +`moq_consume_datagrams` with its read, free, and close :2599-2650), and raw +frame timestamps (:2538-2541). Two gaps remain: -### Issue context +- Dynamic track serving. moq-ffi's `MoqBroadcastProducer::requested_track` + yields a `MoqTrackRequest` whose `accept(info)` returns the producer + (rs/moq-ffi/src/producer.rs:483, :624); api.rs has no `requested` symbol. + Mirror it as a callback-delivered request handle with accept and reject, + on the datagram task's handle and terminal-status contract. Broadcast + requests are not in this quest: `requested_broadcast` reaches C through + #3190's dynamic handle, so do not add a separate path here. +- Server-side accept. moq-ffi's `MoqServer::accept` yields a `MoqRequest` + whose own `accept()` completes SETUP (rs/moq-ffi/src/server.rs:54, :144, + :186, :259); a C embedder cannot accept sessions at all. -The libmoq C ABI (`rs/libmoq/src/api.rs`) has fallen well behind moq-ffi. All of these are additive (new symbols), so none block the dev->main merge, but the backlog is getting long: +Each addition regenerates `moq.h`, touches `cpp/obs/src` only if used, and +updates `doc/lib/c/index.md`. That page's capability list (:39) already +claims dynamic tracks for C; the request handle makes it true. -- **Subscription options**: `moq_consume_track` takes no options; no priority/ordered/stale/group-range equivalent of `MoqSubscription`, and no mid-stream update. -- **Track info on publish**: `moq_publish_track` cannot set `timescale`/`priority`/`ordered`/`cache` (no `MoqTrackInfo` equivalent). -- **Fetch**: no `fetch_group`, and no dynamic group serving (moq-ffi gains these in #2142; mirror the shape). -- **Dynamic track/broadcast serving**: no `requested_track`/`requested_broadcast` path at all. -- **abort with error code**: only clean close/finish exists; no abort(code) for tracks/groups. -- **Server / two-phase accept**: no server-side API (moq-ffi has `MoqServer`/`MoqRequest` with the SETUP path); C embedders cannot accept sessions. -- **Client TLS knobs**: roots/system-roots/fingerprints/disable-verify are env-only; moq-ffi exposes them as options. -- **Datagrams**: tracked with the moq-ffi datagram issue; mirror whatever lands there. -- **Raw-frame timestamps**: raw consume reports `timestamp_us = 0`; tracked with the raw-frame timestamps issue. +Branch from `dev`, after #3190. Fetch and the video format knob are additive, +so they ship on main through the related quest. -Suggest splitting off pieces as they're picked up rather than one mega-PR. Each addition also touches `cpp/obs` consumers only if used, plus `doc/lib/c` per the Cross-Package Sync table. +## Required -Video decode is the widest hole on the C side: `moq-ffi` has a `video` -feature over `moq-video` (NVIDIA and VAAPI on), but `libmoq`'s -`moq_consume_video_raw` is H.264-only with no format or resolution knob. -`moq play` is the worked example of what the shape should be. +- [#3190](/quest/m1/3190-align-origin-broadcast-creation-naming-across-language.md) - the dynamic handle and request records these calls share ## Closes @@ -42,4 +43,4 @@ feature over `moq-video` (NVIDIA and VAAPI on), but `libmoq`'s ## Related -- [#3190](/quest/m1/3190-align-origin-broadcast-creation-naming-across-language.md) - brings the announce handle and broadcast request serving to C +- [libmoq fetch](/quest/m2/libmoq-fetch.md) - fetch_group and the video format knob, on main diff --git a/quest/m1/2296-moq-native-bring-the-quiche-backend-to-quinn-noq-feature.md b/quest/m1/2296-moq-native-bring-the-quiche-backend-to-quinn-noq-feature.md deleted file mode 100644 index a3f4f15088..0000000000 --- a/quest/m1/2296-moq-native-bring-the-quiche-backend-to-quinn-noq-feature.md +++ /dev/null @@ -1,81 +0,0 @@ -# [M] moq-tokio: bring the quiche backend to quinn/noq feature parity - -## Goal - -Close the remaining gaps between the quiche backend and the common quinn/noq -behavior in `moq-tokio` (the crate `moq-native` was renamed to), tracked by -[#2296](https://github.com/moq-dev/moq/issues/2296). - -## Plan - -Most of the original audit has landed. What remains is listed below, each with -what blocks it. Re-audit against the current tree before starting: the issue was -written against `moq-native` and several items closed themselves as dev moved. - -### Already done - -Do not redo these; they have tests. - -- WebTransport over HTTP/3, raw QUIC (`moqt://` / `moql://`), and the `http://` - fingerprint bootstrap. -- Protocol-version ALPN negotiation, stream counts, idle timeout, path MTU - discovery, custom roots, explicit SHA-256 pinning, disabled verification, - terminal HTTP/auth rejection, connection statistics. -- Outbound mTLS from `--connect-tls-cert` / `--connect-tls-key`, and from an - in-memory `tls::Identity`. -- Inbound optional mTLS from `--listen-tls-root`, surfaced through - `Request::peer_identity`. -- `--connect-tls-host-name`, keep-alive, `--*-quic-gso=false`, and the shared - dual-stack bind plus address-family-aware DNS selection. -- The full `tls::Server` certificate semantics, through the shared - `tls::ServeCerts` and `ez::ServerBuilder::with_cert_resolver`: every - configured cert/key pair, generated and in-memory certificates alongside the - file-backed ones, SNI selection, key/certificate validation, and hot reload - with live fingerprints. - -### Remaining - -Local work, with the APIs already exposed: - -- \[ ] Honor `--listen-quic-lb-id` / `--listen-quic-lb-nonce` with the same - validation and connection-ID layout as quinn/noq. Currently logged and - ignored. `tokio_quiche::QuicListener` carries a `cid_generator`, and - `ez::ServerBuilder::with_listener` takes such a listener, so this is - implementable here. Note that `with_listener` bypasses `with_gso`: the - listener's `capabilities` have to be computed by the caller, so GSO must keep - working through that path. - -Blocked on `web-transport-quiche` / `tokio-quiche` / `quiche`, or on a local -lower-level integration that skips their `ez` layer: - -- \[ ] Make `Server::close` stop the quiche listener and close/drain active - connections. `QuicheServer::close` is a no-op, while quinn/noq send an - endpoint-wide close. `ez::Server` neither exposes a close nor tracks its - established connections. -- \[ ] Match quinn/noq platform certificate verification, including mobile. - boringssl takes a concrete root list rather than a rustls verifier, so the - client path snapshots `rustls-native-certs`; iOS/Android get no roots and fail - closed. -- \[ ] Honor `SSLKEYLOGFILE`, matching the rustls key logging quinn/noq install. - The `SslContextBuilder` is built inside `web-transport-quiche`'s connection - hook, which exposes no keylog callback. -- \[ ] Hot reload the inbound mTLS client roots (`--listen-tls-root`). - `ez::ClientAuth` is applied once, when the listener is built. -- \[ ] Support a pinned client-fingerprint allowlist (`tls::Listen::peers`), - which currently returns `tls::Error::PeersUnsupported`. It needs a per-handshake - verify callback on the server side; boringssl's client-auth path validates - against a fixed root store instead. -- \[ ] Honor `--listen-preferred-v4` / `--listen-preferred-v6`. quiche still has - TODOs for encoding/decoding the `preferred_address` transport parameter. - -### Not parity blockers - -- \#2276 is noq-only multipath, which quinn does not support. -- \#686 tracks congestion control/BBR, where quinn and noq do not currently - behave the same. -- \#679 tracks multi-threaded UDP receive scaling, which is a reason to use - quiche rather than a parity gap. - -## Closes - -- [#2296](https://github.com/moq-dev/moq/issues/2296) - close this issue when the quest finishes diff --git a/quest/m1/2318-js-net-remaining-capability-gaps-vs-rs-moq-net-setup-role.md b/quest/m1/2318-js-net-remaining-capability-gaps-vs-rs-moq-net-setup-role.md deleted file mode 100644 index b2337b3bc7..0000000000 --- a/quest/m1/2318-js-net-remaining-capability-gaps-vs-rs-moq-net-setup-role.md +++ /dev/null @@ -1,32 +0,0 @@ -# [M] js/net: remaining capability gaps vs rs/moq-net (SETUP role, finish_at and final sequence, range controls, typed errors) - -## Goal - -Implement and verify the behavior tracked in [#2318](https://github.com/moq-dev/moq/issues/2318) -within the issue's stated scope and boundaries. - -## Plan - -Rescoped during the 2026-08 grooming: SETUP role, frame naming, typed errors, -and start/end cursors landed on dev. One gap survives here: track end -semantics (finishAt / finished(), still collapsed into close(abort?)). The -producer-side prefix serving a Rust publisher has and a browser one does not -is [JS announce](/quest/m1/js-announce.md), not this quest. - -### Issue context - -Where js/net is a strict subset of the Rust model. The subscription-options model is tracked in #2155 and the announce Restart state in #2216; this covers the rest. - -- \[ ] **SETUP role parameter**: Rust encodes param 0x3 (Publisher/Subscriber/Both, derived from which origins the client wired up) and the draft documents it, but `js/net/src/lite/setup.ts` only implements probe and path. A JS client always presents as Both and a JS server cannot read a peer's role. `Established.publish/consume` usage can derive it just like Rust's `Role::from_origins`. -- \[ ] **Track end semantics**: Rust distinguishes `finish()` (clean end at live edge), `finish_at(final_sequence)` (declare an end ahead of the live edge), and `abort(err)`, and consumers can await `finished() -> u64`. JS collapses everything into `close(abort?)`: no `finishAt`, and the SUBSCRIBE\_END group number is discarded on the consuming side, so the JS model cannot express or observe lite-05 clean-end semantics. (Interacts with the SUBSCRIBE\_END off-by-one bug, #2309, which should land first.) -- \[ ] **Range/cursor controls**: `start_at`, `end_at`, `get_group` (wait for a live sequence), sync cache peek, and `latest()` have no JS equivalents. Some are relay-only and fine to omit, but `startAt`/`endAt` pair with the missing subscription range fields and a JS player implementing catch-up/DVR hits this wall. -- \[ ] **Typed errors**: Rust has a `#[non_exhaustive]` enum with stable wire codes; JS throws bare `Error` with prose (only `CacheFull` is a class), so consumers string-match to distinguish `NotFound` from `Unauthorized`, and wire reset codes are not surfaced. At minimum add a stable `code` property before locking the API. -- \[ ] **Delete the dead `SubscribeOptions` export** (`js/net/src/track.ts`): introduced by #2167, superseded by `Subscription` in #2170, identical field-for-field, referenced nowhere. Two exported names for one concept invites drift. -- \[ ] **Producer-side `announce(prefix)`**: Rust publishers advertise a prefix route and serve whatever is requested beneath it; JS only consumes prefix routes, so a browser publisher still announces exact paths and cannot advertise a catalog it would materialize on demand. -- \[ ] **Frame field naming**: JS uses `payload` for `Datagram` but `data` for both Frame types, and `@moq/hang`'s container Frame is shaped differently from both `@moq/net`'s and Rust's. Pick `payload` consistently (JS `keyframe`/`duration` are genuine WebCodecs needs and fine as additive fields). - -Related: #2155, #2216, #2073. - -## Closes - -- [#2318](https://github.com/moq-dev/moq/issues/2318) - close this issue when the quest finishes diff --git a/quest/m1/2709-per-broadcast-bandwidth-estimates-and-reservation.md b/quest/m1/2709-per-broadcast-bandwidth-estimates-and-reservation.md index 355c6008a7..f3e22abb29 100644 --- a/quest/m1/2709-per-broadcast-bandwidth-estimates-and-reservation.md +++ b/quest/m1/2709-per-broadcast-bandwidth-estimates-and-reservation.md @@ -39,11 +39,12 @@ Steps: reserves against the same registry. - Demand comes from the `js/net` track producer (active vs idle) and priority from the track info the publisher set (`js/hang` `PRIORITY`: catalog 100, - audio 80, video 60), the same tiers Rust allocates on. + text 90, audio 80, video 60, `js/hang/src/catalog/priority.ts:5`), the same + tiers Rust allocates on. - `js/publish` video takes its reservation's grant instead of `estimate * 0.9`; audio reserves at its configured bitrate and ignores the grant, which matches Rust's audio today. Following the grant for Opus is the JS twin of - [#2848](/quest/m1/2848-follow-the-bandwidth-grant-in-moq-audio-instead-of.md) + [#2848](/quest/m2/2848-follow-the-bandwidth-grant-in-moq-audio-instead-of.md) and stays out of scope. Tests: the ported `allocate()` cases verbatim; an integration test with two @@ -58,6 +59,7 @@ Branch from `dev`, where the shared connection and `forward.ts` live. ## Related -- [#2848](/quest/m1/2848-follow-the-bandwidth-grant-in-moq-audio-instead-of.md) - audio following its grant, the Rust half +- [#2848](/quest/m2/2848-follow-the-bandwidth-grant-in-moq-audio-instead-of.md) - audio following its grant, the Rust half - [#2859](/quest/m1/2859-passthrough-imports-reserve-no-bandwidth-so-a-co-resident.md) - passthrough imports reserving nothing - [#2857](/quest/m1/binding-rate-control.md) - the same gap for the native bindings +- [#2774](/quest/m1/2774-collapse-reload-and-shared-into-one-connection-class.md) - the unified Connection owns the sampler and allocator; both touch `connection/{reload,pool,established}.ts` diff --git a/quest/m1/2774-collapse-reload-and-shared-into-one-connection-class.md b/quest/m1/2774-collapse-reload-and-shared-into-one-connection-class.md index e6f2f82388..9ebb793678 100644 --- a/quest/m1/2774-collapse-reload-and-shared-into-one-connection-class.md +++ b/quest/m1/2774-collapse-reload-and-shared-into-one-connection-class.md @@ -2,44 +2,46 @@ ## Goal -Implement and verify the behavior tracked in [#2774](https://github.com/moq-dev/moq/issues/2774) -within the issue's stated scope and boundaries. +`@moq/net` exports one `Connection`: `new Connection({ url })` reconnects and +shares a pooled transport by default, `Established` and `Reload` leave the +published surface, and two `` tiles on one relay still become one +QUIC session without either arranging it. ## Plan -Use the public issue's scope, implementation notes, and acceptance criteria -below as the starting plan. Reconcile paths and assumptions with the current -tree before implementation. - -### Issue context - -Follow-up to #2705, which added `Connection.Shared` (a reactive handle on a pooled `{origin, reconnect loop}` keyed by relay URL) alongside the existing `Connection.Reload`. - -#### The problem with the current names - -`Shared` names the mechanism, not the role. Sharing is how it works; what it is to a caller is "the connection to a relay". A caller writing `new Connection.Shared({ url })` is thinking "I want a connection", not "I want a shared one". Three consequences: - -- It marks the default. The only reasons not to share are a pinned certificate or a supplied transport, and both send you to `Reload` instead. A name that qualifies the thing everyone should use implies an `Unshared` peer that does not meaningfully exist. -- Sharing is supposed to be invisible. Two `` tiles becoming one QUIC session is the selling point precisely because nobody has to arrange it, so encoding it in the type name makes callers reason about something they should not have to. -- It ages badly. The stated direction is reconnect and GOAWAY on by default with `Established`/`Reload` dropped from the published surface. At that point this class *is* the connection API and the qualifier is noise, plus a second breaking rename to remove it. - -The repo rule this trips is "name by role, not by today only implementation" (CLAUDE.md, Public API Scrutiny). Note there is no Rust counterpart to converge on: `moq_native::Reconnect` names the behavior and there is no pooling type at all. - -#### Proposal - -One `Connection` class, with reconnect and sharing as defaults rather than as separate types: - -- `new Connection({ url })` reconnects and shares by default. -- `share: false` for the cases that cannot be shared honestly (pinned certificate, supplied transport). -- `Established` and `Reload` drop off the published surface (`@internal` or unexported), leaving one entry point. -- GOAWAY slots into the same loop: the redirect handler dials the new URI and attaches it to the same origin, and nothing above holds a session. - -#### The part that needs design, not just a rename - -`Reload.close()` closes the connection; `Shared.close()` releases one handle and lets the last one out tear it down. Collapsing them means one `close()` whose meaning depends on construction options, which could easily be worse than two honest types. Options worth weighing: always refcount (a lone holder closing is then the same thing), or keep the unshared path internal-only so the ambiguity never reaches a caller. - -Doing this while #2705 is already breaking would save consumers a second migration of the same call sites. Deferring is defensible; the cost is that migration happening twice. +`Shared` names the mechanism, not the role: a caller writing +`new Connection.Shared({ url })` wants a connection, and the qualifier implies +an `Unshared` peer that does not meaningfully exist. Rust already has the +target name: `moq_tokio::Connection` (`rs/moq-tokio/src/connection.rs:537`) is +a cloneable handle on a reconnect loop, and the mirror-names rule says JS +follows it (`moq-native` is a tombstone, `rs/moq-native/src/lib.rs:1`). + +Settled: `Connection` is a cloneable refcounted handle. `close()` releases one +handle; the transport ends when the last handle closes, after the existing +linger (`js/net/src/connection/pool.ts:15`). That is what `Shared.close()` +does today (`pool.ts:205`, the release at `:259-272`); `Reload.close()` +(`js/net/src/connection/reload.ts:428`) is the lone-holder case of the same +rule, so one `close()` has one meaning. + +- A supplied transport cannot enter the reconnect loop at all (`ReloadProps` + refuses it, `reload.ts:41-46`); it goes to `connect()` and an `Established` + (`js/net/src/connection/connect.ts:65`, `:146`). Fix the comment at + `pool.ts:53-54`, which sends that case to `Reload`. +- Sharing keys on the URL. Options the pool cannot honor (a pinned + certificate, caller-owned origins) take `share: false` and get a private + loop with the same handle semantics. +- GOAWAY rides the same loop: the redirect handler dials the new URI and + attaches it to the same origin; nothing above holds a session. +- `Established` and `Reload` become `@internal` or unexported, leaving one + entry point. + +Breaking on `@moq/net`, so it lands on dev. ## Closes - [#2774](https://github.com/moq-dev/moq/issues/2774) - close this issue when the quest finishes + +## Related + +- [Bandwidth allocator](/quest/m1/2709-per-broadcast-bandwidth-estimates-and-reservation.md) - the send-side estimate hangs off the same connection handle +- [JS announce](/quest/m1/js-announce.md) - the broadcast-owned announcement rides the connection's origin diff --git a/quest/m1/2815-lift-adaptive-stage-refusal.md b/quest/m1/2815-lift-adaptive-stage-refusal.md index c84f211e3e..e75bfc46ff 100644 --- a/quest/m1/2815-lift-adaptive-stage-refusal.md +++ b/quest/m1/2815-lift-adaptive-stage-refusal.md @@ -2,34 +2,40 @@ ## Goal -Two encoding stages over one connection, such as `moq ... capture -- capture`, -are accepted, and both encoders target shares of the connection's estimate that -sum to at most it, surplus left unclaimed when a ceiling binds. The refusal in `rs/moq-cli/src/args.rs`, "a stage that encodes to fit -the connection's bandwidth estimate assumes it's the only publisher", is gone -with the test that asserts it. +Two encoding stages over one connection, `moq --connect import capture +-- import capture`, are accepted, and both encoders target shares of the +connection's estimate that sum to at most it, surplus left unclaimed when a +ceiling binds. The refusal in `rs/moq-cli/src/args.rs:277-282`, "a stage that +encodes to fit the connection's bandwidth estimate assumes it's the only +publisher", is gone with the test that asserts it. ## Plan -The allocator #2854 landed on dev is what the refusal stood in for: `main.rs` -mints one `bandwidth::Allocator` per connection and every encoding sender -reserves against it (the `moq-video` encode producer, `moq-audio` capture), so -the guard refuses a configuration the allocator already divides by track -priority. +The allocator #2854 landed on dev is what the refusal stood in for: +`spawn_moq` mints one `bandwidth::Allocator` per connection +(`rs/moq-cli/src/main.rs:295-319`) and clones it into every import stage +(`main.rs:367`, `:380`), and every encoding sender reserves against it: the +moq-video capture loop (`rs/moq-video/src/encode/producer.rs:467-469`) and +moq-audio's `publish_capture` driver +(`rs/moq-audio/src/encode/capture.rs:471-474`). So the guard refuses a +configuration the allocator already divides by track priority +(`rs/hang/src/catalog/priority.rs:21-26`). - Delete the `adaptive` / `imports == 1` ensure and - `an_adaptive_capture_must_be_the_only_import`. Keep - `audio_only_capture_is_not_bandwidth_adaptive` only if `uses_bandwidth` still - has a reader; otherwise delete both. -- Regression: two capture stages on one connection whose grants sum to at most - the estimate and rank by priority, next to the allocator's - `concurrent_tracks_split_the_estimate`, plus an args test that runs the same - validation entry point the CLI does and accepts the combination, not one that - only parses it. + `an_adaptive_capture_must_be_the_only_import` (`args.rs:1175`). Keep + `audio_only_capture_is_not_bandwidth_adaptive` (`:1233`) only if + `uses_bandwidth` (`:690`) still has a reader; otherwise delete both. +- Acceptance stays at the args and allocator unit level, since two + default-device captures would open the same camera: an args test that runs + `Invocation::validate`, the entry point the CLI uses, and accepts the + combination rather than only parsing it; and a test next to the allocator's + `concurrent_tracks_split_the_estimate` in + `rs/moq-net/src/model/bandwidth.rs` where two video wants on one estimate + get grants summing to at most it and ranked by priority. - Audio reserves its configured rate and does not follow a smaller grant until - [#2848](/quest/m1/2848-follow-the-bandwidth-grant-in-moq-audio-instead-of.md); + [#2848](/quest/m2/2848-follow-the-bandwidth-grant-in-moq-audio-instead-of.md); that is the allocator's documented advisory contract and holds for one capture stage exactly as for two, so it is not a reason to keep the refusal. -- `doc/bin/cli.md` "Multiple stages" drops any mention of the limit. Branch from dev. @@ -39,5 +45,5 @@ Branch from dev. ## Related -- [#2848](/quest/m1/2848-follow-the-bandwidth-grant-in-moq-audio-instead-of.md) - audio following its grant +- [#2848](/quest/m2/2848-follow-the-bandwidth-grant-in-moq-audio-instead-of.md) - audio following its grant - [#2859](/quest/m1/2859-passthrough-imports-reserve-no-bandwidth-so-a-co-resident.md) - passthrough imports joining the same allocator diff --git a/quest/m1/2848-follow-the-bandwidth-grant-in-moq-audio-instead-of.md b/quest/m1/2848-follow-the-bandwidth-grant-in-moq-audio-instead-of.md deleted file mode 100644 index 5302f77b2f..0000000000 --- a/quest/m1/2848-follow-the-bandwidth-grant-in-moq-audio-instead-of.md +++ /dev/null @@ -1,37 +0,0 @@ -# [M] Follow the bandwidth grant in moq-audio instead of holding a fixed reservation - -## Goal - -Implement and verify the behavior tracked in [#2848](https://github.com/moq-dev/moq/issues/2848) -within the issue's stated scope and boundaries. - -## Plan - -Use the public issue's scope, implementation notes, and acceptance criteria -below as the starting plan. Reconcile paths and assumptions with the current -tree before implementation. - -### Issue context - -Follow-up to [#2815](https://github.com/moq-dev/moq/issues/2815), which divides a connection's send-bandwidth estimate among the tracks sharing it. - -In that first pass `moq-audio` registers a share at its configured bitrate and then ignores the grant: it reserves, but never follows. That keeps the video encoder's share honest (audio is no longer invisible to it), which is most of the value, but it means a link too small for the configured audio rate has no way to shed audio bits. - -#### What to add - -Opus can retune live. `moq_audio::encode::encoder` already has `set_opus_bitrate` (valid range 500 bps up to a per-channel-count max) and reads the value back with `OPUS_GET_BITRATE`, so following a grant is a matter of feeding it through a rate policy at the same point `moq-video` does, rather than any new codec work. - -Reuse `moq_video::encode::rate::Control` rather than writing a second policy, or lift it somewhere both crates can reach. Its attack/decay shape (drops apply at once, raises ramp) is codec-agnostic, and having two rate policies drift apart is exactly the problem [#2815](https://github.com/moq-dev/moq/issues/2815) was closing. - -#### What can't adapt - -PCM. `pcm::bitrate(codec_rate, codec_channels)` is fixed by sample rate and channel count, and `Config::bitrate` is rejected outright for it. So a "reserves but never follows" share has to remain expressible regardless; this issue is about Opus opting into following, not about removing the fixed case. - -#### Priority - -Low. `hang::catalog::PRIORITY` puts audio at 80 and video at 60, so the allocator satisfies audio's reservation before video sees a bit. Audio only gets squeezed once the link can't carry audio alone, at which point the picture is long gone. Worth doing for the tail case, not worth blocking on. - -## Closes - -- [#2848](https://github.com/moq-dev/moq/issues/2848) - close this issue when the quest finishes - diff --git a/quest/m1/2853-quiche-with-a-pinned-source-port-can-dial-only-a-broken.md b/quest/m1/2853-quiche-with-a-pinned-source-port-can-dial-only-a-broken.md deleted file mode 100644 index ff2881be30..0000000000 --- a/quest/m1/2853-quiche-with-a-pinned-source-port-can-dial-only-a-broken.md +++ /dev/null @@ -1,44 +0,0 @@ -# [M] quiche with a pinned source port can dial only a broken IPv4 address - -## Goal - -Implement and verify the behavior tracked in [#2853](https://github.com/moq-dev/moq/issues/2853) -within the issue's stated scope and boundaries. - -## Plan - -Use the public issue's scope, implementation notes, and acceptance criteria -below as the starting plan. Reconcile paths and assumptions with the current -tree before implementation. - -### Issue context - -#### Problem - -`rs/moq-native/src/quiche.rs` truncates its candidate list to one address when `--connect-bind` pins a non-zero source port, because a pinned port only fits one socket at a time: - -```rust -if self.bind.port() != 0 { - candidates = candidates.with_limit(1); -} -``` - -Since #2749, that single candidate can come from the speculative IPv4-only lookup rather than the authoritative all-family answer. `Candidates::next` already mitigates this per RFC 8305 section 3: for the first candidate it holds an IPv4-only answer back and waits up to `--connect-resolution-delay` (50ms) for the full lookup, so the platform's own RFC 6724 ranking usually wins. - -That bounds the window rather than closing it. If AAAA is more than the resolution delay slower than A, the wait times out and the IPv4 address is taken. With `limit(1)` there is no second attempt, so a host whose IPv4 path is broken and whose IPv6 path works now fails to connect, where before #2749 the dial waited for the complete resolver result and took its first (IPv6) address. - -Narrow by construction: it needs the quiche backend, a pinned non-zero `--connect-bind` port, a slow AAAA, and a broken IPv4 path. Every other backend races both families, so `limit(1)` is the only place a preference becomes an exclusion. - -#### Suggested direction - -For the `limit(1)` path specifically, wait for the authoritative answer rather than accepting the fast lane: the fast lane exists to start dialing sooner, which is worth nothing when only one attempt will ever be made. - -Found while reviewing #2852 (merging main into dev); the code is identical on `main`, so this is not a merge regression and was left out of that PR. Originally raised by the Codex connector bot as an inline comment there. - -## Required - -- [noq parity gate](/quest/m2/quic/noq-parity.md) - decides whether quiche stays a supported backend; if it is retired this quest is abandoned with the verdict - -## Closes - -- [#2853](https://github.com/moq-dev/moq/issues/2853) - close this issue when the quest finishes diff --git a/quest/m1/2859-passthrough-imports-reserve-no-bandwidth-so-a-co-resident.md b/quest/m1/2859-passthrough-imports-reserve-no-bandwidth-so-a-co-resident.md index fd4660c1b4..e464d04de2 100644 --- a/quest/m1/2859-passthrough-imports-reserve-no-bandwidth-so-a-co-resident.md +++ b/quest/m1/2859-passthrough-imports-reserve-no-bandwidth-so-a-co-resident.md @@ -1,44 +1,103 @@ -# [M] Passthrough imports reserve no bandwidth, so a co-resident encoder over-targets +# [L] Passthrough imports reserve their measured peak bitrate ## Goal -Implement and verify the behavior tracked in [#2859](https://github.com/moq-dev/moq/issues/2859) -within the issue's stated scope and boundaries. +A passthrough import (`moq import rtmp`, `srt`, `rtc`, `hls`, or a container +on stdin) sharing a connection with a capture encoder claims each of its +tracks' peak-hold bitrate on the connection's allocator, so the encoder +targets what is left of the uplink instead of the whole of it. Passthrough +tracks reserve and never follow: nobody here chose their bitrate, so a grant +is nothing they can act on. ## Plan -Use the public issue's scope, implementation notes, and acceptance criteria -below as the starting plan. Reconcile paths and assumptions with the current -tree before implementation. - -### Issue context - -Follow-up to [#2854](https://github.com/moq-dev/moq/pull/2854), which divides a connection's send estimate among the tracks sharing it. - -#### Problem - -Only tracks that *encode* register a reservation today: `moq-video` and `moq-audio`'s `publish_capture`. A passthrough import (`moq import rtmp`, `srt`, `hls`) republishes media that arrived already encoded, and those tracks are minted by the container importers, which register nothing. - -So a capture publish and an rtmp import sharing one connection are invisible to each other: the capture encoder targets the whole uplink while the rtmp stream independently consumes a chunk of it, and the encoder over-targets by exactly the rtmp stream's bitrate. It's [#2815](https://github.com/moq-dev/moq/issues/2815) one layer up, and [#2809](https://github.com/moq-dev/moq/pull/2809) is what makes running the two together routine. - -#### The wrinkle, and why it's resolvable - -A passthrough track has **no configured ceiling**. Nobody here chose its bitrate; the upstream encoder did. The only number available is measured, which normally fails the rule the allocator is built on: reserve the maximum a track can ever send, never what it happens to be sending. A VBR source sitting on a black screen at 1 Mbps can jump to 6 Mbps between one frame and the next, and a reservation that had followed it down would already have handed that room to somebody else. - -`moq_mux::catalog::Estimate::bitrate` is the exception. It is documented as *the maximum* bitrate, measured over a 1s window, so it's a peak-hold rather than an instantaneous rate, and it satisfies the ceiling rule as-is. The importers already feed it (`catalog::Estimator`), so the number is sitting there. - -#### What to build - -Register each passthrough track with its catalog-estimated bitrate, in the reserve-only mode `moq-audio` already uses: claim the budget, never follow the grant. A passthrough track can't be asked to back off, so its entry means "subtract this from everyone else's budget" rather than a ceiling anyone will respect. That's the same shape PCM audio needs, so no new mode. +Only encoders reserve today: the moq-video capture loop +(`rs/moq-video/src/encode/producer.rs:467-469`) and moq-audio's capture +driver (`rs/moq-audio/src/encode/capture.rs:471-474`). Passthrough tracks are +minted by `moq_mux::import::Track::{audio,video}` +(`rs/moq-mux/src/import/track.rs:195-202`, `:233-239`), which register +nothing, so a capture encoder over-targets by exactly the import's bitrate. +[#2809](https://github.com/moq-dev/moq/pull/2809) made running the two +together routine. + +### The number + +A passthrough track has no configured ceiling, and the allocator's rule is to +reserve a ceiling, never a measurement (`rs/moq-net/src/model/bandwidth.rs:213-218`). +`moq_mux::catalog::Estimate::bitrate` is the exception: it is the maximum +over 1 s windows, a peak-hold that only ever rises +(`rs/moq-mux/src/catalog/estimate.rs:6`, `:194-212`). Every container +`Producer` already keeps an `Estimator` (`rs/moq-mux/src/container/producer.rs:59`) +and the codec importers push it into the catalog rendition on every cut +(`rs/moq-mux/src/codec/legacy.rs:181-184`). + +Update cadence: the container `Producer` calls `Reservation::update` whenever +`estimate().bitrate` exceeds the ceiling it reserved, which happens at most +once per closed window and only upward. That is legitimate under +`Reservation::update`'s contract (`bandwidth.rs:320-325`): the ceiling +genuinely moved. A ratchet discovers a ceiling late; it never follows a VBR +source down and never hands room away when the picture goes still. Two known limits, neither fatal: -- **A peak-hold starts at zero.** Until the source's first peak, the reservation understates and a co-resident encoder over-targets by the difference. It converges within seconds, and it is strictly better than the reservation of zero these tracks hold today. -- **The estimate updates as the source's peak grows**, so the reservation ratchets up over the life of the stream and never down. That's the conservative direction, which is the right one here. - -Priority falls out of [#2854](https://github.com/moq-dev/moq/pull/2854): the importers now stamp `hang::catalog::PRIORITY` per kind, so an imported audio track already outranks an imported video one. +- The first window closes after 1 s of media, so until then the track claims + nothing and a co-resident encoder over-targets by the difference. Take the + reservation on the first estimate rather than reserving zero. +- The claim never shrinks over the life of the stream. That is the + conservative direction. + +### Plumbing + +`spawn_import` receives the connection's allocator and discards it without +the capture feature (`rs/moq-cli/src/main.rs:448`, `:451-454`). +`crate::moq::ImportTarget { origin, name, max_age }` +(`rs/moq-cli/src/moq.rs:15-26`, minted at `main.rs:465-469`) gains +`bandwidth: moq_net::bandwidth::Allocator`, and every importer takes it the +way it takes `max_age`: + +- rtmp and srt: `listen_import` / `connect_import` (`rs/moq-cli/src/rtmp.rs:47`, + `:113`; `rs/moq-cli/src/srt.rs:35`, `:99`) hand it to + `moq_rtmp`'s `Publish::accept` (`rs/moq-rtmp/src/server.rs:518-535`, into + `Publisher::new`) and `moq_srt`'s (`rs/moq-srt/src/server.rs:333-337`, + `serve_publish` `:414-418`) beside `with_max_age`. +- rtc: the same two entry points in `rs/moq-cli/src/rtc.rs:60`, `:88`. +- hls: `hls::import(origin, name, playlist, max_age)` + (`rs/moq-cli/src/hls.rs:45-50`) takes an `ImportTarget` instead of the + three loose fields and passes the allocator to + `moq_hls::import::Import::new` (`rs/moq-hls/src/import.rs:613`). +- stdin containers: `Publish::new(broadcast, &format, max_age)` + (`main.rs:476`, `rs/moq-cli/src/publish.rs:257`). + +Each lands in `moq_mux::container::Producer`, which owns the track and the +`Estimator`, so the reservation sits beside the number that drives it. The +allocator rides the same constructor path `max_age` does; where that reaches +four arguments, fold the options into a struct. + +Naming: `reserve` and `Reserved` in `rs/moq-mux/src/catalog/` are the catalog +gate that withholds the first snapshot (`rs/moq-mux/src/catalog/producer.rs:25`, +`:51`, `:360`; `rs/moq-mux/src/catalog/tracks.rs:283`). The bandwidth claim is +named explicitly, `bandwidth: Option` and +`with_bandwidth(allocator)`, never a bare `reserve`. + +Priority needs nothing: the importers stamp `hang::catalog::PRIORITY` per +kind (`track.rs:202`, `:239`; `rs/hang/src/catalog/priority.rs:21-26`), so an +imported audio track already outranks an imported video one. + +Tests: next to `writes_measure_the_catalog_estimate` +(`rs/moq-mux/src/container/producer.rs:433`), a container `Producer` with an +allocator claims nothing before the first window, reserves the first +estimate, raises the ceiling on a larger later window and holds it on a +smaller one; an allocator test where a passthrough want at its peak lowers a +co-resident encoder's grant by exactly that amount. + +Branch from dev. ## Closes - [#2859](https://github.com/moq-dev/moq/issues/2859) - close this issue when the quest finishes +## Related + +- [#2815](/quest/m1/2815-lift-adaptive-stage-refusal.md) - two capture stages sharing one allocator +- [#2709](/quest/m1/2709-per-broadcast-bandwidth-estimates-and-reservation.md) - the same allocator mirrored in js/net +- [Ladder](/quest/m2/ladder/README.md) - a transcode ladder dividing the same estimate diff --git a/quest/m1/2895-add-an-atomic-readiness-gate-for-origin-broadcasts.md b/quest/m1/2895-add-an-atomic-readiness-gate-for-origin-broadcasts.md deleted file mode 100644 index 977f0387ac..0000000000 --- a/quest/m1/2895-add-an-atomic-readiness-gate-for-origin-broadcasts.md +++ /dev/null @@ -1,59 +0,0 @@ -# [S] Add an atomic readiness gate for Origin broadcasts - -## Goal - -Implement and verify the behavior tracked in [#2895](https://github.com/moq-dev/moq/issues/2895) -within the issue's stated scope and boundaries. - -## Plan - -Use the public issue's scope, implementation notes, and acceptance criteria -below as the starting plan. Reconcile paths and assumptions with the current -tree before implementation. - -### After prefix routes - -Half of the stated problem is gone. -[moq#3225](https://github.com/moq-dev/moq/pull/3225) made -`origin::Producer::create_broadcast` stop announcing: the blessed order is -create, populate, announce, and `announce` is a separate call returning a guard. -So a broadcast is no longer announced before its tracks exist, and -`broadcast::Route::announce = false` (which the issue correctly says is not a -readiness gate) does not exist either. - -What remains is the exact-path half: `create_broadcast` still makes the source -visible to `request_broadcast` immediately, so a concurrent consumer can find a -broadcast whose tracks and `broadcast::Dynamic` handler are not installed yet. -Re-scope to that, and weigh it against the convention now being sufficient for -the announced path. - -### Issue context - -#### Problem - -`origin::Producer::create_broadcast` makes an eligible source visible by exact-path lookup, and may announce it, before the caller has installed all of its tracks or a `broadcast::Dynamic` handler. A concurrent consumer can therefore find the broadcast during a partially prepared state. - -`broadcast::Route::announce = false` is not a readiness gate. It suppresses announcement events, but the broadcast remains reachable through exact-path lookup. - -#### Goal - -Add an atomic readiness mechanism so callers can prepare a broadcast completely before it becomes discoverable by either announcements or exact-path lookup. - -The API should make the safe publication sequence clear and difficult to misuse. Decide whether readiness belongs in construction, a consuming terminal operation, or a small typestate/builder boundary before implementation. - -#### Scope - -- Define one atomic transition from hidden/preparing to visible. -- Gate both exact-path lookup and announcements on that transition. -- Cover local sources, replacements, and sources initially parked behind an incumbent. -- Specify what dropping an uncommitted source does. -- Add race-focused tests proving consumers never observe partial readiness. - -#### Relationship - -Related to #1073, but does not block it. #1073 deliberately preserves the current immediate-visibility behavior while making Origin lifecycle caller-driven. - -## Closes - -- [#2895](https://github.com/moq-dev/moq/issues/2895) - close this issue when the quest finishes - diff --git a/quest/m1/2924-moq-relay-tls-rotation-is-not-atomic-across-thread-per.md b/quest/m1/2924-moq-relay-tls-rotation-is-not-atomic-across-thread-per.md deleted file mode 100644 index bc218c8055..0000000000 --- a/quest/m1/2924-moq-relay-tls-rotation-is-not-atomic-across-thread-per.md +++ /dev/null @@ -1,50 +0,0 @@ -# [XL] moq-relay: TLS rotation is not atomic across thread-per-core QUIC workers - -## Goal - -Implement and verify the behavior tracked in [#2924](https://github.com/moq-dev/moq/issues/2924) -within the issue's stated scope and boundaries. - -## Plan - -Use the public issue's scope, implementation notes, and acceptance criteria -below as the starting plan. Reconcile paths and assumptions with the current -tree before implementation. - -### Issue context - -Follow-up from #2921 (M1 part 1 of #2875), which added `runtime.workers`. Documented there rather than fixed, because the fix needs an API `moq-tokio` does not have yet. - -#### Mechanism - -Each QUIC worker builds its own listener with `listen::Config::init`, so each independently: - -- loads the `listen.tls.cert` / `listen.tls.key` files, -- spawns its own `tls::reload_certs` watcher, -- snapshots its own mTLS client roots. - -`Workers` keeps the *first* worker's `Certificates` handle, and that is the one `/certificate.sha256` publishes. - -Three consequences: - -1. **Rotation is not atomic.** During a reload, workers can be serving different certificates. Both are valid, so TLS still completes, but the group is briefly inconsistent and the published fingerprint may match only some of them. -2. **A failed watcher diverges permanently.** `tls::reload_certs` logs and continues when it cannot watch; that worker then serves the old certificate indefinitely while its siblings rotate, and nothing surfaces the split. -3. **N redundant watchers** on the same files, one per worker. - -The mTLS roots have the same shape: `--listen-tls-root` is snapshotted per worker. - -#### Why it was not fixed in #2921 - -Injecting one shared, already-loaded identity into every worker needs `moq-tokio` to accept in-memory certificate material. `tls::Listen` takes paths (`cert: Vec`, `key`, `root`) and each backend loads them itself, so there is no way to hand N listeners one resolved, hot-reloadable identity today. - -\#2921 documents the divergence window instead of implying it is not there, and separately rejects `--listen-tls-generate` with workers, since that case is not merely inconsistent but actively broken (each worker would generate a *different* self-signed certificate while the fingerprint endpoint advertises one of them). - -#### Suggested direction - -Give `moq-tokio` a way to build a listener from resolved TLS material rather than paths, then have the relay load and watch once on the shared runtime and hand every worker the same handle. Rotations then apply to the group at once and `/certificate.sha256` is authoritative for every worker. That would also let `--listen-tls-generate` work with workers: generate once, share it. - -Reported by an adversarial review pass (Codex) on #2921. - -## Closes - -- [#2924](https://github.com/moq-dev/moq/issues/2924) - close this issue when the quest finishes diff --git a/quest/m1/2964-quic-workers-dropping-one-split-server-resizes-the.md b/quest/m1/2964-quic-workers-dropping-one-split-server-resizes-the.md deleted file mode 100644 index face6a94f4..0000000000 --- a/quest/m1/2964-quic-workers-dropping-one-split-server-resizes-the.md +++ /dev/null @@ -1,59 +0,0 @@ -# [M] QUIC workers: dropping one split() Server resizes the reuseport group - -## Goal - -Implement and verify the behavior tracked in [#2964](https://github.com/moq-dev/moq/issues/2964) -within the issue's stated scope and boundaries. - -## Plan - -Use the public issue's scope, implementation notes, and acceptance criteria -below as the starting plan. Reconcile paths and assumptions with the current -tree before implementation. - -One assumption has already moved: `Spawner::run` now takes a -`FnOnce() -> Future` builder, so the issue context's objection that a builder -is a banned callback no longer holds. What is left is who owns the servers, -which is the first option below. - -### Issue context - -Found in the second review round on #2921. The worker group is off by default and Linux-only, so this is not urgent, but it is the last unenforced part of the "bound once, in order, never resized" invariant that connection-ID steering rests on. - -#### Mechanism - -`moq_tokio::worker::Workers::split` returns `Vec<(Server, Spawner<'_>)>`. The `Spawner` borrows the group, so a caller cannot drop a worker's *thread* on its own. But `Server` owns the `quinn::Endpoint`, and therefore the socket, and `Server::listen(mut self)` consumes it - so the accept loop has to own it. - -That leaves two ways to take one socket out of the group: - -1. Drop a returned `Server` without running it. -2. Let the future built from one `Server` return while its siblings keep serving. - -Either way Linux moves the last socket in the reuseport array into the vacated slot. The cBPF filter still reduces modulo the original count, and connection IDs encoding the moved member now select an index past the end of the array, so the kernel falls back to hashing the 4-tuple. Live sessions on a worker that never failed get misrouted. - -`moq-relay` does the right thing today - `Relay::run` ends on the first worker to finish and then calls `Workers::shutdown` - so its exposure is a shutdown that was already happening. An embedder gets no such guarantee. - -#### Why it is not fixed in #2921 - -Making it unrepresentable means `Workers` owns the servers and drives them, so the caller passes something like `FnOnce(Server) -> impl Future` for the group to build each accept loop from. That is a callback parameter, which [CLAUDE.md](/CLAUDE.md) rules out of public APIs, and avoiding it is why the split/spawner shape exists at all. Worth a deliberate decision rather than a drive-by. - -#### Options - -- Accept a consumed `FnOnce(Server) -> Future` as a *builder* rather than a policy hook (no `Send + Sync + 'static` smuggling, no hidden timing), and have `run` wrap the future so the group stops when any member's future completes. -- Leave the contract documented (where it is now) and rely on callers. -- Fold it into the `SK_REUSEPORT` + `BPF_MAP_TYPE_REUSEPORT_SOCKARRAY` work from #2875. A map-based selector picks by slot rather than by position, so a member leaving stops being catastrophic and this whole class of problem goes away. Same place #2960 points. - -Related: #2960 (a restarting relay joining the old process's group), #2875 (the epic). - -## Closes - -- [#2964](https://github.com/moq-dev/moq/issues/2964) - close this issue when the quest finishes - -The socket ownership redesign must also cover `moq_sock::shard::Group` -formation: binding fewer than all declared members currently exposes usable -sockets before the steering filter is attached. Dropping an earlier socket -before the last bind also invalidates the recorded slot positions. Require -complete formation before serving, and retain every socket for the served -group lifetime. Add regressions for partial formation and early socket drop. -Both current runtime callers bind every member before returning the group; -this remains a limitation of the lower-level public API. diff --git a/quest/m1/2991-net-coalesce-dynamic-tracks-and-preserve-sequences-across.md b/quest/m1/2991-net-coalesce-dynamic-tracks-and-preserve-sequences-across.md deleted file mode 100644 index 34a7e01ead..0000000000 --- a/quest/m1/2991-net-coalesce-dynamic-tracks-and-preserve-sequences-across.md +++ /dev/null @@ -1,56 +0,0 @@ -# [M] net: coalesce dynamic tracks and preserve sequences across replacements - -## Goal - -Implement and verify the behavior tracked in [#2991](https://github.com/moq-dev/moq/issues/2991) -within the issue's stated scope and boundaries. - -## Plan - -Use the public issue's scope, implementation notes, and acceptance criteria -below as the starting plan. Reconcile paths and assumptions with the current -tree before implementation. - -### Issue context - -#### Problem - -Dynamic tracks should have one logical identity per broadcast and track name, but the Rust and JavaScript models currently violate different parts of that invariant. - -##### Rust resets sequences when a dynamic producer is replaced - -A closed dynamic track is removed from the broadcast's weak cache. The next subscription creates a fresh `track::Request`, and `Request::new` creates a fresh `TrackState`. Because `max_sequence` is empty, both `append_group` and `append_datagram` restart at sequence 0. - -That conflicts with the relay's logical track splicing. `resume::Producer::takeover` retains the previous live edge and starts a replacement at `latest + 1`. Groups from a restarted producer are therefore filtered until its counter catches up, causing the same playback stall fixed for JavaScript in #2953. - -The existing `test_linger_reconnect_splices` avoids the reset by explicitly creating replacement group 2 after the old producer emitted groups 0 and 1. Using `append_group()` there would create group 0 and leave the subscriber stalled. Explicit group or datagram writes can raise the old producer's shared sequence edge further, making the catch-up window longer. - -##### JavaScript permits concurrent same-name dynamic producers - -`BroadcastProducer.subscribe()` calls the internal subscribe path with `register = false`. Multiple publishing-side subscriptions for the same name therefore enqueue independent requests and create independent `track.Producer` instances. - -\#2953 made those concurrent producers share a sequence allocator. That prevents duplicate sequence allocation, but concurrent producers are the wrong model. Publishing-side subscriptions should coalesce like `BroadcastConsumer.subscribe()` and Rust's `broadcast::Consumer::track`: one pending or live producer per broadcast and name, one on-demand request, and multiple subscribers fanning out from it. - -#### Desired behavior - -- A broadcast has at most one pending or live dynamic track producer per track name. -- Concurrent JavaScript publishing-side subscriptions for the same name emit one request and share its accepted producer. -- Subscription options from all subscribers remain aggregated on that request. -- After that producer closes, a later request creates a new producer but continues the group/datagram sequence namespace for that broadcast and name. -- Explicit group and datagram writes advance the shared allocator. -- A new broadcast generation starts each track at sequence 0. -- Rust and JavaScript expose the same lifecycle and sequencing behavior. - -The sequence allocator should be shared across producer incarnations without sharing the closed producer's cache or terminal state. - -#### Regression coverage - -- JavaScript: two `BroadcastProducer.subscribe()` calls for one name produce one request and both subscribers receive from the accepted producer. -- JavaScript: remove or replace #2953's concurrent-producer test, since concurrent same-name producers should not be representable. -- Rust and JavaScript: close a dynamic producer after group/datagram sequences have advanced, re-request the same name, and verify the replacement appends at the next sequence. -- Rust relay model: keep a logical subscriber live across replacement and verify the first replacement group is delivered immediately rather than filtered until catch-up. -- Both implementations: verify a separate broadcast generation starts at 0. - -## Closes - -- [#2991](https://github.com/moq-dev/moq/issues/2991) - close this issue when the quest finishes diff --git a/quest/m1/3046-fold-moq-token-into-moq-token-via-a-usage-executable-view.md b/quest/m1/3046-fold-moq-token-into-moq-token-via-a-usage-executable-view.md index 1ca85f08f6..9f0afd20ad 100644 --- a/quest/m1/3046-fold-moq-token-into-moq-token-via-a-usage-executable-view.md +++ b/quest/m1/3046-fold-moq-token-into-moq-token-via-a-usage-executable-view.md @@ -1,50 +1,40 @@ -# [M] Fold moq-token into moq token via a Usage executable view +# [M] Retire the standalone moq-token binary ## Goal -Implement and verify the behavior tracked in [#3046](https://github.com/moq-dev/moq/issues/3046) -within the issue's stated scope and boundaries. +`moq token` is the only spelling of the token CLI: one binary, one help tree, +one completion tree, one release artifact. After one deprecation release the +`moq-token-cli` crate, its release workflow, and its packaging are deleted. +The `moq-token` library is untouched. ## Plan -Use the public issue's scope, implementation notes, and acceptance criteria -below as the starting plan. Reconcile paths and assumptions with the current -tree before implementation. - -### Issue context - -#### Summary - -Fold the standalone `moq-token` binary into `moq token`, so there is one binary and one command surface instead of two build artifacts sharing a library. - -#### Where we are - -`moq-token-cli` is a lib+bin. The command surface lives in `moq_token_cli::Args`, which the standalone `moq-token` binary wraps in its own `Root` spec root and `moq-cli` nests under `moq token`. The implementation is genuinely shared, so this is not about duplicated logic. What is duplicated is the *surface*: two spec roots, two help renderings, two completion trees, two release artifacts, and two places for a flag to drift. - -#### Why now - -Now that #3030 has landed on `dev`, Usage has the mechanism for exactly this: an **executable view**. A view is argv0 dispatch against a single binary's own spec, so one binary can present a different root depending on the name it was invoked under. `moq-token generate ...` and `moq token generate ...` become one spec with two surfaces, and help renders the right prefix for each (`page_view` / `render_failure_view` already exist for this). - -The blocker today is not the CLI plumbing, it is that they are separate build artifacts. Views cannot span two binaries. - -#### Sketch - -- `moq-cli` declares `#[usage(view("moq-token", bin = "moq-token", root = "token"))]`. -- Ship `moq-token` as a symlink, hardlink, or a thin renamed copy of `moq`, rather than as its own compiled binary. -- Drop `moq-token-cli`'s `Root` struct; the crate keeps exporting `Args` for `moq-cli` to nest. -- `moq-token` (the library) is unaffected -- it stays free of CLI concerns either way. - -#### Things to decide - -- **Packaging.** `moq-token` is currently released as its own artifact with its own version. A symlink changes what the release workflow produces and what a package manager installs. This is the bulk of the work. -- **Binary size.** `moq-token` today is a small binary; making it an alias of `moq` means anyone who wants only token tooling pulls the full media router. -- **Whether the standalone name survives at all**, or whether `moq token` simply becomes the only spelling after a deprecation period. That is the simpler end state if nobody depends on the separate binary. - -The second point may be the one that kills it. Worth measuring `moq` vs `moq-token` stripped sizes before committing. - -#### Depends on - -\#3030 (Usage migration), merged to `dev` on 2026-08-26, which is why this branches from `dev`. +`moq-token-cli` is a lib+bin. Its `Args` is nested by `moq-cli` as `moq token` +(rs/moq-cli/src/main.rs:197-199, the only consumer, rs/moq-cli/Cargo.toml:101) +and wrapped by the standalone binary's own `Root` (rs/moq-token-cli/src/lib.rs: +12-22). The logic is shared; the surface is duplicated: two spec roots, two +help renderings, two completion trees, two release artifacts, and two places +for a flag to drift. The docs already point at `moq token` (#3557). + +usage-rs 6.3.0 can dispatch a second root off argv0 (executable views, +`executable_views_emit_and_dispatch_from_argv0` in its tests/facade.rs), so +`moq-token` could survive as a renamed copy of `moq`. That keeps a name nobody +depends on and makes it an alias of the full media router, so the binary goes +instead. + +1. Deprecation release: `moq-token` prints a notice naming `moq token` on + every run and keeps working. Ship it as an ordinary `moq-token-cli` patch. +2. Delete the crate. Move `Args` and its subcommands into `moq-cli`, and + remove `.github/workflows/moq-token-cli.yml`, + `packaging/moq-token-cli/nfpm.yaml`, + `.github/homebrew/Formula/moq-token-cli.rb.tmpl`, and the + `moq-token-cli` triggers and entries in `.github/workflows/docker.yml:8`, + `cachix.yml:7,47`, `alert.yml:37`, `release-brew.yml:22,152`, and + `release-winget.yml:28,92`. +3. Grep the repo for `moq-token ` invocations and `moq-token-cli`, and fix + every remaining doc, demo recipe, and install page. + +Branch from `dev`, where the CLI lives on `usage`. ## Closes diff --git a/quest/m1/3056-watch-video-decoder-captures-the-rewind-generation-at.md b/quest/m1/3056-watch-video-decoder-captures-the-rewind-generation-at.md deleted file mode 100644 index 87d8b1a064..0000000000 --- a/quest/m1/3056-watch-video-decoder-captures-the-rewind-generation-at.md +++ /dev/null @@ -1,66 +0,0 @@ -# [S] watch: the video decoder resets on a declared discontinuity - -## Goal - -A frame submitted before a declared discontinuity never surfaces after it. -The decoder is reset (and reconfigured) when the container consumer reports a -discontinuity, the way the audio decoder already does, so queued pictures from -the old epoch are discarded rather than parked against the new clock. - -## Plan - -Rescoped in the 2026-09 planning pass: undeclared rewinds are going away -([Monotonic timeline](/quest/m1/monotonic-timeline.md)), so the generation -mismatch below no longer produces the sixty-second park, but the bug it -describes still applies to a declared discontinuity, where a stale frame must -not be presented at all. Fix it the first way the issue suggests: call -`decoder.reset()` and re-`configure()` in `#onDiscontinuity`, so queued chunks -never surface. Keep the post-await generation guard as well: `reset()` cannot -cancel an output callback that already holds a frame and is parked in -`#park()` or `sync.wait()`, and `sync.reset()` releases exactly that wait, so -the guard is what stops a stale callback writing `timestamp` and `frame` -after the reset. The -regression needs a real WebCodecs decoder, so it lives in a browser harness -rather than a bun unit test. - -## Required - -- [Monotonic timeline](/quest/m1/monotonic-timeline.md) - settles what a discontinuity is before the decoder's reaction to one is pinned - -### Issue context - -Found during the review of #3048. Pre-existing on `main`; that PR does not introduce it. - -#### The bug - -`DecoderTrack`'s WebCodecs output callback guards against rewinds with a generation counter: - -```ts -output: async (frame) => { - const generation = this.#discontinuity; // read when the frame comes OUT -``` - -The counter is read when the frame is *decoded*, not when its chunk was *submitted*. A frame submitted before a rewind but decoded after it therefore reads the already-bumped value, so the later `generation !== this.#discontinuity` check compares the new value against itself and passes. - -The guard only catches frames that were already inside the callback and parked in `#park` when the rewind landed, which is why it sits after the await. Frames still queued inside the `VideoDecoder` are exactly the ones it misses. - -`#onDiscontinuity` clears `timestamp`, clears the buffered ranges, and calls `sync.reset()`, but never calls `decoder.reset()`, so the queued chunks keep decoding. - -#### Consequence - -A stale frame that survives the guard parks against the re-anchored clock for the full distance between the two timelines. With the old timeline at 60s and the rewind restarting at 0, `sync.wait()` computes a sleep of roughly 60s for it. It then paints long after it is meaningless, or gets released early by an unrelated change to the pacing input. - -#### Possible fixes - -- Call `decoder.reset()` (and re-`configure()`) in `#onDiscontinuity`, which is what the audio decoder already does for its own discontinuities. WebCodecs `reset()` discards queued outputs, so the stale frames never surface. -- Or associate each submitted chunk with the generation it was submitted under and reject mismatches on output. - -Either wants a regression test where a pre-rewind output arrives after the reset. That needs a real WebCodecs `VideoDecoder`, which bun's test environment does not provide, so it likely belongs in `test/wasm/` or another browser harness rather than a unit test. - -#### Note - -The maintainer intends to remove rewind support, in which case deleting the path is the simpler resolution than fixing the generation plumbing. Filing it so the behavior is recorded either way. - -## Closes - -- [#3056](https://github.com/moq-dev/moq/issues/3056) - close this issue when the quest finishes diff --git a/quest/m1/3060-moq-net-ban-hop-id-0-from-hop-chains.md b/quest/m1/3060-moq-net-ban-hop-id-0-from-hop-chains.md index 4f9a950be3..2dea9332b2 100644 --- a/quest/m1/3060-moq-net-ban-hop-id-0-from-hop-chains.md +++ b/quest/m1/3060-moq-net-ban-hop-id-0-from-hop-chains.md @@ -2,60 +2,119 @@ ## Goal -Implement and verify the behavior tracked in [#3060](https://github.com/moq-dev/moq/issues/3060) -within the issue's stated scope and boundaries. +A hop chain names real hops only. `Hop::UNKNOWN` (Hop ID 0) stays the absence +marker in the fields that need one (`AnnounceInterest.exclude_hop`, +`AnnounceOk.origin`, `RELAY_HOPS`), but a chain entry that names nobody is a +decode-time PROTOCOL_VIOLATION on both wire dialects, so nothing has to +filter, loop-detect, or substitute around it. ## Plan -Use the public issue's scope, implementation notes, and acceptance criteria -below as the starting plan. Reconcile paths and assumptions with the current -tree before implementation. - -### Issue context - -`Origin::UNKNOWN` (Hop ID 0) is documented as "no identity", but it is currently legal *inside a hop chain*, and that is where every problem with it comes from. A chain entry that names nobody cannot be filtered on, cannot be loop-detected, and cannot be told apart from another entry that also names nobody. Ban it from chains and those problems stop being cases to handle. - -0 stays reserved and keeps its job as the absence marker in the fields that need one (`AnnounceInterest.exclude_hop`, `AnnounceOk.origin`, `RELAY_HOPS`). That reading is unambiguous precisely because no endpoint may adopt 0 as its identity, which `Origin::new` already enforces. What changes is that a chain names real hops only. - -#### What this closes - -moq-dev/moq#3053: a peer that declares 0 and sends its own HOP\_PATH cannot be filtered on the identity we assigned it. Substituting the assigned id into the chain was implemented during moq-dev/moq#3042 and reverted after producing four defects in four review rounds. With 0 illegal in a chain, a peer with no identity has nothing legal to write as its terminal entry, so that advertisement is a decode-time PROTOCOL\_VIOLATION rather than something to substitute into. The four defects were: - -1. Only the terminal entry may be substituted; earlier zeros belong to upstreams the receiver never spoke to. -2. Substituting can construct an invalid chain: `[X, 0]` from a peer assigned `X` becomes `[X, X]`, a PROTOCOL\_VIOLATION for whoever we forward it to. -3. The assigned identity must not be tested against a peer that declared one, or its ordinary traffic is discarded as a loop. -4. The check and the substitution must be gated together, or a peer assigned `Y` that sent `[Y, 0]` bypasses the check and gets rewritten anyway. - -Each was a new conditional on the same decision. None of them exist if the chain cannot carry 0. - -It also removes the privacy question substitution raised: an assigned identity is indistinguishable on the wire from a declared one, so writing it into a forwarded chain publishes our private name for a peer that asked not to be named. - -#### Scope - -**Both wire dialects.** A non-zero Hop ID appearing twice is already a PROTOCOL\_VIOLATION in `draft-lcurley-moq-cluster`; moq-dev/moq#3049 mirrors that into `draft-lcurley-moq-lite` and moves it into `OriginList` so it holds at construction rather than only at decode. This issue is the next step on the same rule: `HopPath::validate` drops the duplicate-zeros exemption, and a zero entry becomes invalid on its own. - -**moq-lite-01/02/03.** These carry no real Hop IDs. Lite-03 sends a bare hop count that `announce.rs` expands into that many `UNKNOWN` placeholders, which is the only remaining source of zeros in a chain, and lite-01/02 send nothing at all. They stay supported: the count is read as the **route cost** instead, which is what it was for before it was also made to carry loop prevention, and which the lite draft already equates to it (`An absent parameter means the default cost of 1, under which the accumulated Route Cost equals the hop count`). - -That means the loop bound has to come from the cost, since the count no longer tracks chain length: - -- charge the configured link cost, with a **mandatory floor of 1** on lite-01/02/03 links. A link priced 0 is a supported config (two relays in one datacenter) and would otherwise stop the value growing, leaving the loop unbounded. -- reject a received value above `MAX_HOPS`, reproducing today's 32-hop ceiling. -- emit the accumulated cost where `encode_hops` currently emits `hops.len()`. - -Behavior change worth calling out: a lite-03 route's stored chain drops from N entries to one (`[assigned_upstream_id]`), so cost carries the distance and hop length stops standing in for it. That shifts how lite-03 routes rank against others, and the tie-break key in `model/origin.rs` is computed over the chain. - -**Drafts.** `draft-lcurley-moq-cluster` section "The Reserved Hop ID 0" is deleted rather than amended. The rules it anchors change with it: HOP\_PATH validity becomes "no Hop ID appears twice" with no exemption, "an advertisement whose first entry is 0 has an unknown origin" goes away because every first entry now names a real publisher, and "Assigned Identities" shifts from MAY to mandatory, since a receiver has no way to spell an unnamed upstream in a chain. `RELAY_HOPS` keeps meaning "no identity" when it carries 0; what a peer may no longer do is put 0 in a HOP\_PATH. `draft-lcurley-moq-lite` gets the matching chain rule alongside the Hop Count rule. - -The section's honest summary today, `Declaring 0 therefore trades loop detection and failover for anonymity`, becomes false rather than merely narrower: a peer that declares 0 is assigned an identity and filtered on it, and gets no anonymity from the chain because it can no longer write into one. - -#### Done when - -Every `== Origin::UNKNOWN` / `!= Origin::UNKNOWN` test that exists to ask "does this chain entry name anybody" is gone, across `lite/subscriber.rs`, `ietf/{subscriber,publisher,cluster}.rs`, and `model/{origin,broadcast}.rs`. The marker survives only where it means "this field is absent". Any survivor in the first category means 0 is still special somewhere and the change is incomplete. - -Targets `dev`: it changes published `moq-net` API. The outbound validation it -builds on landed in #3066, where `Hops::push` enforces the duplicate rule -wherever a chain is built. +`Hop::UNKNOWN` (rs/moq-net/src/model/origin.rs:35, re-exported at +model/mod.rs:49) is documented as "no identity" but is legal inside a hop +chain, and that is where every problem with it comes from. A chain entry that +names nobody cannot be filtered on, cannot be loop-detected, and cannot be +told apart from another entry that also names nobody. 0 keeps its job as the +absence marker because no endpoint may adopt it as an identity, which +`Hop::new` already enforces. What changes is that a chain names real hops +only. + +### What this supersedes + +#3053 (closed): a peer that declares 0 and sends its own HOP_PATH cannot be +filtered on the identity we assigned it. Substituting the assigned id into +the chain was implemented during #3042 and reverted after producing four +defects in four review rounds: + +1. Only the terminal entry may be substituted; earlier zeros belong to + upstreams the receiver never spoke to. +2. Substituting can construct an invalid chain: `[X, 0]` from a peer assigned + `X` becomes `[X, X]`, a PROTOCOL_VIOLATION for whoever we forward it to. +3. The assigned identity must not be tested against a peer that declared + one, or its ordinary traffic is discarded as a loop. +4. The check and the substitution must be gated together, or a peer assigned + `Y` that sent `[Y, 0]` bypasses the check and gets rewritten anyway. + +Each was a new conditional on the same decision. None of them exist if the +chain cannot carry 0. Substitution also raised a privacy question: an +assigned identity is indistinguishable on the wire from a declared one, so +writing it into a forwarded chain publishes our private name for a peer that +asked not to be named. One such substitution still ships: lite/subscriber.rs: +296-307 rewrites the first `Hop::UNKNOWN` placeholder with `session_origin` +through `replace_first`, and the chain that leaves it no longer records that +the entry was assigned rather than declared. + +### Scope + +**Both wire dialects.** A non-zero Hop ID appearing twice is already a +PROTOCOL_VIOLATION on both; #3066 moved the rule into `Hops` so it holds at +construction rather than only at decode. The zero exemption lives there too: +`Hops::push` (model/origin.rs:277) and `TryFrom>` (:340-345) skip +the duplicate check for `Hop::UNKNOWN`. Drop the exemption and refuse a zero +entry outright. `HopPath::validate` (ietf/cluster.rs:88-93) only rejects an +empty list and stays as it is. + +**moq-lite-01/02/03.** These carry no real Hop IDs. Lite-03 sends a bare hop +count that lite/announce.rs:195 expands into that many `Hop::UNKNOWN` +placeholders, the only remaining source of zeros in a chain; lite-01/02 send +nothing at all. They stay supported: the count is read as the route cost +instead, which is what it was for before it was also made to carry loop +prevention, and which the lite draft already equates to it: "the accumulated +Route Costs equal the hop count and routing degenerates to shortest-path" +(drafts/draft-lcurley-moq-lite.md:789). Lite-06 carries a Warm and a Cold +Route Cost (:859-862, :888-889), so the lite-03 count feeds both halves +through `Cost::new` (model/origin.rs:449). + +The loop bound then has to come from the cost, since the count no longer +tracks chain length: + +- charge the configured link cost, with a mandatory floor of 1 on + lite-01/02/03 links. A link priced 0 is a supported config (two relays in + one datacenter) and would otherwise stop the value growing, leaving the + loop unbounded. +- reject a received value above `MAX_HOPS` (model/origin.rs:211), reproducing + today's 32-hop ceiling. +- emit the accumulated cost where `encode_hops` (lite/announce.rs:224) + currently emits `hops.len()`. + +Behavior change worth calling out: a lite-03 route's stored chain drops from +N entries to one (`[assigned_upstream_id]`), so cost carries the distance and +hop length stops standing in for it. That shifts how lite-03 routes rank +against others: `route_order` (model/origin.rs:633-639) ranks cost first, +then chain length, then a hash over the chain. + +**Drafts.** `draft-lcurley-moq-cluster` section "The Reserved Hop ID 0" +(drafts/draft-lcurley-moq-cluster.md:108) is deleted rather than amended. The +rules it anchors change with it: HOP_PATH validity becomes "no Hop ID appears +twice" with no exemption, "an advertisement whose first entry is 0 has an +unknown origin" goes away because every first entry now names a real +publisher, and "Assigned Identities" (:121) shifts from MAY to mandatory, +since a receiver has no way to spell an unnamed upstream in a chain. +`RELAY_HOPS` keeps meaning "no identity" when it carries 0; what a peer may no +longer do is put 0 in a HOP_PATH. `draft-lcurley-moq-lite` gets the matching +chain rule alongside the Hop Count rule and drops "Duplicate values of 0 are +not a violation" (draft-lcurley-moq-lite.md:886). + +The cluster section's summary, "Declaring 0 therefore trades loop detection +and failover for anonymity", becomes false rather than merely narrower: a +peer that declares 0 is assigned an identity and filtered on it, and gets no +anonymity from the chain because it can no longer write into one. + +### Done when + +Every `== Hop::UNKNOWN` / `!= Hop::UNKNOWN` test that exists to ask "does this +chain entry name anybody" is gone, across lite/subscriber.rs, +lite/publisher.rs:612, ietf/subscriber.rs, ietf/publisher.rs, +ietf/cluster.rs, and model/origin.rs. The marker survives only where it means +"this field is absent" (server.rs:404 is one). Any survivor in the first +category means 0 is still special somewhere and the change is incomplete. + +Targets `dev`: it changes published `moq-net` API. ## Closes - [#3060](https://github.com/moq-dev/moq/issues/3060) - close this issue when the quest finishes + +## Related + +- [#3053](https://github.com/moq-dev/moq/issues/3053) - closed; the substitution approach this supersedes diff --git a/quest/m1/3126-moq-bench-every-readme-example-fails-to-parse-and.md b/quest/m1/3126-moq-bench-every-readme-example-fails-to-parse-and.md deleted file mode 100644 index e4395d4479..0000000000 --- a/quest/m1/3126-moq-bench-every-readme-example-fails-to-parse-and.md +++ /dev/null @@ -1,48 +0,0 @@ -# [M] moq-bench: every README example fails to parse, and cumulative latency percentiles cannot be windowed to steady state - -## Goal - -Implement and verify the behavior tracked in [#3126](https://github.com/moq-dev/moq/issues/3126) -within the issue's stated scope and boundaries. - -## Plan - -Rescoped during the 2026-08 grooming: the README half is fixed on dev by the -usage migration. What remains: per-interval latency percentiles or histograms -so the startup ramp can be excluded from steady-state numbers. - -### Issue context - -Two smaller things that got in the way of running a relay comparison with `moq-bench` (`dev` @ `fc57e0175`). - -#### The README's flag names do not exist - -`rs/moq-bench/README.md` documents the target flag as `--client-connect` throughout: - -```bash -moq-bench --client-connect https://relay.example.com - -moq-bench --file rs/moq-bench/config/hd.toml \ - --client-connect https://relay.example.com \ - --connections 500 -``` - -The binary takes `--connect`; `--client-connect` is not accepted. The client TLS flags in the same section are `--connect-tls-*` (e.g. `--connect-tls-insecure`), not `--client-tls-*`. `--file` is also wrong: the config file is a positional argument, so `moq-bench --file foo.toml` fails with `unexpected argument '--file' found`, matching `moq-relay`'s positional `[FILE]`. - -Every example invocation in the README currently fails to parse. - -#### Latency percentiles are cumulative, so a steady-state window cannot be measured - -The JSONL that `--output` writes carries the counters as cumulative and monotonic, which is documented and is exactly right - a consumer diffs successive lines to get rates. But `latency_p50_ms` / `p90` / `p99` / `max` are cumulative too: they summarize the whole run to date, and there is no way to recover the distribution for a window from two lines. - -That matters because the intended methodology is to skip the ramp: - -> Run the load from one machine and the sampler on the relay's host, then join the two JSONL files on `timestamp_ms` over the same steady-state window. Skip the `--startup` window while connections ramp. - -You can do that for every rate in the file, but not for latency: the `--startup` ramp's samples are permanently baked into the percentiles. In practice this shows up as a p99 that is pure ramp artifact - I was seeing `latency_p99_ms` of 589-826 ms on runs whose steady-state p50/p90 were 1-2 ms, purely because a handful of connections' first groups landed while the swarm was still connecting. - -Emitting the per-interval histogram (or resettable per-interval percentiles alongside the cumulative ones) would make latency as windowable as the counters already are. `latency_samples` is already per-line, so the shape is half there. - -## Closes - -- [#3126](https://github.com/moq-dev/moq/issues/3126) - close this issue when the quest finishes diff --git a/quest/m1/3187-preserve-structured-protocol-error-codes-across-ffi-and-c.md b/quest/m1/3187-preserve-structured-protocol-error-codes-across-ffi-and-c.md index 08ba4d6444..3444181860 100644 --- a/quest/m1/3187-preserve-structured-protocol-error-codes-across-ffi-and-c.md +++ b/quest/m1/3187-preserve-structured-protocol-error-codes-across-ffi-and-c.md @@ -1,50 +1,36 @@ -# [M] Preserve structured protocol error codes across FFI and C bindings +# [L] Preserve structured protocol error codes across FFI and C bindings ## Goal -Implement and verify the behavior tracked in [#3187](https://github.com/moq-dev/moq/issues/3187) -within the issue's stated scope and boundaries. +Every binding (Python, Swift, Kotlin, Go, Dart, C) can read the exact session +or stream code a peer sent, tell session scope from stream scope, match a known +kind, and keep an application or unknown code without loss. Transport and +internal failures stay separate from protocol failures. ## Plan -Use the public issue's scope, implementation notes, and acceptance criteria -below as the starting plan. Reconcile paths and assumptions with the current -tree before implementation. - -### Issue context - -#### Problem - -Rust preserves structured session and stream failures, including known protocol variants, application codes, and unknown future codes: - -- [`SessionError::App(u16)` and `Unknown(u32)`](https://github.com/moq-dev/moq/blob/7494084aaf7e2fa6abe553ac83101ac4ef19f33a/rs/moq-net/src/error.rs#L60-L108) -- [TypeScript session and stream code types](https://github.com/moq-dev/moq/blob/7494084aaf7e2fa6abe553ac83101ac4ef19f33a/js/net/src/error.ts#L10-L145) - -The UniFFI boundary flattens every `moq_net::Error` into the broad `MoqError::Protocol` variant: - -- [FFI error mapping](https://github.com/moq-dev/moq/blob/7494084aaf7e2fa6abe553ac83101ac4ef19f33a/rs/moq-ffi/src/error.rs#L1-L8) - -The C facade similarly reports a broad status with textual detail. Python, Swift, Kotlin, Go, and C callers can send application error codes through abort and cancel APIs, but cannot reliably inspect a received code. This prevents applications from implementing protocol-defined recovery or policy. - -#### Proposed direction - -Expose a portable structured protocol error shape that preserves: - -- session versus stream scope -- the exact numeric code -- a known semantic kind when recognized -- unknown future codes without lossy conversion -- a human-readable message for diagnostics - -Keep transport and internal failures separate from protocol failures. C should expose equivalent getters or an output record rather than requiring callers to parse `moq_error()`. - -#### Acceptance criteria - -- Every binding can recover the exact received session or stream code. -- Application-defined and unknown codes round-trip without loss. -- Callers can still match broad error categories ergonomically. -- Cross-language tests cover a known code, an application code, and an unknown code. -- The public shape is designed on `dev` before the next binding compatibility release. +Rust keeps the structure: `SessionError::App(u16)` and `Unknown(u32)` +(`rs/moq-net/src/error.rs:60-108`). The UniFFI boundary throws it away: +`MoqError` is `#[uniffi(flat_error)]` (`rs/moq-ffi/src/error.rs:2-7`), so every +`moq_net::Error` reaches a binding as `Protocol` plus a message. The C facade +does the same through `libmoq::Error::Moq` (`rs/libmoq/src/error.rs:9-21`) and +the thread-local `moq_error()` string (`rs/libmoq/src/api.rs:736`). Callers can +send application codes through abort and cancel but cannot inspect a received +one, so protocol-defined recovery or policy is impossible outside Rust. + +- Remove `flat_error` and export a structured error record: scope (session or + stream), the numeric code, the known kind when recognized, and the message. + Keep the broad categories matchable. +- `to_code` is deliberately not injective (`error.rs:89-96`: a received 32-63 + decodes as `Unknown`), so the record carries the received code verbatim + rather than re-deriving it from the kind. +- C gets getters or an output record; parsing `moq_error()` is not the API. +- Cross-language tests cover a known code, an application code, and an unknown + code. + +Breaking on the binding surface, so it lands on dev. Sized [L]: a `moq-ffi` +shape change walks the whole cross-package sync table (`rs/libmoq`, `py`, +`swift`, `kt`, `dart`, `go/wrapper`, and `doc/lib`). ## Closes diff --git a/quest/m1/3190-align-origin-broadcast-creation-naming-across-language.md b/quest/m1/3190-align-origin-broadcast-creation-naming-across-language.md index 5789e36f2b..2b801677d6 100644 --- a/quest/m1/3190-align-origin-broadcast-creation-naming-across-language.md +++ b/quest/m1/3190-align-origin-broadcast-creation-naming-across-language.md @@ -1,53 +1,82 @@ -# [M] Bindings: create_broadcast, announce, and dynamic mean the same thing everywhere +# [L] Bindings: create_broadcast, announce, and dynamic mean the same thing in every binding ## Goal Every native binding exposes the announce surface the Rust model settled on, with one meaning per name: -- `create_broadcast` (Python, Rust) / `createBroadcast` (Swift, Kotlin) / +- `create_broadcast` (Python, Rust) / `createBroadcast` (Swift, Kotlin, Dart) / `CreateBroadcast` (Go) / `moq_origin_create_broadcast` (C) creates an unadvertised broadcast. - `announce(route)` and `unannounce()` on the broadcast producer advertise and retract its exact path; announcing again re-prices the route in place. -- `dynamic(prefix, route)` returns the handle that advertises the prefix and - serves the requests beneath it. +- `dynamic(pattern, route)` returns the handle that advertises a path pattern + and serves the requests beneath it. The pattern is a string in the + `moq_net::path::Pattern` dialect; a prefix is spelled `foo/**`. -No binding announces on the caller's behalf any more. +No binding announces on the caller's behalf. Every binding's docs state the +same order: create, `dynamic()` for tracks served on demand, populate, then +announce. ## Plan Today `rs/moq-ffi`'s `create_broadcast` calls `broadcast.announce(..)` -internally, so Python, Swift, Kotlin, Go, and C all inherit an auto-announce -that Rust does not have. `MoqOriginProducer::announce(prefix, route)` and -`MoqOriginProducer::dynamic()` both wrap an `origin::Dynamic`, but only the -latter exposes a request queue: the former forwards its requests into it, and -rejects them while no handler is alive. Two handles share one queue that one of -them owns; that is what the rename must not paper over. +internally (`rs/moq-ffi/src/origin.rs:467-473`), so Python, Swift, Kotlin, Go, +Dart, and C all inherit an auto-announce that Rust does not have. +`MoqOriginProducer::announce(prefix, route)` (`:481`) and +`MoqOriginProducer::dynamic()` (`:429`, no arguments: it advertises the whole +origin through `self.inner.dynamic("", ..)`) both wrap an `origin::Dynamic`, +but only the latter exposes a request queue: the former forwards its requests +into it, and rejects them while no handler is alive. Two handles share one +queue that one of them owns; that is what the rename must not paper over. + +The Rust model's `origin::Producer::dynamic(prefix: impl Into, route)` +(`rs/moq-net/src/model/origin.rs:1366`) changes to take a +`moq_net::path::Pattern` (`rs/moq-net/src/path/pattern.rs:172`), so the +announce API breaks once. Until [Advertise](/quest/m1/wildcard/advertise.md) +lands, anything but a prefix-shaped pattern (literal segments then `**`) is +refused; the bindings take the pattern as a string and inherit that refusal. + +The convention this documents is what closes #2895. A subscribe by exact path +before the broadcast's tracks exist gets `NotFound` at once +(`rs/moq-net/src/model/broadcast.rs:876-879`) unless a `dynamic()` handler is +alive to park it (`rs/moq-net/src/model/requests.rs:57-60`); announcing only +makes a path discoverable, never reachable (`broadcast.rs:240-242`). So a +publisher creates, attaches `dynamic()` if tracks are served on demand, +populates the catalog, and announces last. - moq-ffi: `MoqOriginProducer::create_broadcast(path)` stops announcing. - `MoqBroadcastProducer::set_announce(bool)` becomes `announce(route: MoqRoute)` - plus `unannounce()`. `MoqOriginProducer::announce` and `MoqOriginDynamic` merge - into `dynamic(prefix, route) -> MoqOriginDynamic`, which keeps `update(route)`, - `requested_broadcast()` (yielding `MoqBroadcastRequest` with accept and - reject), and `cancel()`; `MoqAnnounce` is deleted. + `MoqBroadcastProducer::set_announce(bool)` (`rs/moq-ffi/src/producer.rs:244`) + becomes `announce(route: MoqRoute)` plus `unannounce()`. + `MoqOriginProducer::announce` and `MoqOriginProducer::dynamic()` merge into + `dynamic(pattern: String, route) -> MoqOriginDynamic` (`:84`), which keeps + `update(route)`, `requested_broadcast()` (yielding `MoqBroadcastRequest` + with accept and reject), and `cancel()`; `MoqAnnounce` (`:325`) is deleted. - libmoq: hard rename `moq_origin_publish` to `moq_origin_create_broadcast` with no alias, `moq_publish_set_announce` replaced by `moq_publish_announce(route)` and `moq_publish_unannounce`, and - dynamic/request accessors mirroring the FFI. Regenerate `moq.h`; update - `cpp/obs/src` and the `cpp/obs/test` stub that declares the old symbol. -- Wrappers: `py/moq-rs`, `swift`, `kt`, and `go/wrapper` (flat on `dev`) - adopt the three verbs and drop any create-and-announce convenience. Swift - and Python default the route (`announce(route: .init())`, `announce(route= - Route())`), matching their labeled-argument idiom. `dart/` exists only on - `main`, so it has its own quest gated on the merge. -- Docs: `doc/lib/{py,swift,kt,go,c}`, including the `doc/lib/py/moq-rs.md` - sentences that say `create_broadcast` creates an announced broadcast and pair - `announce("live/")` with a prefix-less `dynamic()`. + dynamic/request accessors mirroring the FFI. `rs/libmoq/build.rs` + regenerates `moq.h`; update `cpp/obs/src/moq-output.cpp:233` and the stub in + `cpp/obs/test/moq-output-test.cpp:224` that declares the old symbol. +- Wrappers: `py/moq-rs`, `swift`, `kt`, `go/wrapper` (flat on `dev`), and + `dart` adopt the three verbs and drop any create-and-announce convenience. + Swift and Python default the route (`announce(route: .init())`, + `announce(route=Route())`), matching their labeled-argument idiom. For Dart, + regenerate `dart/moq_ffi` (`just generate` in `dart/`, kixelated/uniffi-dart + per `dart/README.md`), then adapt `dart/moq/lib/moq.dart` (`:54-56`, whose + `createBroadcast` is documented as create-and-announce) and + `dart/moq/test/moq_test.dart` (`:32-35` expects the announcement to follow + `createBroadcast` on its own). +- Docs: `doc/lib/{py,swift,kt,go,c,dart}/index.md`. The publish examples at + `py:42`, `go:55`, `kt:40`, `swift:43`, and `dart:34` create a broadcast and + rely on the auto-announce; each gains the announce call and the create, + dynamic, populate, announce order. The capability lists (`py:67`, `kt:56`, + `swift:63`) name a bare `dynamic()`. Tests: each wrapper covers create, populate, `announce(route)`, visible in -`announced`, then `unannounce()`; and `dynamic(prefix, route)` serving a -request. Run `just test smoke-full` since the FFI surface changed. +`announced`, then `unannounce()`; `dynamic(pattern, route)` serving a request +under `live/**`; and a non-prefix pattern refused. Run `just test smoke-full` +since the FFI surface changed. Branch from `dev`: every rename is breaking. @@ -59,4 +88,4 @@ Branch from `dev`: every rename is breaking. - [#2152](/quest/m1/2152-libmoq-c-abi-catch-up-with-the-moq-ffi-surface.md) - the rest of the C ABI catch-up - [JS announce](/quest/m1/js-announce.md) - the same alignment for js/net -- [Dart announce](/quest/m1/dart-announce.md) - the Dart wrapper, once `dev` merges +- [Advertise](/quest/m1/wildcard/advertise.md) - lifts the prefix-only refusal so `dynamic()` accepts any pattern diff --git a/quest/m1/933-video-rotation-metadata-not-propagated-from-mobile-camera.md b/quest/m1/933-video-rotation-metadata-not-propagated-from-mobile-camera.md deleted file mode 100644 index c7bc388519..0000000000 --- a/quest/m1/933-video-rotation-metadata-not-propagated-from-mobile-camera.md +++ /dev/null @@ -1,59 +0,0 @@ -# [S] Video rotation metadata not propagated from mobile camera publish to watch renderer - -## Goal - -Implement and verify the behavior tracked in [#933](https://github.com/moq-dev/moq/issues/933) -within the issue's stated scope and boundaries. - -## Plan - -Rescoped during the 2026-08 grooming: the watch renderer already applies -catalog rotation on dev and file import rotates stored footage. What remains -is the publish side: detect device orientation during live camera capture and -set catalog rotation. Land it on dev. - -### Issue context - -#### Summary - -When publishing from a mobile device (e.g. iPhone camera via ``), the video orientation is incorrect on the watch side. The phone captures in landscape natively (e.g. 640x480) even when held in portrait, but no `rotation` metadata is included in the catalog, and the watch-side renderer doesn't apply rotation even if it were present. - -#### Steps to Reproduce - -1. Open a `` page on an iPhone (using WebSocket fallback via `@moq/web-transport-ws`) -2. Select camera source, hold phone in portrait orientation -3. Open the corresponding `` page in another browser -4. Video appears rotated 90 degrees - the viewer sees the image sideways - -#### Observed Behavior - -- Catalog contains `640x480` coded dimensions with no `rotation` field -- The watch-side canvas renderer (`watch/video/renderer.js`) handles `flip` but not `rotation` -- The publish side (`publish/video/index.d.ts`) exposes `flip` as a Signal but has no `rotation` Signal - -#### Expected Behavior - -- The publish side should detect device orientation (e.g. via `VideoFrame.rotation`, `window.screen.orientation`, or MediaStreamTrack settings) and include `rotation` in the catalog -- The watch-side renderer should read `catalog.rotation` and apply the appropriate transform when drawing frames to canvas - -#### Analysis - -The catalog schema already supports `rotation` (it appears in the type definitions for both publish and watch catalog types). The gap is: - -1. **Publish**: `Video.Root` has a `flip: Signal` but no corresponding `rotation` signal, so rotation is never set in the catalog -2. **Watch**: `renderer.js` lines 100-105 check `catalog.flip` and apply `ctx.scale(-1, 1)` but have no corresponding rotation logic - -#### Environment - -- `@moq/hang` v0.1.2 -- `@moq/web-transport-ws` (WebSocket fallback) -- Publishing from iPhone Safari (portrait), watching in Chrome desktop -- Relay: `cdn.moq.dev` - -#### Workaround - -For now we're applying a CSS rotation on the watch side based on aspect ratio heuristics, but this is fragile and doesn't handle all cases correctly. - -## Closes - -- [#933](https://github.com/moq-dev/moq/issues/933) - close this issue when the quest finishes diff --git a/quest/m1/README.md b/quest/m1/README.md index 8f07bd623e..4eecafd439 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -2,66 +2,50 @@ ## Goal -Everything that lands on the dev branch or with its merge to main: the -thread-per-core runtime (moq-uring, moq-tokio, quiche), the net model and -allocator follow-ups, breaking bindings work, and the archive line that gates -the merge itself, because moq.pro needs archive-backed recording on the release -that `dev` produces before it can adopt it. +Everything that must land on `dev` before it merges to `main`: the breaking +API and wire changes (the announce and wildcard surface, error codes, the +allocator mirrors, the bindings), the merge gates (the archive line, because +moq.pro needs archive-backed recording on the release that `dev` produces), +and the merge itself. ## Plan -Branch these quests from dev, not main. Several were rescoped during the -2026-08 grooming because dev already moved under them; reconcile each plan -with the current dev tree before starting. +Branch a quest from `dev` when it breaks a published API or wire. A merge +gate that lands on `main` (the duration marker, the additive Resolve and +Demand halves of the wildcard line) branches from `main` and ranks here only +because the merge waits on it. A quest stays here only if it breaks a +published API or wire, or gates the merge. Work that is identical on +`main`, additive, or targets a `0.0.x` crate lives in +[m2](/quest/m2/README.md) even when it builds on dev-only code; it starts on +`main` after the merge. The 2026-09-09 grooming reconciled every quest here +with the dev tree. ## Quests -- [Compressed delta regression](/quest/m1/json-compressed-delta-test-timeout.md) - diagnose the encoded-size test timeout under load - -- [Stream sessions](/quest/m1/uring-tcp/README.md) - serve WebSocket and HTTP from the io_uring workers, where io_uring pays off most -- [Perf](/quest/m1/perf/README.md) - eliminate measured hot-path costs across moq-uring, kio, and the moq-net model: copies, locks, clock reads, allocations, syscalls -- [#2296](/quest/m1/2296-moq-native-bring-the-quiche-backend-to-quinn-noq-feature.md) - moq-tokio: bring the quiche backend to quinn/noq feature parity -- [#2924](/quest/m1/2924-moq-relay-tls-rotation-is-not-atomic-across-thread-per.md) - moq-relay: TLS rotation is not atomic across thread-per-core QUIC workers -- [#2964](/quest/m1/2964-quic-workers-dropping-one-split-server-resizes-the.md) - QUIC workers: dropping one split() Server resizes the reuseport group -- [Transport feature](/quest/m1/tokio-transport-feature.md) - moq-tokio has one gate for "has a transport", and its backend-less build passes `-D warnings` -- [#2853](/quest/m1/2853-quiche-with-a-pinned-source-port-can-dial-only-a-broken.md) - quiche with a pinned source port can dial only a broken IPv4 address -- [Gap discontinuity](/quest/m1/gap-discontinuity.md) - a hole in the delivered group sequence is the discontinuity unless the boundary proves continuity; no marker has to arrive -- [Monotonic timeline](/quest/m1/monotonic-timeline.md) - a track's timestamps never fall below its live edge; publishers declare a discontinuity and continue forward, consumers stop detecting rewinds -- [Group overflow](/quest/m1/group-overflow-abort.md) - an oversized open group aborts for every reader instead of shedding its head -- [Control timeout code](/quest/m1/control-timeout-code.md) - a request stream the peer never answered stops claiming its content was late -- [#2895](/quest/m1/2895-add-an-atomic-readiness-gate-for-origin-broadcasts.md) - Add an atomic readiness gate for Origin broadcasts -- [#2991](/quest/m1/2991-net-coalesce-dynamic-tracks-and-preserve-sequences-across.md) - net: coalesce dynamic tracks and preserve sequences across replacements -- [#3190](/quest/m1/3190-align-origin-broadcast-creation-naming-across-language.md) - every native binding exposes create_broadcast, announce/unannounce on the broadcast, and dynamic(prefix, route) with one meaning -- [JS announce](/quest/m1/js-announce.md) - js/net gets createBroadcast, a broadcast-owned announcement, and the dynamic handle -- [Dart announce](/quest/m1/dart-announce.md) - the Dart wrapper mirrors the same three operations once dev merges -- [Archive](/quest/m1/archive/README.md) - record selected tracks to any object_store and replay them over FETCH or derived HLS; gates the dev merge -- [Playable](/quest/m1/hls-playable.md) - a 24/7 broadcast never becomes permanently unplayable over HLS -- [#2815](/quest/m1/2815-lift-adaptive-stage-refusal.md) - moq-cli runs several adaptive import stages on one connection now that the allocator divides the estimate -- [#2848](/quest/m1/2848-follow-the-bandwidth-grant-in-moq-audio-instead-of.md) - Follow the bandwidth grant in moq-audio instead of holding a fixed reservation -- [#2859](/quest/m1/2859-passthrough-imports-reserve-no-bandwidth-so-a-co-resident.md) - Passthrough imports reserve no bandwidth, so a co-resident encoder over-targets -- [Ladder](/quest/m1/ladder/README.md) - a transcode ladder adapts to the uplink it publishes over, instead of encoding every live rung at its ceiling -- [Binding rate control](/quest/m1/binding-rate-control.md) - the bindings mirror the allocator and reservation, so a non-Rust publisher follows its bandwidth share -- [#2709](/quest/m1/2709-per-broadcast-bandwidth-estimates-and-reservation.md) - js/net mirrors the send-side bandwidth allocator so each publisher encodes against its own share -- [JS abandonment](/quest/m1/js-subscribe-abandonment.md) - returning demand during IETF setup keeps its track across microtasks -- [IETF stream types](/quest/m1/ietf-uni-stream-types.md) - accept padding and close sessions for genuinely unknown uni-stream types -- [HLS cache misses](/quest/m1/hls-cache-miss-codes.md) - moq-hls: a segment the relay dropped is served as a 500, because the miss is matched against a table the wire stopped using -- [#3187](/quest/m1/3187-preserve-structured-protocol-error-codes-across-ffi-and-c.md) - Preserve structured protocol error codes across FFI and C bindings -- [#2318](/quest/m1/2318-js-net-remaining-capability-gaps-vs-rs-moq-net-setup-role.md) - js/net: remaining capability gaps vs rs/moq-net (SETUP role, finish_at and final sequence, range controls, typed errors) +- [Archive](/quest/m1/archive/README.md) - record selected tracks to any object_store and replay them over FETCH or derived HLS; the whole line gates the dev merge +- [Duration marker](/quest/m1/duration-marker.md) - every video group ends with an empty frame at its exclusive end; audio loses its end marker; lands on main but gates gap-discontinuity +- [Gap discontinuity](/quest/m1/gap-discontinuity.md) - a hole in the delivered group sequence resets the decoder unless the boundary is contiguous within 1 ms; empty groups stop meaning anything +- [Monotonic timeline](/quest/m1/monotonic-timeline.md) - producers refuse a group below the live edge and consumers drop rewind detection +- [#3190](/quest/m1/3190-align-origin-broadcast-creation-naming-across-language.md) - every native binding, Dart included, creates unadvertised, announces from the broadcast, and takes a path Pattern in `dynamic(pattern, route)` +- [JS announce](/quest/m1/js-announce.md) - js/net drops `publish()` and `RouteProvider` for `createBroadcast`, `announce(route)`, and a `dynamic(pattern, route)` handle +- [Wildcard](/quest/m1/wildcard/README.md) - a service advertises a path pattern priced at its start-up cost; Advertise gates the merge, Resolve and Demand are additive +- [Route cold cost](/quest/m1/route-cold-cost.md) - MoqRoute carries warm and cold, so an observed route re-announces intact +- [#3060](/quest/m1/3060-moq-net-ban-hop-id-0-from-hop-chains.md) - a hop chain names real hops only; Hop ID 0 stays the absence marker +- [Group overflow](/quest/m1/group-overflow-abort.md) - an open group past its budget aborts for every reader with GROUP_TOO_LARGE, and head eviction is deleted +- [#2774](/quest/m1/2774-collapse-reload-and-shared-into-one-connection-class.md) - one cloneable refcounted `Connection` mirroring `moq_tokio::Connection`; close releases a handle - [Close classification](/quest/m1/js-close-classification.md) - a browser consumer tells a requested end from a fault, so the media harness fails on real errors during a transition -- [#2774](/quest/m1/2774-collapse-reload-and-shared-into-one-connection-class.md) - Collapse Reload and Shared into one Connection class -- [HLS dead publisher](/quest/m1/hls-closed-publisher-500.md) - a segment whose publisher disconnected answers 500 instead of 404 -- [HLS sibling identity](/quest/m1/hls-sibling-epoch-identity.md) - validate sibling media against the epoch described by its catalog -- [#2075](/quest/m1/2075-mirror-catalog-reservation-gating-in-moq-hang-js-hang.md) - Mirror catalog reservation gating in @moq/hang (js/hang) -- [#933](/quest/m1/933-video-rotation-metadata-not-propagated-from-mobile-camera.md) - Video rotation metadata not propagated from mobile camera publish to watch renderer -- [#3056](/quest/m1/3056-watch-video-decoder-captures-the-rewind-generation-at.md) - watch: video decoder captures the rewind generation at output time, not submit time -- [Plan: A/V clock](/quest/m1/plan-av-clock.md) - settle how the audio playhead drives Sync.reference while audio plays, then build it -- [Config provenance](/quest/m1/config-provenance.md) - the merge records which source set a value, so TOML survives CLI defaults and empty lists, and env outranks the file +- [#3187](/quest/m1/3187-preserve-structured-protocol-error-codes-across-ffi-and-c.md) - protocol error codes cross moq-ffi and C as a scope, code, and kind instead of a message string +- [#2709](/quest/m1/2709-per-broadcast-bandwidth-estimates-and-reservation.md) - js/net mirrors the send-side bandwidth allocator so each publisher encodes against its own share +- [Binding rate control](/quest/m1/binding-rate-control.md) - the bindings mirror the allocator and reservation, so a non-Rust publisher follows its bandwidth share +- [#2859](/quest/m1/2859-passthrough-imports-reserve-no-bandwidth-so-a-co-resident.md) - passthrough imports claim their peak-hold catalog bitrate on the allocator so a co-resident encoder targets what is left +- [#2815](/quest/m1/2815-lift-adaptive-stage-refusal.md) - moq-cli accepts several adaptive import stages on one connection now that the allocator divides the estimate +- [HLS 404](/quest/m1/hls-cache-miss-codes.md) - a relay miss and a disconnected publisher answer 404 over moq-lite; IETF upstreams stay 500 +- [HLS sibling restart](/quest/m1/hls-sibling-epoch-identity.md) - a replaced sibling publisher restarts its rendition instead of serving stale rows +- [A/V clock](/quest/m1/plan-av-clock.md) - the audio playhead drives Sync.reference while audio plays, through per-track sync handles +- [Config provenance](/quest/m1/config-provenance.md) - the merge records which source set a value, so an empty TOML list survives the environment and env outranks the file - [Cluster construction](/quest/m1/cluster-construction.md) - construct one stable origin after its cache settings are known, deleting the rebuilding builder -- [Abort on drop](/quest/m1/abort-on-drop.md) - one abort-on-drop task guard per crate, replacing five copies of the same Drop -- [#3046](/quest/m1/3046-fold-moq-token-into-moq-token-via-a-usage-executable-view.md) - Fold moq-token into moq token via a Usage executable view -- [#3126](/quest/m1/3126-moq-bench-every-readme-example-fails-to-parse-and.md) - moq-bench: every README example fails to parse, and cumulative latency percentiles cannot be windowed to steady state +- [#3046](/quest/m1/3046-fold-moq-token-into-moq-token-via-a-usage-executable-view.md) - retire the standalone moq-token binary after one deprecation release; `moq token` is the only spelling - [Native Go context](/quest/m1/go-native-context.md) - the Go generator emits context.Context itself, retiring the hand-rolled cancellation token -- [#2152](/quest/m1/2152-libmoq-c-abi-catch-up-with-the-moq-ffi-surface.md) - libmoq: C ABI catch-up with the moq-ffi surface -- [Route cold cost](/quest/m1/route-cold-cost.md) - MoqRoute carries warm and cold, so an observed route re-announces intact -- [#3060](/quest/m1/3060-moq-net-ban-hop-id-0-from-hop-chains.md) - moq-net: ban Hop ID 0 from hop chains +- [#2152](/quest/m1/2152-libmoq-c-abi-catch-up-with-the-moq-ffi-surface.md) - libmoq serves tracks on demand and accepts sessions, the two moq-ffi calls C still lacks +- [Transport feature](/quest/m1/tokio-transport-feature.md) - moq-tokio has one `_transport` gate and its backend-less build passes `-D warnings` - [Merge dev](/quest/m1/merge-dev.md) - dev lands on main with a closing keyword for every issue it fixed diff --git a/quest/m1/abort-on-drop.md b/quest/m1/abort-on-drop.md deleted file mode 100644 index aac1a07ac3..0000000000 --- a/quest/m1/abort-on-drop.md +++ /dev/null @@ -1,30 +0,0 @@ -# [XS] moq-tokio: one abort-on-drop guard instead of five - -## Goal - -One type per crate owns "abort this task when its owner drops", and every site -that wants that behavior uses it. - -## Plan - -Dev only, which is why this sits in m1. Five copies of the same `Drop` exist -today: - -- `rs/moq-tokio/src/worker.rs`: `AbortOnDrop(JoinHandle)`, awaited through - after the guard is moved, so the shared type has to keep the handle reachable. -- `rs/moq-tokio/src/connection.rs`: `AbortOnDrop { handle: AbortHandle, closed: - CloseGuard }`. The `closed` field is connection policy that stays where it is; - only the abort half is shared. -- `rs/moq-tokio/src/tls.rs`: `Reload(JoinHandle<()>)`, whose doc comment explains - what a leaked certificate watcher costs, not what the guard does. It keeps its - name and its comment and becomes a thin newtype. -- `rs/moq-ffi/src/ffi.rs`: two more, one declared inside `detached` and one at - module scope, both over `AbortHandle`. - -Collapse the three `moq-tokio` sites onto one crate-private guard and the two -`moq-ffi` ones onto another. `moq-ffi` already depends on `moq-tokio`, so a -single shared guard is reachable, but that means a new `pub` item in a published -crate for a five-line `Drop` with two internal callers, which Public API Scrutiny -does not favor. Promote it if a third crate wants it. - -Nothing about behavior changes, so this lands on the existing tests. diff --git a/quest/m1/archive/README.md b/quest/m1/archive/README.md index 5e3f673179..20d99cadcb 100644 --- a/quest/m1/archive/README.md +++ b/quest/m1/archive/README.md @@ -6,137 +6,107 @@ `broadcast::Consumer` into any `object_store::ObjectStore`, then serves those groups through a caller-provided `broadcast::Producer`. The archive timeline is both its seek index and its availability contract: every range it advertises -has already been atomically stored. +has already been stored. The same archive supports MoQ FETCH, DVR, and ordinary HLS derived on demand. After normal catalog discovery, an HLS media playlist is generated by downloading only the timeline, never media objects. +The whole line gates the dev merge: moq.pro needs archive-backed recording on +the release dev produces ([merge dev](/quest/m1/merge-dev.md)). + ## Plan -### Format +### Landed + +The segment engine is on dev in `rs/moq-mux/src/timeline.rs`: + +- `Segmenter` (:477) builds aligned `{ segment, pts, duration, tracks }` + records (`rs/hang/src/timeline.rs:105-121`) from pacing and non-pacing + tracks, with explicit `cut(pts)` boundaries. +- `Producer::deferred` (:917) returns a `Deferred` (:619) whose `next` yields + a cancellation-safe `Pending` record (:702). Dropping it requeues the record, + `Pending::gap()` (:712) turns it into a gap, and `Producer::push` (:948) + commits it into the visible timeline in segment order. +- The timeline track is a `moq_json::window::Producer` (:742). + `Producer::pop` (:983) trims the oldest records and `finish` (:1015) closes + the track. +- `moq-hls` renders live playlists from the timeline alone and FETCHes media + per HTTP request (`rs/moq-hls/src/export/mod.rs:3-8`). A clean timeline + finish ends every window with `EXT-X-ENDLIST` (:325-327). + +No `moq-archive` crate exists, `object_store` is absent from `Cargo.lock`, and +the percent encoding of track names is unimplemented. -The format lives beside HANG and the timeline implementation. The application -chooses the object prefix, selected tracks, retention, credentials, and -recording identity; `moq-archive` owns the portable layout and codecs. +### Format -Each timeline segment is aligned at application-declared keyframe boundaries. -For every selected track it becomes one immutable object containing all of that -track's complete groups in the segment: +The format is the draft's Recording section as rewritten by +[format](/quest/m1/archive/format.md). The application chooses the object +prefix, selected tracks, retention, and credentials; `moq-archive` owns the +portable layout and codecs: ```text //.info // ``` -Track names use the HANG recording percent encoding. `.info` is versioned JSON -containing immutable track properties. A segment object is a versioned binary -envelope with a group/frame index followed by the original frame payloads. One -GET can therefore populate an LRU with the adjacent groups likely to be fetched -next, while audio-only or low-rendition playback never downloads unrelated -tracks. - -There is no `.head`, manifest, `.complete`, or special `.timeline` object. The -archive timeline is an ordinary track stored by the same `(track, segment)` -rule. Its name and configuration come from the catalog's `archive` entry, and -object listing is the bootstrap and recovery mechanism. - -### Writer and timeline - -The application passes a `broadcast::Consumer` and explicitly opts arbitrary -track names in as pacing or non-pacing. This includes catalog, JSON, and other -non-media tracks; `moq-archive` never discovers tracks by parsing a media -catalog. - -Reuse the segment engine already on `dev`: one record contains -`segment`, `pts`, `duration`, and each track's group ranges; pacing tracks gate -completion, non-pacing tracks join the open segment without stalling it, and -`cut(pts)` lets an application declare its keyframe boundary. Generalize that -engine so a consumer-side archive writer can feed it complete groups and commit -the record asynchronously. - -For each closed segment the writer: - -1. Encodes and atomically PUTs each available `(track, segment)` object. -2. Removes a failed track object's ranges, exactly as if those groups were never - received. -3. Appends the resulting record to the Window-backed archive timeline. - -The writer buffers groups independently of relay RAM and only includes a group -after it completes. Later segments continue after missing groups or failed -objects. A clean source end flushes the final partial segment but writes no -completion marker. - -### Reader - -The reader takes a `broadcast::Producer` and uses `track::Dynamic` to answer -FETCH requests for tracks and groups present in the archive timeline. It maps -the request to `(track, segment)`, GETs that object once, validates and caches -it, then reproduces the original group and frame timestamps. The reader is -transport and ingest-format agnostic; RTMP, SRT, WHIP, and future publishers -need no archive-specific implementation once they produce a broadcast. +`.info` is versioned JSON with the immutable priority and timescale. A segment +object is a versioned binary envelope: a group/frame table with timestamps and +payload offsets, then the original payloads. One GET populates an LRU with the +adjacent groups, while audio-only or low-rendition playback never downloads +unrelated tracks. + +There is no `.head`, manifest, `.complete`, or `.timeline` object. The archive +timeline is an ordinary track stored by the same `(track, segment)` rule and +named by the catalog's `archive` entry. Object listing is the bootstrap and +recovery mechanism. + +### Writer and reader + +The application passes a `broadcast::Consumer` and opts arbitrary track names +in as pacing or non-pacing, including catalog and JSON tracks; `moq-archive` +never parses a media catalog. For each closed segment the writer PUTs one +object per participating track, drops the ranges of any track whose PUT +failed, then pushes the record. A clean source end flushes the final partial +segment and finishes the timeline; there is no completion marker. + +The reader takes a `broadcast::Producer` and uses `track::Dynamic` +(`rs/moq-net/src/model/track.rs:1652`) to answer FETCH for the tracks and +groups the timeline advertises: map the request to `(track, segment)`, GET +once, validate, cache, and replay the original timestamps. Any ingest that +produces a broadcast (RTMP, SRT, WHIP) is archivable without its own +implementation. ### Retention -An unbounded archive only pushes records. A DVR pops records from the Window, -persists the archive timeline without them, waits a short grace period, then -deletes the corresponding `(track, segment)` objects. The index therefore stops -advertising an object before it can disappear. - -Archive and stored HLS remain mutually exclusive recording formats. HLS is a -derived view of the archive, not a second stored copy. +An unbounded archive only pushes records. A DVR pops records, stores the new +timeline group, waits a short grace period, then deletes the expired +`(track, segment)` objects, so the index stops advertising an object before it +can disappear. HLS is a derived view of the archive, never a second stored +copy. ### Managed boundary -Per-project sealing, retention prefixes, recording and generation keying, -election, billing, and platform discovery are downstream (moq.pro) concerns. -The archive entry is portable while the source catalog exists; a managed -recordings API keeps completed recordings discoverable after source teardown. -Live deep history is likewise a downstream overlay contribution that exposes a -managed store's larger Window without rewriting the source archive entry. - -### Research baseline - -As of 2026-08-30, the `dev` timeline already implements aligned broadcast -segments, pacing and non-pacing tracks, explicit cuts, discontinuous group -ranges, and the complete metadata needed to render HLS without media reads. -[moq#3168](https://github.com/moq-dev/moq/pull/3168) merged the generic -`moq_json::Window` primitive on `main`. No `moq-archive` crate or object-store -implementation exists yet. - -The serving seam also exists: a relay forwards a FETCH miss upstream and -`track::Dynamic` lets the archive owner answer it. IETF FETCH and protected -broadcast archives remain out of scope. +Per-project sealing, retention prefixes, recording keying, election, billing, +and platform discovery are downstream (moq.pro) concerns. The archive entry is +portable while the source catalog exists; a managed recordings API keeps +completed recordings discoverable after source teardown. IETF FETCH and +protected broadcasts are out of scope. ## Quests -- [Archive timeline](/quest/m1/archive/timeline.md) - make the existing segment - engine reusable by the consumer-side writer and publish its records through a - bounded `Window` -- [Archive catalog](/quest/m1/archive/catalog.md) - replace the root `timeline` - entry with the durable `archive` capability and its replay identity -- [Archive store](/quest/m1/archive/store.md) - define the portable - `(track, segment)` layout and codecs directly over `object_store` -- [Recording writer](/quest/m1/archive/writer.md) - consume explicitly selected - tracks, persist each segment, then commit its timeline record -- [Recording reader](/quest/m1/archive/reader.md) - serve archived FETCH requests - through a supplied `broadcast::Producer` -- [Browser archive](/quest/m1/archive/browser.md) - implement the same archive - contract for browser-published broadcasts -- [Archive catalog cutover](/quest/m1/archive/cutover.md) - remove the live-only - `timeline` entry after supported publishers are store-backed -- [Offline archive HLS](/quest/m1/archive/hls.md) - render playlists from the - archive timeline and fetch segment media lazily -- [DVR rewind](/quest/m1/archive/dvr.md) - seek through a bounded archive and - return to live playback -- [Archive proof](/quest/m1/archive/proof.md) - prove persistence ordering, - selective reads, exact FETCH replay, and timeline-only HLS generation -- [Catalog version binding](/quest/m1/archive/catalog-version.md) - explicitly - bind groups to the catalog version that describes them after the first archive - ships with the timestamp rule +- [Recording format](/quest/m1/archive/format.md) - the draft's Recording section becomes the `(track, segment)` layout with the timeline stored as an ordinary track +- [Archive catalog](/quest/m1/archive/catalog.md) - one root `archive` entry subsumes `timeline` and names the timeline track, replay path, store URL, and format version +- [Archive store](/quest/m1/archive/store.md) - `moq-archive` puts, gets, lists, and deletes the versioned objects over `object_store` +- [Recording writer](/quest/m1/archive/writer.md) - feed the segmenter from a `broadcast::Consumer`, store each segment, then commit its record +- [Recording reader](/quest/m1/archive/reader.md) - serve archived FETCH through a supplied `broadcast::Producer` +- [Browser archive](/quest/m1/archive/browser.md) - the same contract for browser-published broadcasts +- [Offline archive HLS](/quest/m1/archive/hls.md) - render playlists from the archive timeline and fetch segment media lazily +- [DVR rewind](/quest/m1/archive/dvr.md) - seek through a bounded archive and return to live playback +- [Archive proof](/quest/m1/archive/proof.md) - prove persistence ordering, selective reads, exact FETCH replay, and timeline-only HLS generation +- [Catalog version binding](/quest/m1/archive/catalog-version.md) - bind groups to the catalog version that describes them, after the first archive ships ## Related -- [Playable](/quest/m1/hls-playable.md) - live export and archive-derived HLS share the timeline model -- [wildcard](/quest/m2/wildcard/README.md) - catch-all routing exposes an archive at its stable replay path +- [wildcard](/quest/m1/wildcard/README.md) - catch-all routing exposes an archive at its stable replay path - [e2ee](/quest/m2/e2ee/README.md) - protected broadcasts are excluded initially diff --git a/quest/m1/archive/browser.md b/quest/m1/archive/browser.md index f00ffdbca4..5e006064d1 100644 --- a/quest/m1/archive/browser.md +++ b/quest/m1/archive/browser.md @@ -9,16 +9,22 @@ layout, and FETCH behavior as native `moq-archive` users. Port the archive contract to the JS packages with memory and OPFS storage. The application explicitly enrolls video, audio, catalog, or arbitrary data tracks; -the archive does not infer them from Hang. Use the shared timeline segmenter and -merged JSON Window, including application-driven keyframe cuts. +the archive does not infer them from Hang. The JS timeline already publishes +through the Window (`js/hang/src/container/timeline.ts:134`, `:165`, backed by +`js/json/src/window/`) with pacing tracks and application-driven cuts; add the +deferred commit Rust has (`Producer::deferred`, +`rs/moq-mux/src/timeline.rs:917`). Persist one object per `(track, segment)` after all included groups complete, then publish the archive timeline record. A typical audio segment contains many -one-group-per-frame audio groups. Match the Rust binary envelope and track-info -vectors byte for byte. Match the writer's generic commit prerequisites too, so -a failed catalog snapshot never leaves dependent media ranges advertised. - -Add the JS equivalent of `track::Dynamic` so the publisher can answer FETCH +one-group-per-frame audio groups. Match the Rust binary envelope and `.info` +bytes exactly, per [format](/quest/m1/archive/format.md), and share the +writer's commit prerequisites so a failed catalog snapshot never leaves +dependent media ranges advertised. + +The missing piece in `js/net` is a `track::Dynamic` equivalent: a consumer can +`fetchGroup` (`js/net/src/track.ts:272`), but nothing in `js/net/src` lets a +producer serve that miss on demand. Add it so the publisher can answer FETCH misses from memory or OPFS after relay eviction. Keep the bounded bytes in an LRU and use the same timeline-before-delete ordering for DVR retention. @@ -27,7 +33,7 @@ remains downstream (moq.pro) work. ## Required +- [Recording format](/quest/m1/archive/format.md) - [Archive catalog](/quest/m1/archive/catalog.md) -- [Recording writer](/quest/m1/archive/writer.md) - [Archive store](/quest/m1/archive/store.md) -- [Archive timeline](/quest/m1/archive/timeline.md) +- [Recording writer](/quest/m1/archive/writer.md) diff --git a/quest/m1/archive/catalog.md b/quest/m1/archive/catalog.md index 89366af919..6e88450b98 100644 --- a/quest/m1/archive/catalog.md +++ b/quest/m1/archive/catalog.md @@ -1,43 +1,55 @@ -# [S] Archive catalog +# [M] Archive catalog ## Goal -A HANG catalog can advertise a durable archive, its Window timeline, and its -stable replay identity through one root `archive` entry. +A HANG catalog advertises its segment index and any durable archive through +one root `archive` entry that replaces `timeline`: the timeline track, the +replay MoQ broadcast path, the object-store URL, and the format version. ## Plan -Add `archive` as a distinct root capability, not an alias for the current live -`timeline`. The entry keeps the timeline track, timescale, maximum duration, and -wall-clock anchor, then adds the archive format version, stable replay URL, and -broadcast epoch. The catalog supplies the timeline track's name and -configuration; it has no reserved physical identity. - -The entry promises that every range in the advertised timeline is durably -FETCHable. A broadcast with no archive store has no `archive` entry. The latest -catalog group still comes from ordinary SUBSCRIBE, and authorization for the -replay URL remains external so managed and customer-owned stores share one -format. - -Catalog composition preserves a child's archive entry instead of synthesizing -one for a derivative. A catch-all replay route may expose the stable URL, but -the catalog epoch identifies the exact recording generation. - -This entry is portable discovery while the catalog exists. It does not keep a -source catalog alive or enumerate offline recordings. Managed deployments -(moq.pro) use their durable recordings API after source teardown and can expose -a growing deep window through a separate live archive contribution downstream. - -Keep the existing `timeline` entry during migration so publishers without a -store remain exportable. [Archive catalog cutover](/quest/m1/archive/cutover.md) -deletes it after every supported publisher can emit `archive`; do not make -either entry a compatibility alias for the other. +One breaking catalog change: the root `archive` entry subsumes `timeline` +(`rs/hang/src/catalog/root.rs:39-42`), so exactly one entry names the segment +index. It carries: + +- the timeline fields as they stand in `rs/hang/src/catalog/timeline.rs:24-55` + (`track`, `timescale`, `durationMax`, `wall`) +- the replay MoQ broadcast path the archive is served back from, if any +- the object-store URL the objects live under, if the publisher exposes one +- the format version from [format](/quest/m1/archive/format.md) + +A live publisher without a store advertises `archive` with the timeline +fields alone, and the store-less HLS export +(`rs/moq-hls/src/export/mod.rs:3-8`) reads `archive.timeline` where it reads +`timeline` today, so nothing loses HLS across the change. The entry promises +that every range in the advertised timeline is FETCHable; with a store it +promises the ranges are durable. There is no alias, dual-write, or fallback +`timeline` entry, and no epoch: a wildcard replay path names no generation, +and a client that must tell recordings apart compares the entry's replay path +and store URL. + +The catalog supplies the timeline track's name and configuration; the +timeline has no reserved physical identity. The latest catalog group still +comes from ordinary SUBSCRIBE, and authorization for the replay path and +store URL stays external so managed and customer-owned stores share one +format. Catalog composition preserves a child's `archive` entry instead of +synthesizing one for a derivative. + +The entry is portable discovery while the catalog exists. It does not keep a +source catalog alive or enumerate offline recordings; managed deployments +(moq.pro) use their recordings API after source teardown. + +Land it as one change across `rs/hang` (the catalog type, `moq-mux` +`Producer::section` at `rs/moq-mux/src/timeline.rs:907`, `moq-hls`), the +`js/hang` mirror (`js/hang/src/catalog/root.ts:27`, +`js/hang/src/container/timeline.ts:131`), and a new Catalog Section in the +draft's Timeline chapter (`drafts/draft-lcurley-moq-hang.md:560`) replacing +`timeline`. ## Required -- [Archive timeline](/quest/m1/archive/timeline.md) +- [Recording format](/quest/m1/archive/format.md) - the version the entry advertises ## Related -- [Catalog version binding](/quest/m1/archive/catalog-version.md) - explicit - historic applicability is deliberately separate +- [Catalog version binding](/quest/m1/archive/catalog-version.md) - explicit historic applicability is deliberately separate diff --git a/quest/m1/archive/cutover.md b/quest/m1/archive/cutover.md deleted file mode 100644 index aae8439b50..0000000000 --- a/quest/m1/archive/cutover.md +++ /dev/null @@ -1,23 +0,0 @@ -# [S] Archive catalog cutover - -## Goal - -The root `timeline` entry is removed after every supported publisher can -advertise a stored `archive`, with no intermediate live-HLS outage. - -## Plan - -Delete the legacy `timeline` catalog entry and its consumer fallback; `archive` -becomes the only HLS availability contract. Do not retain an alias or -dual-write after the migration. - -Cut the release only after `moq-cli` and browser publishing attach their -archive writers. Downstream (moq.pro) deployments should adopt that release -only once their RTMP, SRT, and WHIP gateways attach the same writer in one -change, so deployed native paths never lose HLS between the schema change and -store enrollment. - -## Required - -- [Recording writer](/quest/m1/archive/writer.md) - wires every `moq-cli` import path -- [Browser archive](/quest/m1/archive/browser.md) - closes the remaining browser publish path diff --git a/quest/m1/archive/dvr.md b/quest/m1/archive/dvr.md index 045e8b0b7e..6e3377145a 100644 --- a/quest/m1/archive/dvr.md +++ b/quest/m1/archive/dvr.md @@ -7,10 +7,13 @@ same timeline and `(track, segment)` objects as an unbounded archive. ## Plan -The writer owns retention. It pops expired records from the archive Window, -persists the new timeline group, waits a short grace period, then deletes each -expired track segment. The timeline never advertises an object after deletion, -and no manifest or head must be rewritten. +The writer owns retention. It pops expired records from the archive Window +(`Producer::pop`, `rs/moq-mux/src/timeline.rs:983`), stores the new timeline +group as an ordinary `(track, segment)` object per +[format](/quest/m1/archive/format.md), waits a short grace period, then +deletes each expired track segment. The timeline never advertises an object +after deletion, and nothing is rewritten: no manifest, head, or timeline +object. The player reads the archive timeline, FETCHes old groups through the normal miss chain, and splices back to SUBSCRIBE at the live edge without opening a diff --git a/quest/m1/archive/format.md b/quest/m1/archive/format.md new file mode 100644 index 0000000000..1b9f406715 --- /dev/null +++ b/quest/m1/archive/format.md @@ -0,0 +1,73 @@ +# [M] Recording format + +## Goal + +The HANG draft's Recording section (`drafts/draft-lcurley-moq-hang.md:739-913`) +specifies the object layout the archive line implements: a per-track `.info` +and segment objects, the timeline stored as an ordinary track, object listing +as bootstrap and recovery, and per-track omission when a PUT fails. +`just drafts check` passes. + +## Plan + +Why the section changes: S3 cannot append. The draft's `.timeline` object +(:826-859) grows by appending complete groups and is followed with ranged +GETs, which on an object store means rewriting the whole object per segment +and an entity-validator dance on every retention trim. Its writer also records +a whole-segment gap when any one track's object fails (:870-874), discarding +media that was stored fine. + +Rewrite the section to this design: + +- Layout (:750-775). Two object kinds only: + + ```text + //.info + // + ``` + + Keep the percent encoding of track names (:762-764), so an encoded name + never contains `/` or starts with `.`. Drop `.complete` and `.timeline` + (:754-755) and rename `.track` to `.info`, the one reserved name. +- Track objects (:777-793). `.info` is versioned JSON carrying the immutable + `priority` and `timescale`, with the meanings of moq-lite `TRACK_INFO`. + Delete :788-789: there is no broadcast epoch, and `Publisher Max Latency` is + not stored because a reader serves with its own policy. +- Segment objects (:795-824). A versioned binary envelope: a header with the + format version, a group/frame table (group sequence, each frame's timestamp, + payload offset and length), then the payload bytes. The table locates one + group or a `frame_start` without parsing frames, and a decoder bounds-checks + every entry against the retrieved length before touching a payload. Update + the Security Considerations paragraph (:922-925) from `Length` fields to the + table. +- The Timeline Object (:826-859). Delete the section. The timeline is an + ordinary track stored by the same `(track, segment)` rule and named by the + catalog's `archive` entry ([catalog](/quest/m1/archive/catalog.md)). Each of + its objects holds the complete Window groups (Track Framing, :590-616) + closed in that span, so a reader replays checkpoints in order and nothing is + ever rewritten. +- Writer (:861-882). A track's `.info` and segment object are durable before + the record naming them is published, as today. A failed track PUT omits only + that track from the record; the other tracks' ranges stay. This is the + per-track counterpart of `Pending::gap()` + (`rs/moq-mux/src/timeline.rs:712-719`), which clears every track; + [writer](/quest/m1/archive/writer.md) adds the method beside it. No + completion marker: a clean end flushes the final partial segment and + finishes the timeline track. +- Bootstrap and recovery. Listing the prefix is how a reader or a restarting + writer learns which timeline and track segments exist. A record the listing + cannot back is not served. +- Retention (:884-896). Replace the ranged-GET and atomic-replace text with: + expiry trims the timeline (`Producer::pop`, `timeline.rs:983`) and makes the + trimmed timeline durable before the expired segment's objects are deleted. +- Reader (:898-913). Keep the whole-object GET rule. Drop the `.complete` and + `.timeline` caching paragraph (:902-905). An unknown envelope version or a + table entry outside the object is a missing segment, never an invalid + recording. + +Validate with `just drafts check`. No code changes; the crates follow in +[store](/quest/m1/archive/store.md), writer, and reader. + +## Related + +- [Archive store](/quest/m1/archive/store.md) - implements the layout and codecs this section specifies diff --git a/quest/m1/archive/hls.md b/quest/m1/archive/hls.md index 4b519e224d..f6b271d97c 100644 --- a/quest/m1/archive/hls.md +++ b/quest/m1/archive/hls.md @@ -17,9 +17,12 @@ When a player requests media, use the recording reader to GET only the selected `(track, segment)` object and transmux its groups on demand. Switching between 360p and 1080p must not download both rendition objects. No LL-HLS parts. -Emit `EXT-X-ENDLIST` only when the caller supplies terminal state out of band. -The portable archive has no completion marker in the first version, so a -standalone or BYOB archive without such a caller remains a reloadable playlist. +Emit `EXT-X-ENDLIST` exactly when the timeline track the exporter reads +finishes cleanly, as the live export already does +(`rs/moq-hls/src/export/mod.rs:325-327`, `rendition.rs:243-246`). The store +holds no completion marker: the reader finishes the replayed timeline track +when its caller supplies finality out of band, so a standalone or BYOB archive +without such a caller stays a reloadable playlist. Initially select the historic catalog snapshot by the existing moq-net timestamp rule. Explicit group-to-catalog identity belongs to the related diff --git a/quest/m1/archive/proof.md b/quest/m1/archive/proof.md index 478b171665..a8bec7adcf 100644 --- a/quest/m1/archive/proof.md +++ b/quest/m1/archive/proof.md @@ -13,16 +13,19 @@ replay their original sequences, timestamps, and payloads through `track::Dynamic`. Verify the exact object keys and bytes on memory, local, and S3-compatible -`object_store` implementations. A 360p or audio-only FETCH must not GET the -1080p object, while adjacent group requests should hit the segment LRU. +`object_store` implementations, including the percent-encoded track names. A +360p or audio-only FETCH must not GET the 1080p object, while adjacent group +requests should hit the segment LRU. Cover the persistence boundary: a crash after segment PUT but before timeline -commit leaves invisible orphan data; a failed or mismatched `.info` exposes no -ranges; a failed independent track PUT omits only that track; a failed catalog -PUT never exposes dependent media; later segments remain usable. Also cover a -stalled pacing track forced to a gap, sparse group ranges, malformed offsets, -unknown format versions, segment create collisions under the single-writer -epoch invariant, a missing tail, and clean end without a completion marker. +commit leaves invisible orphan data that a listing bootstrap ignores; a failed +or mismatched `.info` exposes no ranges; a failed independent track PUT omits +only that track while the record's other tracks stay advertised; a failed +catalog PUT never exposes dependent media; later segments remain usable. Also +cover a stalled pacing track forced to a gap, sparse group ranges, malformed +table offsets, an unknown envelope or `.info` version treated as a missing +segment, segment create collisions under the single-writer rule, a missing +tail, and a clean end without a completion marker. Finally render and reload HLS playlists while rejecting every media-object GET until a segment URI is requested. diff --git a/quest/m1/archive/reader.md b/quest/m1/archive/reader.md index 1fe59484c3..1d660b37de 100644 --- a/quest/m1/archive/reader.md +++ b/quest/m1/archive/reader.md @@ -3,33 +3,40 @@ ## Goal A reader takes an archive and a caller-provided `broadcast::Producer`, then -serves FETCH requests for every track and group advertised by the archive -timeline. +serves FETCH requests for every track and group the archive timeline +advertises. ## Plan -Load the archive catalog and Window timeline through the ordinary track layout. -Use the timeline's track ranges to map `(track, group)` to its segment object; -there is no separate manifest or mutable head. Object listing bootstraps the -available timeline groups and follows a growing archive. - -Use `track::Dynamic` to accept requested tracks and groups on the supplied -producer. GET one `(track, segment)` object, validate its format version and -every group/frame offset, and place it in a byte-bounded LRU. Adjacent group -FETCHes then reuse the same object. A request for one audio track or rendition -must never download another track's segment object. +Bootstrap from an object listing under the prefix: it names the timeline +track's segments and every track's `.info` and segments. Read the timeline +objects in order through the Window decoder, and follow a growing archive by +re-listing; there is no manifest, head, or completion marker. A record whose +objects the listing cannot back is not served. + +Use `track::Dynamic` (`rs/moq-net/src/model/track.rs:1652`, minted by +`Producer::dynamic` :1587) to accept requested tracks and groups on the +supplied producer; a cache-miss `Consumer::fetch_group` (:2352) parks on it. +Map `(track, group)` to its segment through the timeline's track ranges, GET +that one object, validate the envelope version and every group/frame table +entry against the retrieved length, and place it in a byte-bounded LRU. +Adjacent group FETCHes reuse the same object. A request for one audio track or +rendition never downloads another track's object. Reproduce the original group sequence, frame timestamps, and payload bytes, -including a requested `frame_start`. A group absent from the timeline, a missing -object, or a malformed envelope behaves exactly like a group the source never -delivered; siblings and later segments remain usable. +including a requested `frame_start`. A group absent from the timeline, a +missing object, an unknown envelope version, or a malformed table behaves +exactly like a group the source never delivered; siblings and later segments +remain usable. The reader does not interpret media, own routing, or expose storage paths. It -also does not infer a terminal broadcast state. A caller such as a managed -recordings API may supply finality out of band; otherwise an archive may be -growing, crashed, or simply missing its tail and remains reloadable. +does not infer a terminal broadcast state: a caller such as a managed +recordings API may supply finality out of band, and the reader then finishes +the replayed timeline track. Otherwise an archive may be growing, crashed, or +missing its tail, and remains readable. ## Required +- [Recording format](/quest/m1/archive/format.md) - [Archive catalog](/quest/m1/archive/catalog.md) - [Archive store](/quest/m1/archive/store.md) diff --git a/quest/m1/archive/store.md b/quest/m1/archive/store.md index 3592a61af5..af3d300d30 100644 --- a/quest/m1/archive/store.md +++ b/quest/m1/archive/store.md @@ -2,42 +2,47 @@ ## Goal -`moq-archive` stores the same versioned `(track, segment)` objects on memory, -local disk, and S3-compatible storage through a generic -`T: object_store::ObjectStore`. +`moq-archive` stores the versioned `(track, segment)` objects of +[format](/quest/m1/archive/format.md) on memory, local disk, and +S3-compatible storage through a generic `T: object_store::ObjectStore`. ## Plan -Use `object_store` directly rather than adding a second store trait or separate -backend implementations. Callers that need runtime dispatch can supply -`Arc`; the archive API itself remains generic. +Add `object_store` to `[workspace.dependencies]`; it is absent from +`Cargo.lock` today. Use it directly rather than adding a second store trait or +per-backend implementations. Callers that need runtime dispatch supply +`Arc`; the archive API itself stays generic. -The portable layout is: +The layout is the format's: ```text //.info // ``` -Encode track names with the HANG recording percent encoding. `.info` is a -versioned JSON body containing immutable track priority and timescale. A segment -object is a custom versioned binary envelope containing multiple complete MoQ -groups from one track, a group/frame table with timestamps and payload offsets, -then the original payload bytes. Bounds-check every table entry while decoding. +Implement the percent encoding of track names here; nothing in `rs/hang/src` +does it yet. `.info` is a versioned JSON body with the immutable priority and +timescale. A segment object is the versioned binary envelope: a group/frame +table with timestamps and payload offsets, then the original payload bytes. +Bounds-check every table entry while decoding and refuse an unknown version. -Every PUT is an atomic whole-object create. Under the recording's unique epoch -and single-writer invariant, an existing deterministic key means the segment is -already persisted; retries do not compare checksums or rewrite it. Object -attributes such as content type or cache policy are optional hints, never format -metadata. +Every PUT is a whole-object create. One writer owns a prefix, so a +deterministic key that already exists means the segment is already persisted; +retries neither compare checksums nor rewrite it. Object attributes such as +content type or cache policy are optional hints, never format metadata. -Track metadata is the exception: if `.info` already exists, GET it and require +`.info` is the exception: if it already exists, GET it and require byte-equivalent contents. A priority or timescale mismatch is a hard enrollment error, never an idempotent retry. -The store exposes layout/codec helpers plus put, get, list, and delete over the -underlying `ObjectStore`. There is no `.head`, manifest, `.complete`, append, -or mutable object. Listing is the source of truth for bootstrap and recovery. -Do not add presigned-URL handling; credential policy belongs to the application. +The store exposes the layout and codec helpers plus put, get, list, and delete +over the underlying `ObjectStore`. There is no `.head`, manifest, `.complete`, +append, or mutable object; listing is the source of truth for bootstrap and +recovery. Do not add presigned-URL handling; credential policy belongs to the +application. -Land the crate in the moq workspace beside HANG and the archive draft. +Land the crate in the moq workspace beside `hang`. + +## Required + +- [Recording format](/quest/m1/archive/format.md) - the layout and codecs this crate implements diff --git a/quest/m1/archive/timeline.md b/quest/m1/archive/timeline.md deleted file mode 100644 index 10b481fd01..0000000000 --- a/quest/m1/archive/timeline.md +++ /dev/null @@ -1,32 +0,0 @@ -# [M] Archive timeline - -## Goal - -The existing broadcast timeline becomes a reusable segment engine whose -Window-backed records can be committed only after an asynchronous archive write. - -## Plan - -Start from `dev`'s implemented model rather than creating another index. It -already produces aligned `{ segment, pts, duration, tracks }` records, supports -arbitrary pacing and non-pacing tracks, accepts explicit `cut(pts)` boundaries, -and represents discontinuous group ranges and keyframe state. - -Separate that segmentation state from its current producer-side MoQ sink. It -must accept complete group facts from either the existing producer recorders or -the consumer-side archive writer, then yield a closed segment to an asynchronous -commit sink. The archive sink stores the segment objects, removes unavailable -ranges, and acknowledges the final record; only that acknowledged record enters -the visible timeline. - -Replace the never-rolled `moq_json::stream` track with the merged -`moq_json::Window`. One public timeline model covers bounded DVR and unbounded -archives: unbounded use only pushes, while DVR also pops. Group rolls are an -encoding detail and do not surface to consumers. - -Preserve the hard HLS property: after the catalog selects a rendition, timeline -records alone are sufficient to render its media playlist without downloading -any media object. - -Land the Rust and draft changes against `dev`, building on the `Window` -primitive merged in [moq#3168](https://github.com/moq-dev/moq/pull/3168). diff --git a/quest/m1/archive/writer.md b/quest/m1/archive/writer.md index dcbfbeaef3..7c86c9966f 100644 --- a/quest/m1/archive/writer.md +++ b/quest/m1/archive/writer.md @@ -3,63 +3,67 @@ ## Goal A generic `moq-archive` writer consumes explicitly selected tracks from a -`broadcast::Consumer`, batches their complete groups into aligned segments, and -publishes a timeline record only after the objects it names are stored. +`broadcast::Consumer`, feeds their complete groups to the existing segmenter, +stores one object per track per segment, and commits the timeline record only +after the objects it names are durable. ## Plan The application supplies the broadcast, object prefix, and arbitrary track -registrations. Each registration is pacing or non-pacing, matching the existing -timeline engine; the writer does not parse a catalog or know which tracks are -media. +registrations, each pacing or non-pacing; the writer does not parse a catalog +or know which tracks are media. -Enrollment atomically creates the track's `.info` before accepting any groups. -If that create fails, enrollment fails and no range for the track can enter the +Enrollment creates the track's `.info` before accepting any group. If that +create fails, enrollment fails and no range for the track can enter the timeline. On `AlreadyExists`, read the immutable `.info` and accept only byte-equivalent metadata; a priority or timescale mismatch fails enrollment. -Refactor the existing `moq-mux::timeline` segmentation state so producer-side -recorders and this consumer-side writer share its cuts, completeness gates, -group-range construction, gaps, timestamps, and final partial segment. The -application calls `cut(pts)` when it knows an aligned keyframe boundary. A -segment may contain many groups per track, especially one-group-per-frame audio. -A pacing track that stops without closing deliberately blocks automatic segment -completion. The application applies its own deadline and calls `cut(pts)` to -force the close or removes that track. The forced segment contains only complete -groups and records the stalled range as a gap. Storage does not invent a timeout. +Feed the segmenter already on dev (`rs/moq-mux/src/timeline.rs`): take +`Producer::deferred` (:917), enroll each registration through +`Deferred::track` or `pacing_track` (:625-631), and report every complete +group read from the consumer through its `Recorder` (:1096). The application +calls `cut(pts)` (:635) when it knows an aligned keyframe boundary. A segment +may hold many groups per track, especially one-group-per-frame audio. A pacing +track that stops without closing blocks segment completion on purpose; the +application applies its own deadline and calls `cut(pts)` or removes the +track. Storage does not invent a timeout. -Buffer complete groups independently of relay retention. When a segment closes, -encode and atomically PUT one object per participating track. After all PUTs -settle, omit failed tracks or missing groups from the record and append it to -the archive's `moq_json::Window`. Never publish a range first and hope the relay -still has it. A later segment continues normally after any omission. +For each `Pending` record from `Deferred::next` (:658), encode and PUT one +object per participating track, buffering groups independently of relay +retention. After all PUTs settle, drop the ranges of every track whose PUT +failed and commit with `Producer::push` (:948). That needs a per-track +omission on `Pending` beside `Pending::gap()` (:712-719), which clears every +track; add it here, per [format](/quest/m1/archive/format.md). Never publish a +range first and hope the relay still has it. A later segment continues +normally after any omission. -Allow applications to declare that one enrolled track's applicable group is a -commit prerequisite for other tracks. Store prerequisites first; if one fails, -omit its dependent ranges or fail the segment according to application policy. -HANG publishers use this generic mechanism to make the catalog snapshot durable -before advertising media that needs it. The writer compares timestamps and -durability but does not parse the catalog. +Commit prerequisites are new API: an application declares that one enrolled +track's applicable group must be durable before other tracks' ranges in the +same record are published. Store prerequisites first; if one fails, omit its +dependents or fail the record according to application policy. HANG publishers +use this to make the catalog snapshot durable before advertising media that +needs it; the writer compares timestamps and durability but does not parse the +catalog. -The archive timeline is itself stored through the same track machinery. Cut its -active group when the application requests a flush; storage never invents a -maximum age or cuts a source group. On a clean source end, flush the final -partial segment. Do not write a completion marker. +The archive timeline is itself stored through the same track machinery. Cut +its active group when the application requests a flush; storage never invents +a maximum age or cuts a source group. On a clean source end, drain with +`Deferred::finish` (:650), commit the final partial segment, and call +`Producer::finish` (:1015). Do not write a completion marker. -Retention is writer policy. For DVR, pop expired records from the Window, make -the new archive timeline visible, wait the configured grace period, then delete -the corresponding segment objects. +Retention is writer policy. For DVR, `Producer::pop` (:983) the expired +records, make the new timeline group durable, wait the configured grace +period, then delete the corresponding segment objects. Keep archive policy out of protocol libraries. As the native application that owns its storage and track choices, `moq-cli` attaches the writer to every -import path and explicitly enrolls the resulting `broadcast::Consumer` tracks. -Downstream (moq.pro) edge gateways attach the same writer once it ships in a -release. +import path and enrolls the resulting `broadcast::Consumer` tracks. Downstream +(moq.pro) gateways attach the same writer once it ships in a release. ## Required +- [Recording format](/quest/m1/archive/format.md) - [Archive catalog](/quest/m1/archive/catalog.md) -- [Archive timeline](/quest/m1/archive/timeline.md) - [Archive store](/quest/m1/archive/store.md) ## Closes diff --git a/quest/m1/binding-rate-control.md b/quest/m1/binding-rate-control.md index 990b3d475d..fd7fecbb68 100644 --- a/quest/m1/binding-rate-control.md +++ b/quest/m1/binding-rate-control.md @@ -1,4 +1,4 @@ -# [M] Bindings follow the connection's bandwidth share +# [L] Bindings follow the connection's bandwidth share ## Goal @@ -6,54 +6,73 @@ A non-Rust publisher follows the connection's send estimate the way Rust does. The bindings mirror `bandwidth::Allocator` and `bandwidth::Reservation`: a session mints the allocator, the built-in video encoder in moq-ffi and libmoq reserves its configured bitrate against it and follows the grant, the built-in -audio encoder reserves its bitrate and holds it (following comes with #2848, as -in Rust today), and an application that owns its encoder reserves a share for -its own track and reads the current grant. A Python, Swift, Kotlin, Go or C -video publisher stops holding its configured bitrate through congestion. +audio encoder reserves its bitrate and follows once +[#2848](/quest/m2/2848-follow-the-bandwidth-grant-in-moq-audio-instead-of.md) +lands, and an application that owns its encoder reserves a share for its own +track and reads the current grant. A Python, Swift, Kotlin, Go or C video +publisher stops holding its configured bitrate through congestion. -Boundaries: the allocation rules are Rust's (strict priority tiers, max-min fair -within a tier, ceilings never observed rates) and the bindings add no policy. -OBS adopting the surface is [OBS rate control](/quest/m2/obs-moq-video/rate-control.md). +Boundaries: `rs/moq-net/src/model/bandwidth.rs` is the spec, as it is for +[#2709](/quest/m1/2709-per-broadcast-bandwidth-estimates-and-reservation.md); +the bindings add no policy. OBS adopting the surface is +[OBS rate control](/quest/m2/obs-moq-video/rate-control.md). ## Plan `MoqBroadcastProducer` holds only the broadcast, and one origin can serve several sessions, so a "follow this connection" flag on the publish call has no -connection to follow; the handle comes from the session. On dev -`rs/moq-net/src/model/bandwidth.rs` provides `Allocator::new(estimate)`, +connection to follow; the handle comes from the session. + +What the tree has today. `Allocator::new(estimate)` / `unlimited()`, `reserve(&track::Demand, max) -> Reservation`, and -`Reservation::{peek, consumer, update}`, and `Session::send_bandwidth()` is the -estimate. Today `rs/moq-ffi/src/video.rs` and `audio.rs` never set -`Options::bandwidth`, and the estimate reaches a binding only as the -`send_rate_bps` snapshot on the connection stats. +`Reservation::{peek, consumer, update}` (`bandwidth.rs:184-343`). moq-ffi's +`encode_video` (`rs/moq-ffi/src/video.rs:327-363`) builds a +`moq_video::encode::Config`, opens a `Sink`, and wraps it in +`Producer::with_track` / `Producer::new`; it never touches +`moq_video::encode::Options`, whose `bandwidth: Allocator` field +(`rs/moq-video/src/encode/producer.rs:246`) is capture-gated +(`rs/moq-video/src/encode/mod.rs:40-41`) and drives the capture loop only. +The audio side does build `moq_audio::encode::Producer` from an `Options` +carrying an allocator (`rs/moq-ffi/src/audio.rs:254`, +`rs/moq-audio/src/encode/producer.rs:62`) but leaves it unlimited. The +estimate reaches a binding only as the `send_rate_bps` snapshot on +`MoqConnectionStats` (`rs/moq-ffi/src/session.rs:539-543`). - moq-ffi: `MoqSession::bandwidth() -> MoqBandwidth` returns a handle to the one allocator the session owns, so every handle shares one reservation - registry. The allocator consumes the live `bandwidth::Consumer`, and because a - moq-ffi session reconnects on its own, that consumer is the reconnecting - one: it reports no estimate while disconnected and resumes on the next - connection, and reservations survive the gap. Two ways in, because a - reservation needs the track's demand and the built-in encoders create their - track inside the publish call: an app-owned encoder calls - `MoqBandwidth::reserve(track, max_bps) -> MoqReservation` on a track producer - it already holds, while the video and audio publish options take the - `MoqBandwidth` handle and the publish call reserves at the configured bitrate - itself, exposing the result as `producer.reservation()`. - `MoqReservation::grant() -> Option` is the current share as a snapshot - (an encoder that asks before each frame needs nothing more); `None` means the - allocator has no estimate or the track is not demanded, hold the current - rate, and `Some(0)` is a real zero grant, exactly the distinction - `Reservation::peek` draws. `update(max_bps)` moves the ceiling and dropping - the reservation releases the share. The built-in video encoder feeds the - reservation's consumer to `Options::bandwidth` unchanged, so the same `None` - versus zero semantics reach `rate::Control`; audio registers the reservation - and ignores the grant until #2848 lands. `set_bitrate` stays as the manual + registry. `MoqSession::Inner` is a client `moq_tokio::Connection` or an + accepted `moq_net::Session` (`session.rs:605-609`), and their estimates + differ in type: `Connection::send_bandwidth()` is an infallible, + reconnect-surviving consumer (`rs/moq-tokio/src/connection.rs:998`), while + `Session::send_bandwidth()` is an `Option` + (`rs/moq-net/src/session.rs:99`), so the accepted side mints + `.map(Allocator::new).unwrap_or_else(Allocator::unlimited)`. On the client + side the consumer reports `None` while disconnected and resumes on the next + connection, and reservations survive the gap. +- Two ways in, because a reservation needs the track's demand and the + built-in encoders create their track inside the publish call: an app-owned + encoder calls `MoqBandwidth::reserve(track, max_bps) -> MoqReservation` on a + track producer it already holds, while the video and audio publish options + take the `MoqBandwidth` handle and the publish call reserves at the + configured bitrate itself, exposing the result as `producer.reservation()`. + `MoqReservation::grant() -> Option` is `Reservation::peek`: `None` + means no estimate or no demand, hold the current rate, and `Some(0)` is a + real zero grant. `update(max_bps)` moves the ceiling and dropping the + reservation releases the share. +- The built-in video encoder's follow loop runs in moq-ffi: a task reads the + reservation's `consumer()`, feeds it through `moq_mux::rate::Control` (the + policy moq-video uses, moved there by #2848), and applies each target with + `MoqVideoProducer::set_bitrate` (`video.rs:298-307`), retiring on + `BitrateUnsupported` exactly as moq-video does + (`rs/moq-video/src/encode/producer.rs:383-386`). The audio publish call + passes the handle's allocator into `Options::bandwidth`, so it reserves and + follows whenever the Rust Producer does. `set_bitrate` stays as the manual ceiling. - libmoq: `moq_session_bandwidth`, `moq_bandwidth_reserve`, `moq_reservation_grant`, `moq_reservation_update`, `moq_reservation_close`, a bandwidth-handle parameter on the raw video and audio publish calls, and a reservation accessor on their producers. Regenerate `moq.h`. -- Wrappers `py/moq-rs`, `swift`, `kt`, `go/wrapper/moq` and +- Wrappers `py/moq-rs`, `swift`, `kt`, `go/wrapper/*.go` and `doc/lib/{py,swift,kt,go,c}` per the Cross-Package Sync table; dart after dev merges. Run `just test smoke-full`. - Tests: two video producers on one session reserving 4 and 2 Mbps against a @@ -71,5 +90,5 @@ Branch from dev. ## Related - [#2709](/quest/m1/2709-per-broadcast-bandwidth-estimates-and-reservation.md) - the same allocator mirrored in js/net -- [#2848](/quest/m1/2848-follow-the-bandwidth-grant-in-moq-audio-instead-of.md) - audio following its grant in Rust -- [Ladder](/quest/m1/ladder/README.md) - the transcode consumer of the same estimate +- [#2848](/quest/m2/2848-follow-the-bandwidth-grant-in-moq-audio-instead-of.md) - audio following its grant in Rust, and the policy's move to moq-mux +- [Ladder](/quest/m2/ladder/README.md) - the transcode consumer of the same estimate diff --git a/quest/m1/cluster-construction.md b/quest/m1/cluster-construction.md index 049a98ab77..c33e147bd0 100644 --- a/quest/m1/cluster-construction.md +++ b/quest/m1/cluster-construction.md @@ -8,24 +8,31 @@ publishers. Callers do not need to know the order of origin-rebuilding methods. ## Plan -`rs/moq-relay/src/cluster.rs` constructs an origin in `Cluster::new`, exposes -it through the public `origin` field, then constructs another in `with_cache`. -It retains `info` alongside the live origin to support that replacement and -rebinds `nodes` afterward. A caller can clone `cluster.origin` or attach stats -before calling `with_cache`; those handles keep the old origin. Consuming -`self` in the builder does not prevent this because the origin is cloneable. -`Relay::load` orders these calls correctly, but the public API only documents -the prerequisite that the origin must still be pristine. +`rs/moq-relay/src/cluster.rs` constructs an origin in `Cluster::new` (:661), +exposes it through the public `origin` field, then constructs another in +`with_cache` (:700-709). It retains `info` alongside the live origin to +support that replacement and rebinds `nodes` afterward. A caller can clone +`cluster.origin` or attach stats before calling `with_cache`; those handles +keep the old origin. Consuming `self` in the builder does not prevent this +because the origin is cloneable. The two call sites, `Relay::load` +(rs/moq-relay/src/relay.rs:217) and the cache test helper +(rs/moq-relay/src/cache.rs:269), order the calls correctly, but the public API +only documents the prerequisite that the origin must still be pristine. - Put origin-defining settings into construction, using one options struct with defaults. Construct the origin and its node view once, after the cache - pool, retention ceiling, identity, and linger are known. + pool, retention ceiling, and identity are known. `linger` is not one of + them: it is a deprecated, hidden no-op that only logs a warning + (cluster.rs:485-494, :670-675), so drop it rather than carry it into the + options. - Delete the origin-rebuilding `with_cache` path and any stored construction state that has no remaining purpose. Keep builders that only attach independent services if they do not invalidate existing handles. -- Migrate `Relay::load` and audit external embedder usage before removing the - published method. This is a `dev` change. Do not add a compatibility shim - that retains the same origin replacement hazard. +- Migrate both call sites and audit external embedder usage before removing + the published method. This is a `dev` change. Do not add a compatibility + shim that retains the same origin replacement hazard. +- Update the docs that name `with_cache`: the governor paragraph in + doc/bin/relay/config.md:193 and the `Cache` doc comment at cache.rs:74. - Verify that configured cache settings reach the same origin used by serving, node discovery, and stats. Cover the API shape at compile time where possible and exercise publish/consume through a retained origin handle. diff --git a/quest/m1/config-provenance.md b/quest/m1/config-provenance.md index 9a5d0e11bf..d933fb6abd 100644 --- a/quest/m1/config-provenance.md +++ b/quest/m1/config-provenance.md @@ -4,47 +4,54 @@ `moq-relay` and `moq-bench` resolve configuration with recorded provenance. Only a value the command line or the environment actually supplied overrides -the TOML file; a list the file sets to empty, a flag it sets to false, and a -field the parser merely defaults all survive the merge. Precedence is -`CLI > env > file > defaults`, declared in one place, and the relay can say -which source set a setting. +the TOML file: a list the file sets to empty survives the merge, and a secret +injected by an orchestrator beats a placeholder in a checked-in file. +Precedence is `CLI > env > file > defaults`, declared in one place, and the +relay can say which source set a setting. ## Plan -Two shipped defects share one cause. The merge parses CLI plus environment -plus defaults, overlays the file, then re-parses argv so explicit flags win. -Presence is inferred from the value, so "set to the empty list", "set to -false", "set to the declared default", and "never set" are indistinguishable: - -- A `Vec` reads empty when it has no items, so a file that deliberately - sets `version = []` is refilled from the environment. Roughly fifteen - env-bound list fields are affected: TLS certificate, key, and root lists, - accepted versions on both sides, the Unix peer allowlists, cluster peers, - auth domains, and the web HTTPS material. -- The final re-parse reapplies every declared default, so a flag with a - default (`websocket.delay = "200ms"`, `web.ws`, the backoff fields, the log - level, the client bind) overwrites what the file said. `web.ws = false` - becoming `true` re-exposes a listener the operator disabled. -- The file outranks the environment, which no comparable tool does and which - means a secret injected by an orchestrator cannot override a placeholder in - a checked-in file. - -The `Option` convention that patched the boolean half is a per-field -workaround; `Option>` would multiply it. Fix the merge instead of the -types: - -- Build the layers from what the parser saw rather than from the parsed - struct. `usage-config`'s `CliLayer`, `EnvLayer` over a registry that - declares which key each variable backs, and `Layers` in caller-chosen order - give exactly that, plus provenance. The cost is real: `usage::Cli` and - `usage::Config` reject each other's attributes, so every merged setting is - declared a second time, about a hundred for the relay and twenty for the - bench, with `Registry::drift` as the test that the two declarations stay in - step. An all-optional overlay struct is the fallback if the registry proves - too heavy; either way, presence comes from the source, never the value. +`Config::parse_and_merge` (rs/moq-relay/src/config.rs:197-233) parses CLI plus +environment plus defaults, overlays the TOML file, then re-parses argv with +`update_from` so explicit flags win. Presence is inferred from the standing +value, so the re-parse refills whatever reads as empty: + +- A `Vec` reads empty when it has no items (usage-derive 6.3.0 + `standing_presence`, codegen.rs:3591), so a file that deliberately sets + `version = []` is refilled from the environment. About fifteen env-bound + list fields are affected: `moq-tokio`'s TLS roots, fingerprints, certs, + keys, and generated names (rs/moq-tokio/src/tls.rs:435,471,1211,1218,1230, + 1277), the accepted versions on both sides (listen.rs:73, connect.rs:495), + the Unix peer allowlists (unix.rs:150,160,171), and the relay's cluster + peers, auth roots and domains, and web HTTPS material + (rs/moq-relay/src/cluster.rs:419, auth.rs:323,472, web.rs:110,124,143). +- A plain scalar always reads as present (codegen.rs:3589), so a flag with a + declared default is safe. A bare `bool` is not, which is why every + TOML-overridable boolean is typed `Option` and resolved in code + (`web.ws`, rs/moq-relay/src/web.rs:62; the regression at config.rs:487-491). + That convention is a per-field workaround; `Option>` would multiply + it. +- The file outranks the environment, which no comparable tool does. + +Fix the merge instead of the types: + +- Enable the `config` feature of the workspace `usage` dependency + (Cargo.toml:201; usage-rs 6.3.0 already ships `usage::Config` and + `usage::config`). Build the layers from what the parser saw rather than + from the parsed struct: `usage::config`'s `CliLayer`, `EnvLayer` over a + `Registry` that declares which key each variable backs, and `Layers` in + caller-chosen order give exactly that, plus provenance. The cost is real: + `usage::Cli` and `usage::Config` reject each other's attributes, so every + merged setting is declared a second time: 44 relay flags plus the 100 + flattened in from `moq-tokio` (`long =` across rs/moq-relay/src and + rs/moq-tokio/src), and moq-bench's 13 own settings plus its three flattened + groups (rs/moq-bench/src/config.rs:81-93). `Registry::drift` is the test + that the two declarations stay in step. An all-optional overlay struct is + the fallback if the registry proves too heavy; either way, presence comes + from the source, never the value. - Flip precedence to `CLI > env > file > defaults` and state it in - `doc/bin/relay/config.md`, replacing the sentence that describes today's - emergent order. + `doc/bin/relay/config.md`, which today only says every key is also a flag + and an environment variable (:8). - Delete the `Option` resolve-in-code convention once the merge no longer needs it, so a plain field is safe again. - Table-driven regression coverage: empty lists, false booleans, optional @@ -57,4 +64,4 @@ Branch from `dev`: the relay config lives on `usage` there. ## Closes - [#3051](https://github.com/moq-dev/moq/issues/3051) - close this issue when the quest finishes -- [#3221](https://github.com/moq-dev/moq/issues/3221) - close this issue when the quest finishes +- [#3221](https://github.com/moq-dev/moq/issues/3221) - the list half is what remains: a file's empty list is still refilled from the environment diff --git a/quest/m1/control-timeout-code.md b/quest/m1/control-timeout-code.md deleted file mode 100644 index 79bbd5cb20..0000000000 --- a/quest/m1/control-timeout-code.md +++ /dev/null @@ -1,41 +0,0 @@ -# [S] A control timeout is not a delivery timeout - -## Goal - -A request stream torn down because the peer never answered resets with a code -that says so, rather than DELIVERY_TIMEOUT. Both wires and both languages agree, -so a relay carrying the reset across a hop does not change what it says. - -## Plan - -`DELIVERY_TIMEOUT` describes content that missed its deadline (draft-20 section -3.3.4, and the same claim moq-lite makes with 0x2). Every local timeout uses it -today, including the ones that never touched content: - -- `js/net/src/ietf/subscriber.ts` aborts the request stream with the - `SUBSCRIBE_OK` timer's `TimeoutError`, and `js/net/src/ietf/publisher.ts` does - the same with the PUBLISH_NAMESPACE response timer. `toStreamCode` in - `js/net/src/error.ts` maps every `TimeoutError` to `StreamCode.DeliveryTimeout`. -- Rust does the same through `From<&Error> for StreamError` in - `rs/moq-net/src/error.rs`: `Error::Timeout` becomes - `StreamError::DeliveryTimeout`. - -So a peer that opens a stream and goes quiet is told its content was late. - -The fix is a condition of its own for "you did not answer", not a rename of the -existing one, since a real delivery deadline still needs DELIVERY_TIMEOUT. The -moq-transport stream registry has no value for it, so the IETF wire says -INTERNAL_ERROR; moq-lite can register one in its reserved range or say the same. -Whichever it says, `rs/moq-net/src/error.rs`, `js/net/src/error.ts` and -`rs/moq-net/src/ietf/error.rs` have to agree, and the moq-lite draft changes with -them if a value is registered. - -Decide first whether the distinction is worth a code at all: a caller that only -logs the reason gains nothing, and INTERNAL_ERROR is already what a peer does -with an unregistered value. If it is not, the fix is instead to stop putting a -control timeout through the delivery mapping and let it be INTERNAL_ERROR -outright, which is a smaller change than a new code. - -## Related - -- [Group overflow](/quest/m1/group-overflow-abort.md) - the other condition whose wire code is under review diff --git a/quest/m1/dart-announce.md b/quest/m1/dart-announce.md deleted file mode 100644 index 4ee7a3b98a..0000000000 --- a/quest/m1/dart-announce.md +++ /dev/null @@ -1,26 +0,0 @@ -# [S] Dart: createBroadcast, announce, and dynamic - -## Goal - -The Dart wrapper, its generated `moq_ffi` bindings, tests, and -`doc/lib/dart` expose the same three announce operations as every other -binding: `createBroadcast` creates an unadvertised broadcast, `announce(route)` -and `unannounce()` flip its exact-path advert, and `dynamic(prefix, route)` -returns the handle that advertises and serves requests. Nothing in Dart -announces on the caller's behalf. - -## Plan - -`dart/` exists only on `main`, while the FFI surface it must mirror lands on -`dev` in the bindings quest, so this cannot start until the two share a tree. -Today `dart/moq/lib/moq.dart` documents `createBroadcast` as create-and-announce -and `dart/moq/test/moq_test.dart` asserts the immediate announcement; both -change with the semantics. Regenerate `dart/moq_ffi` from `rs/moq-ffi`, adapt -the hand-written `dart/moq` wrapper, update the test to create, populate, then -`announce(route)`, add a test for `dynamic(prefix, route)` serving a request, -and update `doc/lib/dart/moq.md`. - -## Required - -- [#3190](/quest/m1/3190-align-origin-broadcast-creation-naming-across-language.md) - the FFI surface this wrapper mirrors -- `dev` has merged into `main`, so `dart/` and the new FFI surface are in one tree diff --git a/quest/m2/duration-marker.md b/quest/m1/duration-marker.md similarity index 58% rename from quest/m2/duration-marker.md rename to quest/m1/duration-marker.md index eaa4d8c562..10944cf2c6 100644 --- a/quest/m2/duration-marker.md +++ b/quest/m1/duration-marker.md @@ -5,37 +5,42 @@ In the legacy (hang) container every video group ends with an empty frame stamped at the exclusive end of its last frame, so a group's last frame has a duration without peeking at the next group, and the LOC consumers are ready -to skip the same marker before LOC producers start writing it. Audio writes no empty -frames: its durations are codec-defined, and the end-of-track marker and its -terminal-packet rule go. CMAF is untouched, since it carries sample durations. -An empty frame is never submitted to a decoder and never means the track -ended. +to skip the same marker before LOC producers start writing it. Audio writes no +empty frames: its durations are codec-defined, and the end-of-track marker and +its terminal-packet rule go. CMAF is untouched, since it carries sample +durations. An empty frame is never submitted to a decoder and never means the +track ended. ## Plan -Today only `rs/moq-audio`'s `publish_terminal` writes an empty frame, as the -first frame of a final group followed by the encoder's flush packets, and the -draft's legacy section defines it as the end marker: consumers discard decoded -samples at or after it (the decode consumer's terminal phase in -`rs/moq-audio/src/decode/consumer.rs`, `js/watch/src/audio/terminal.ts`). -`rs/moq-mux/src/container/consumer.rs` latches `end` sticky from any empty -payload and `js/hang/src/container/consumer.ts` returns it positionally. The -last frame of every group is otherwise timed by a guess: +Today only `rs/moq-audio`'s `publish_terminal` +(`rs/moq-audio/src/encode/producer.rs`) writes an empty frame, as the first +frame of a final group followed by the encoder's flush packets, and the +draft's legacy section (`drafts/draft-lcurley-moq-hang.md:514-518`) defines it +as the end marker: consumers discard decoded samples at or after it (the +decode consumer's terminal phase in `rs/moq-audio/src/decode/consumer.rs`, +`js/watch/src/audio/terminal.ts`). `rs/moq-mux/src/container/consumer.rs` +latches `end` (`:186`) sticky from any empty payload and +`js/hang/src/container/consumer.ts` returns it positionally. The last frame of +every group is otherwise timed by a guess: `rs/moq-mux/src/container/fmp4/fragmenter.rs` times a group's trailing frame by the catalog cadence because a group boundary is never a duration, `rs/moq-hls`'s rendition export accumulates whole segments to see each frame's -successor, and the timeline recorder's `end(pts)` gets a bound only when a -caller passes `cut(Some(end))`. +successor, and the Window-backed timeline from #3240 +(`rs/moq-mux/src/timeline.rs`, `end(pts)` at `:1123`) gets a bound only when +a caller passes `cut(Some(end))` (`rs/moq-mux/src/container/producer.rs:216`), +so the archive index records a guessed final duration. - Draft: the legacy and LOC sections say an empty frame is the exclusive end of the frame before it, a video group ends with one, audio has none, and a consumer skips it. Remove the end-marker and terminal-packet paragraph. - Producers: the `rs/moq-mux` container `Producer` writes the marker at `cut` and `finish` for video tracks, at the caller's bound or the last timestamp - plus its estimated duration; the js/hang container producer does the same; - `publish_terminal` stops writing it and its flush packets become ordinary - frames, so a few ms of encoder padding play at the end unless the consumer - trims by the codec delay. + plus its estimated duration; the js/hang container producer + (`js/hang/src/container/legacy.ts:58`) does the same; `publish_terminal` + stops writing it and its flush packets become ordinary frames, so a few ms + of encoder padding play at the end unless the consumer trims by the codec + delay. - Consumers: the mux consumer records a per-group boundary that feeds `max_end`, the last frame's `duration`, and the timeline recorder, instead of a sticky `end`; the audio terminal phase goes; js/hang and js/watch @@ -48,7 +53,9 @@ caller passes `cut(Some(end))`. `js/loc`) submit an empty payload to the decoder, so this quest lands the LOC consumer-side skip only; LOC producers start writing the marker in [LOC duration marker](/quest/m2/loc-duration-marker.md) once skipping - consumers have shipped. This lands on main. + consumers have shipped. This lands on main. It ranks in m1 because + [gap discontinuity](/quest/m1/gap-discontinuity.md) requires it, and the + dev merge requires that. - Tests: a group's last frame carries the marker's duration through fmp4 export and HLS; an audio track end has no marker and plays out; an old-style audio marker is skipped; CMAF is unchanged. @@ -57,5 +64,4 @@ caller passes `cut(Some(end))`. - [Gap discontinuity](/quest/m1/gap-discontinuity.md) - the reset signal, which this marker is not - [Monotonic timeline](/quest/m1/monotonic-timeline.md) - the forward-only rule the boundary sits under -- [Timeline](/quest/m1/archive/timeline.md) - the archive index that wants honest final durations - [LOC duration marker](/quest/m2/loc-duration-marker.md) - the LOC producer half, gated on a release diff --git a/quest/m1/gap-discontinuity.md b/quest/m1/gap-discontinuity.md index 9e4244f34d..dcd542fa40 100644 --- a/quest/m1/gap-discontinuity.md +++ b/quest/m1/gap-discontinuity.md @@ -1,4 +1,4 @@ -# [M] A gap in the group sequence is a discontinuity unless the boundary proves continuity +# [L] A gap in the group sequence is a discontinuity unless the boundary proves continuity ## Goal @@ -6,39 +6,52 @@ A consumer resets codec state and its timeline whenever the group sequence it delivers has a hole it cannot prove harmless, whatever made it: the publisher skipped a sequence to declare a break, the relay or the subscriber's budget shed a group, or a group was lost. A hole is harmless only when the previous -group's end meets the next group's first timestamp within the rounding -tolerance the js consumer already uses (`CONTIGUITY_TOLERANCE`), which keeps the legal -non-sequential numbering (DTS-derived ids, the HLS importer's packed epoch -bits) playing through. No signal that has to arrive carries the reset, -because moq-lite is lossy and any marker group can be shed before the -container layer sees it. An empty group stops meaning anything. +group's end meets the next group's first timestamp within 1 ms, the +`CONTIGUITY_TOLERANCE` the js consumer already uses +(`js/hang/src/container/consumer.ts:90`), which keeps the legal non-sequential +numbering (DTS-derived ids, the HLS importer's packed epoch bits) playing +through. No signal that has to arrive carries the reset, because moq-lite is +lossy and any marker group can be shed before the container layer sees it. An +empty group stops meaning anything. ## Plan -Today `drafts/draft-lcurley-moq-hang.md` says an empty group declares a -discontinuity between codec epochs, and both container consumers key on it: +Today `drafts/draft-lcurley-moq-hang.md:505-507` says an empty group declares +a discontinuity between codec epochs, and both container consumers key on it: `rs/moq-mux/src/container/consumer.rs` waits for a zero-frame group's FIN in -`GroupBuffer::poll_empty` and bumps the counter in `mark_discontinuities` -(on the latency-skip path too); `js/hang/src/container/consumer.ts` does the -same through `Group.empty` and `#markDiscontinuity`. The one producer is -`rs/moq-mux/src/container/producer.rs` `Producer::discontinuity()`, which -appends and immediately finishes an empty group; its callers are the -h264, h265, and opus importers, `rs/moq-video`'s idle capture path -(`capture_stopped`), `rs/moq-audio`'s deferred `pending_discontinuity`, and -moq-boy. js/publish never emits one. On dev, `TrackState::is_stale` in -`rs/moq-net/src/model/track.rs` sheds a finished empty group under a zero -budget, since its reach is its successor's start, so a live-edge subscriber -never sees the marker and the reset is lost end to end (#3291). +`GroupBuffer::poll_empty` (:753) and bumps the counter in +`mark_discontinuities` (:424, on the latency-skip path at :296 too); +`js/hang/src/container/consumer.ts` does the same through `Group.empty` and +`#markDiscontinuity` (:681). The one producer is +`rs/moq-mux/src/container/producer.rs` `Producer::discontinuity()`, which cuts +the open group and creates an empty one. Its callers are `track.discontinuity()` +in the h264, h265, opus, and aac importers (`rs/moq-mux/src/codec/*/import.rs`) +and the legacy codec producer (`rs/moq-mux/src/codec/legacy.rs:197`), the TS +importer directly (`rs/moq-mux/src/container/ts/import.rs:1093`, `:1185`) and +through those importers, `rs/moq-video`'s idle capture path +(`rs/moq-video/src/encode/producer.rs:410`, `capture_stopped`), `rs/moq-audio`'s +deferred `pending_discontinuity` (`rs/moq-audio/src/encode/producer.rs:344`) and +its flush (`:445`), and moq-boy (`rs/moq-boy/src/audio.rs:54`, +`rs/moq-boy/src/video.rs:125`). js/publish never emits one. On dev, +`TrackState::is_stale` (`rs/moq-net/src/model/track.rs:535`) sheds a finished +empty group under a zero budget, since its reach is its successor's start, so a +live-edge subscriber never sees the marker and the reset is lost end to end +(#3291). The cache sweeps on a wall-clock cadence (#3419), so an idle open +group is reclaimed and the subscriber parked inside it is told; that retention +half of #3161 needs nothing here. - Draft: replace the empty-group sentence with the gap rule. A publisher declares a discontinuity by skipping at least one group sequence. A consumer MUST reset codec state, reapplying startup delay and pre-skip, before decoding the first group after a gap in the sequence it delivers, declared or not, unless the previous group's end meets the next group's first - timestamp within a rounding tolerance of about a microsecond, since a - 1024-sample AAC frame has no integer microsecond duration and independently - rounded stamps differ by one. Video knows its end from the - [duration marker](/quest/m2/duration-marker.md); audio from its + timestamp within 1 ms. That tolerance is normative. Per-frame durations and + group base times are rounded to microseconds independently, so a genuinely + contiguous boundary can land a microsecond off (a 1024-sample AAC frame has + no integer microsecond duration, and 48 kHz audio shows the same drift), + while a missing group spans a group duration, orders of magnitude more; 1 ms + separates the two. Video knows its end from the + [duration marker](/quest/m1/duration-marker.md); audio from its codec-defined frame durations. An empty group is permitted and carries no meaning. Group sequences stay free to be non-sequential. A contiguous boundary is what every ordinary group boundary already is: @@ -47,12 +60,15 @@ never sees the marker and the reset is lost end to end (#3291). encoder replacement on a continuous clock needs nothing more. A publisher that wants the consumer to re-apply startup delay presents a hole. - Consumers, both languages: `discontinuity()` bumps when the delivered - sequence advances by more than one and the boundary is not contiguous - (`ptsContiguous` in the js consumer is the shape, now exact rather than - upper-bound), and the empty-group state machine - (`poll_empty`, `Group.empty`, the FIN wait) goes. The max-age skip path - already resets through the same counter. `rs/moq-audio`'s undeclared-hole - handling from #3386 (`gap()`) becomes the declared path as well. + sequence advances by more than one and the boundary is not contiguous. The + js consumer has the shape in `ptsContiguous` (`consumer.ts:101`), today an + upper bound used by the promotion guard (`:114`), which becomes exact. The + Rust mux consumer has no contiguity check at all and gains one. The + empty-group state machine (`poll_empty`, `Group.empty`, the FIN wait) goes. + The max-age skip path already resets through the same counter. + `rs/moq-audio`'s undeclared-hole handling from #3386 + (`rs/moq-audio/src/decode/consumer.rs:313`, `gap()`) becomes the declared + path as well. - Cost to check: a video decoder reset on every shed group. Measure a WebCodecs reset plus configure, and a VideoToolbox or openh264 reset, on the keyframe that follows a skip. If it is material, make the container-level @@ -67,19 +83,22 @@ never sees the marker and the reset is lost end to end (#3291). - Producers: `Producer::discontinuity()` skips a sequence, and every caller above follows; the importers re-estimate as today. - Tests: the transport-level regression for #3291 (a zero-budget subscriber, - an idle publisher, a resume: the reset happens); the mux tests - `empty_group_declares_a_discontinuity`, - `latency_skip_preserves_empty_group_discontinuity`, and - `empty_group_advances`, with their js equivalents, rewritten for the gap - rule; a shed group resets; an empty group does nothing; a non-sequential id - jump with a contiguous boundary (`consumer.nonsequential.test.ts`) does not - reset. + an idle publisher, a resume: the reset happens). The mux consumer tests + `empty_group_declares_a_discontinuity` (`consumer.rs:939`), + `latency_skip_preserves_empty_group_discontinuity` (`:961`), and + `empty_group_advances` (`:2499`), with their js equivalents, rewritten for + the gap rule. The producer tests `discontinuity_is_not_measured_across` + (`producer.rs:469`), `discontinuity_publishes_an_empty_group` (`:559`), and + `discontinuity_moves_the_live_edge_off_stale_content` (`:583`) rewritten to + assert the skipped sequence. A shed group resets; an empty group does + nothing; a non-sequential id jump with a contiguous boundary + (`consumer.nonsequential.test.ts`) does not reset. Branch from dev, where `is_stale` and the current consumers live. ## Required -- [Duration marker](/quest/m2/duration-marker.md) - a video group has to know its own end before a boundary can prove continuity +- [Duration marker](/quest/m1/duration-marker.md) - a video group has to know its own end before a boundary can prove continuity ## Closes @@ -88,8 +107,4 @@ Branch from dev, where `is_stale` and the current consumers live. ## Related - [Monotonic timeline](/quest/m1/monotonic-timeline.md) - requires this: a declared discontinuity is what a forward-only timeline continues from -- [#3056](/quest/m1/3056-watch-video-decoder-captures-the-rewind-generation-at.md) - the watch decoder reset that fires on the counter - -The retention half of timestamp-only expiry (#3161) landed on dev as #3419: the -cache sweeps on a wall-clock cadence, so an idle open group is reclaimed and the -subscriber parked inside it is told. +- [#3056](/quest/m2/3056-watch-video-decoder-captures-the-rewind-generation-at.md) - the watch decoder reset that fires on the counter diff --git a/quest/m1/go-native-context.md b/quest/m1/go-native-context.md index cc3ec04328..f2f8244fee 100644 --- a/quest/m1/go-native-context.md +++ b/quest/m1/go-native-context.md @@ -9,24 +9,27 @@ cancellation token and the generated layer cancels the native task directly. ## Plan Today every affected `moq-ffi` method takes a trailing -`Option>` purely so the Go wrapper has something to cancel: +`Option>` (rs/moq-ffi/src/cancel.rs:18, applied through +`cancel::guard` at :44) purely so the Go wrapper has something to cancel: `uniffi-bindgen-go` renders a Rust `async fn` as a blocking Go call with no cancellation handle at all. The token works and costs the other bindings nothing (it is additive, with a UniFFI argument default), but it exists only to work around the generator. -A fork of `kixelated/uniffi-bindgen-go` adding native `context.Context` -support already exists locally, on branch `codex/context-cancel-3188`. -Landing it means publishing that branch and pinning the new rev in -`flake.nix`, after which the token becomes redundant for Go and can be removed -from the `moq-ffi` surface. - -Decide first whether the fork is worth carrying. Keeping the token is a -supported outcome: it is already shipped and costs nothing outside Go, and a -pinned generator fork is a maintenance obligation on every UniFFI bump. Nobody -should start this until that call is made. +The generator is already our fork: `kixelated/uniffi-bindgen-go` carries the +uniffi 0.32 port that upstream lacks (upstream's latest is v0.7.1+v0.31.0; +flake.nix:267-272). The `context.Context` support sits on that fork's +`codex/context-cancel-3188` branch. Landing it means tagging a release from +that branch and bumping the pin in the six files that name the generator +version together: flake.nix:273-281, `.github/workflows/release-go-ffi.yml` +(repo and revision at :30-31), rs/moq-ffi/build.sh:175, go/ffi/README.md:30-31, +go/scripts/check.sh:33, and go/scripts/stage.sh:60. After that the token is +redundant for Go and comes off the `moq-ffi` surface on `dev`. ## Required -- The `context.Context` support in `kixelated/uniffi-bindgen-go` is published and pinned in `flake.nix` +- `kixelated/uniffi-bindgen-go` tags a release from `codex/context-cancel-3188` + +## Closes +- [#3188](https://github.com/moq-dev/moq/issues/3188) - close this issue when the quest finishes diff --git a/quest/m1/group-overflow-abort.md b/quest/m1/group-overflow-abort.md index 4558aa6627..46b88625e2 100644 --- a/quest/m1/group-overflow-abort.md +++ b/quest/m1/group-overflow-abort.md @@ -10,22 +10,21 @@ delete. The writer learns about its own overrun instead of being told nothing. A remote peer can tell the overrun apart from its own lag from either publisher. js/net maps a locally raised error to its stream code in `withCode` -(`js/net/src/stream.ts`, via `toStreamCode` in `error.ts`), so the new code -needs an entry in `StreamCode` and a class carrying it, the way `Lagged` does, -rather than any new plumbing. +(`js/net/src/stream.ts:10`, via `toStreamCode` in `error.ts:279`), so the new +code needs an entry in `StreamCode` (`js/net/src/error.ts:71-95`) and a class +carrying it, the way `Lagged` does, rather than any new plumbing. -This is a semantics change to the model in both languages, not a bug fix. It -was settled in a planning pass; the decisions are recorded below rather than -left open. +This is a semantics change to the model in both languages, not a bug fix. ## Plan -Today `rs/moq-net` (`model/group.rs`, `MAX_CACHE_BYTES`) and `js/net` -(`group.ts`, `MAX_GROUP_CACHE_BYTES` plus `MAX_GROUP_FRAMES`) evict from the -front of an open group once it passes the cap, and a reader positioned below -the eviction fails with `Lagged`. Readers at or above it keep going, which the -draft-20 IETF publisher path uses to serve a filter whose range excludes the -evicted prefix. +Today `rs/moq-net` (`model/group.rs:34`, `MAX_CACHE_BYTES`, evicted by +`evict()` at `:274`) and `js/net` (`group.ts:12`, `MAX_GROUP_CACHE_BYTES`, plus +`MAX_GROUP_FRAMES` at `:15`, the loop at `:97-102`) evict from the front of an +open group once it passes the cap, and a reader positioned below the eviction +fails with `Lagged`. Readers at or above it keep going, which the draft-20 +IETF publisher path uses to serve a filter whose range excludes the evicted +prefix. ### Decided @@ -40,80 +39,95 @@ evicted prefix. shape `FrameTooLarge` already has in `write_frame`. Today `evict()` returns `()` and every write path returns `Ok(())` regardless, so the producer is told nothing. -- **A new stream error code** beside `TooFarBehind` (`0x5`) and - `FrameTooLarge` (`0x25`), so a remote subscriber can tell the failure apart. - That is a wire change, so `drafts/draft-lcurley-moq-lite.md` is updated in - the same PR. `Error::to_code` has a stability test (`to_code_is_stable`) that - pins the local codes; add the new one there. +- **A new stream error code, GROUP_TOO_LARGE = 0x32**, in moq-lite's own + 48-63 range (`drafts/draft-lcurley-moq-lite.md:268`; 0x30 NO_CAPACITY is + taken at `:311`, and 0x31 goes to + [control timeout](/quest/m2/control-timeout-code.md)). Codes 32-47 are + non-interpretable placeholders (`:263-265`), which is why `FrameTooLarge` + (0x25) says nothing to a peer; this one is assigned. Add the draft row and + `StreamError::GroupTooLarge` in `rs/moq-net/src/error.rs`, and extend + `stream_codes_round_trip` (`error.rs:691`): the new code joins the + registered list rather than the reserved-range loop at `:707-715`, which + asserts the 0x20-0x3f codes decode to `Unknown`. `StreamCode` in js/net + lacks `NoCapacity: 0x30` as well; [js-announce](/quest/m1/js-announce.md) + adds it, and whichever lands first carries it. - **A frame-count cap in both languages, at 8192.** JS caps at 1024 today and Rust has no count cap at all, so a JS publisher dies where an identical Rust one holds 100,000 frames. 8192 gives JS eight times its current headroom and - closes the divergence. The Rust test `no_eviction_under_budget` writes - exactly 100,000 one-byte frames to assert there is no count cap; it is - rewritten rather than deleted, since the byte-budget half of what it proves - still holds. -- **The IETF wire keeps its own mapping.** `rs/moq-net/src/ietf/error.rs` picks - the registered value for the negotiated draft, and it registers nothing for - this condition, so the moq-transport wire says INTERNAL_ERROR. - -### What actually gets deleted - -Less than the original sketch claimed, so budget for it: + closes the divergence. The Rust test `no_eviction_under_budget` + (`group.rs:2041`) writes exactly 100,000 one-byte frames to assert there is + no count cap; it is rewritten rather than deleted, since the byte-budget half + of what it proves still holds. +- **The IETF wire keeps its own mapping.** `rs/moq-net/src/ietf/error.rs:78` + `to_stream_code` picks the registered value for the negotiated draft and + falls through to INTERNAL_ERROR (`:92`) for everything else, so the + moq-transport wire says INTERNAL_ERROR. js/net gates the same way through + `sharedStreamCode` (`js/net/src/error.ts:281`). + +### What gets deleted - Rust: the `offset += 1` inside `evict()` and the `evict()` calls in `write_frame`, `write_frames`, `create_frame`, and `create_frame_owned`. `GroupState::offset` itself **stays**: it is also the `Producer::start_at` - floor, read through `live_first_frame()` by `track.rs` (`covering_group`, - `claim_sequence`) and by `resume.rs` route splicing. The two `Error::Lagged` - returns guarding `index < offset` stay for the same reason. -- JS: `state.evicted` and the eviction loop in `appendFrame`. `state.start` - **stays**: `#readBufferedFrame` increments it on every read, so it is the - running sequence counter, not an eviction floor. -- `group::Consumer::skip_to` and `Group.ReadOptions.from` **stay**. They are - doing range work, not eviction work: a draft-20 filter still has to begin at - `slice.skip` even when nothing was evicted. Only their eviction tolerance - goes, which is the clamping difference against `start_at`. Deleting the - shared cursor would push a drain loop into every publisher instead, and that - duplication is exactly what `write_fill_group` already drifted into once, as - the next section documents. `Consumer.skipped` in JS and the `js/binary` - guard on it do go, since those report eviction and nothing else. + floor, read through `live_first_frame()` (`group.rs:798`) by + `covering_group` (`track.rs:443`) and `claim_sequence` (`:789`), which route + splicing relies on. The two `Error::Lagged` returns guarding + `index < offset` (`:215`, `:261`) stay for the same reason. +- JS: `state.evicted` (`group.ts:75`) and the eviction loop in `appendFrame`. + `state.start` **stays**: `#readBufferedFrame` increments it on every read, + so it is the running sequence counter, not an eviction floor. +- `group::Consumer::skip_to` (`group.rs:1359`) and `Group.ReadOptions.from` + **stay**. They are doing range work, not eviction work: a draft-20 filter + still has to begin at `slice.skip` even when nothing was evicted. Only their + eviction tolerance goes, which is the clamping difference against + `start_at`. Deleting the shared cursor would push a drain loop into every + publisher instead, and that duplication is exactly what `write_fill_group` + drifted into once, as the next section documents. `Consumer.skipped` in JS + and the guards on it in `js/json/src/stream/consumer.ts:101` and + `js/binary/src/stream/consumer.ts:105` do go, since those report eviction + and nothing else. ### Fold in: the reverted skip_to `f6376ed32` (#3323) added `skip_to` at two sites in -`rs/moq-net/src/ietf/publisher.rs` plus a regression test -(`skip_to_tolerates_an_eviction_below_it`). The merge commit `6947217fc` -("Merge main into dev") silently dropped one call site and the test, so -`write_fill_group` is back to the pre-fix drain-below-`skip` form and still -fails with `Lagged` on an eviction confined below the filter start. This quest -deletes that case outright, so restore nothing: instead confirm the fill path -ends up correct under the new semantics, and say in the PR that the reverted -fix was superseded rather than lost a second time. JS kept both call sites -(`ietf/publisher.ts` `#runGroup` and `#runFill`). +`rs/moq-net/src/ietf/publisher.rs` plus two regression tests, +`skip_to_tolerates_an_eviction_below_it` in `group.rs` and +`run_group_serves_the_tail_of_an_evicted_head` in `ietf/publisher.rs`. The +merge commit `6947217fc` ("Merge main into dev") silently dropped one call +site and both tests: today `skip_to` is called once (`publisher.rs:1752`), +neither test exists, and `write_fill_group` (`:719`) is back to the pre-fix +drain-below-`skip` form that fails with `Lagged` on an eviction confined below +the filter start. This quest deletes that case outright, so restore nothing: +confirm the fill path ends up correct under the new semantics, and say in the +PR that the reverted fix was superseded rather than lost a second time. JS +reads from `fill.skip` through `readFrameSequence({ from })` +(`js/net/src/ietf/publisher.ts:571`). ### Coverage -Rust `group.rs` tests to update: `eviction_drops_old_frames`, -`next_frame_returns_cache_full_on_tombstone`, `no_eviction_under_budget`. -Leave the `start_at` tests alone, since that floor survives. JS `group.test.ts` -tests to update: the two cap tests, `"a caught-up reader does not trip the byte -cache cap"`, `"reading a group whose frames were evicted throws Lagged"`, and -`"a read that starts above the eviction window skips the gap instead of -throwing"`. `js/net/src/broadcast.test.ts` and -`js/net/src/ietf/publisher.test.ts:886` also lean on eviction. Add a test that -the writer sees `GroupTooLarge`, which nothing covers today. - -`js/json/src/window/encoder.ts` and the Rust `snapshot` and `window` -encoders keep their own `MAX_GROUP_FRAMES = 256` roll trigger, sized "well -below moq-net's per-group frame cap", which today means js/net's 1024. Once -both languages cap at 8192, raise those roll caps in step (1024 or more, and -still under 8192 so a roll always precedes `GroupTooLarge`) so a window -timeline restates its checkpoint less often; the caps are self-imposed and a -roll is invisible to a window consumer. +Rust `group.rs` tests to update: `eviction_drops_old_frames` (`:2011`), +`next_frame_returns_cache_full_on_tombstone` (`:2027`), +`no_eviction_under_budget` (`:2041`). Leave the `start_at` tests alone, since +that floor survives. JS `group.test.ts` tests to update: the two cap tests +(`:33`, `:51`), `"a caught-up reader does not trip the byte cache cap"` +(`:68`), `"reading a group whose frames were evicted throws Lagged"` (`:79`), +and `"a read that starts above the eviction window skips the gap instead of +throwing"` (`:91`). `js/net/src/track.test.ts:2` and +`js/net/src/broadcast.test.ts:3` import `MAX_GROUP_FRAMES`, and +`js/net/src/ietf/publisher.test.ts:886` leans on a trimmed head. Add a test +that the writer sees `GroupTooLarge`, which nothing covers today. + +Only the window encoders carry their own roll trigger: +`rs/moq-json/src/window/encoder.rs:16` and `js/json/src/window/encoder.ts:5` +set `MAX_GROUP_FRAMES = 256`, sized well below js/net's 1024. Once both +languages cap at 8192, raise those two in step (1024 or more, and still under +8192 so a roll always precedes `GroupTooLarge`) so a window timeline restates +its checkpoint less often; the caps are self-imposed and a roll is invisible +to a window consumer. ### The benchmark breaks -`rs/moq-net/benches/group.rs` sweeps `COUNTS = [512, 8_192, 32_768]` and +`rs/moq-net/benches/group.rs:30` sweeps `COUNTS = [512, 8_192, 32_768]` and unwraps every write (`write_frames(..).unwrap()` and the prefill paths), so the 32,768 case panics the moment a Rust count cap exists and `just bench` fails. Adjust the sweep, or benchmark the rejection deliberately, as part of this @@ -122,9 +136,10 @@ quest rather than discovering it afterwards. The middle case sits exactly on the proposed cap, so settle the boundary and say it in the doc comment: 8192 frames is the largest legal group, and the 8193rd write is the one that returns `GroupTooLarge`. The bench's own comment -already claims its top end "intentionally reaches the raised -`MAX_GROUP_FRAMES`", which is stale: Rust has no such constant today. +(`:29`) claims its top end reaches `MAX_GROUP_FRAMES`, which Rust does not +define today; fix it with the sweep. ## Related -- [Control timeout code](/quest/m1/control-timeout-code.md) - the other condition whose wire code is under review +- [Control timeout code](/quest/m2/control-timeout-code.md) - takes 0x31, the neighbouring code in the same range +- [JS announce](/quest/m1/js-announce.md) - adds `StreamCode.NoCapacity`, the other missing js/net code diff --git a/quest/m1/hls-cache-miss-codes.md b/quest/m1/hls-cache-miss-codes.md index 9519707c7b..a08e96ef89 100644 --- a/quest/m1/hls-cache-miss-codes.md +++ b/quest/m1/hls-cache-miss-codes.md @@ -1,50 +1,76 @@ -# [S] moq-hls reads cache misses against a table the wire stopped using +# [M] moq-hls answers 404 for cache misses and gone publishers ## Goal -`moq-hls` answers 404 when the decoded error identifies a cache miss, and 500 -for genuine failures or an IETF stream reset that cannot distinguish a miss. -Returning 404 over IETF depends on the request-error path preserving that -classification. Decode using the negotiated registry so classification cannot -drift from `moq-net` again. +`moq-hls` answers 404 for a segment the relay cannot serve because the group +is not cached (not found, old, evicted) or because its publisher is gone, and +500 only for a genuine failure. On a moq-lite upstream the classification +comes from registered stream codes decoded by `moq-net`, so it cannot drift +again. An IETF upstream cannot say a miss on a stream reset and still answers +500 there. ## Plan -`is_cache_miss` in `rs/moq-hls/src/export/rendition.rs` compares a wire code -against `moq_net::Error::to_code()`: +### Cache misses -```rust -let code = err.to_code(); -code == moq_net::Error::NotFound.to_code() // 13 - || code == moq_net::Error::Old.to_code() // 2 - || code == moq_net::Error::Evicted.to_code() // 31 -``` +`is_cache_miss` (`rs/moq-hls/src/export/rendition.rs:591-596`) compares the +wire code against `moq_net::Error::to_code()`, the crate's legacy table +(`rs/moq-net/src/error.rs:422-459`: `Old` 2, `NotFound` 13, `Evicted` 31). No +reset has carried that table since `StreamError` replaced it. A miss now goes +out as 0x20, 0x22, or 0x23 (`error.rs:210-213`), values in moq-lite's +non-interpretable 32-47 range (`drafts/draft-lcurley-moq-lite.md:263-265`), so +the receiver decodes them to `StreamError::Unknown` and surfaces +`Error::Remote(32|34|35)` (`error.rs:231-246`, `:529-530`). An IETF upstream +collapses all three to INTERNAL_ERROR and back to `Remote(0)` +(`rs/moq-net/src/ietf/error.rs:80-92`, `:105`). Nothing matches, so every miss +that crossed a session, which in a relay is all of them, answers 500 +(`rs/moq-hls/src/server/routes.rs:261-264`). -That table is the crate's own legacy numbering, and no stream reset has carried -it since #2620 replaced it with the `StreamError` registry. A remote miss now -arrives as `Error::Remote(0x20 | 0x22 | 0x23)` on a moq-lite wire, and as -`Error::Remote(0)` on a moq-transport one, since that registry has no value for -any of the three. None of those match, so every miss that crossed a session, -which in a relay is all of them, is served as a 500 instead of a 404. +The legacy literals collide with IETF request errors instead: an unregistered +request code such as 0xD or 0x1F decodes to `Remote(code)` +(`ietf/error.rs:286`) and matches `NotFound` (13) or `Evicted` (31). A peer's +DELIVERY_TIMEOUT (0x2) does not collide: it decodes to `Error::Timeout`, whose +legacy code is 3, not `Old`'s 2. #3531 already decodes DOES_NOT_EXIST to +`Error::NotFound` (`ietf/error.rs:282`). -Worse, one value collides: `Error::Old.to_code()` is 2, which is DELIVERY_TIMEOUT -on both wires, so a peer's delivery timeout classifies as a cache miss. - -The tests do not catch it because they build the remote shape out of the same -stale table (`Error::Remote(local.to_code())`), so they agree with the code -rather than with the wire. +The tests build the remote shape from the same stale table +(`rendition.rs:614-637`, `Error::Remote(local.to_code())`), so they agree with +the code rather than the wire. The work: -- Classify on the decoded error, not a hand-compared code. `StreamError` already - names `NotFound`, `Old`, and `Evicted`, so the fix is for `moq-net` to keep - them named through `Error` rather than flattening them into `Remote(code)`, - and for `moq-hls` to match variants. -- Decide what a moq-transport upstream can say at all: that registry has no - value for a cache miss on a stream reset, so a relay fetching over it cannot - distinguish one from a failure. Either the miss travels as a request error - rather than a stream reset, or the 500 is correct there and only the moq-lite - path is fixable. -- Rewrite the tests to build the remote shape from the wire registry - (`StreamError::to_code`, `ietf::error::to_stream_code`) so they fail when the - two drift again. +- Register NOT_FOUND, OLD, and EVICTED (or one CACHE_MISS) in moq-lite's own + 48-63 range (`draft-lcurley-moq-lite.md:267-268`; the stream table at + `:292-312` assigns only NO_CAPACITY 0x30 there). Encode and decode them in + `StreamError` so a received one is the named variant, not `Unknown`, and + extend `stream_codes_round_trip` (`error.rs:691`). Mirror in + `js/net/src/error.ts` `StreamCode` (:88-92 still carries the 0x20 values). + They are lite-only: `js/net/src/ietf/error.ts` `sharedStreamCode` keeps + treating them as unshared, pinned by a negative test, so no unassigned lite + value ever reaches a moq-transport peer. +- `is_cache_miss` matches variants (`NotFound`, `Old`, `Evicted`) and nothing + by code. Rebuild the tests from the wire registry (`StreamError::to_code`, + `ietf::error::to_stream_code`) so they fail when the two drift. +- IETF upstreams keep answering 500 for a stream-reset miss; that registry has + no value for one. Say so in the doc comment. + +### Gone publishers + +A publisher that disconnects resets the fetch with code 0, which arrives as +`Error::Remote(0)` on dev (`error.rs:233`, `:529`) and as `Error::Cancel` on +main. Observed against a local relay: with a rendition bound to a publisher +that then disconnected, every segment still in the playlist window answered +500 (`hls request failed err=moq: remote error: code=0`) until it aged out. +The playlist keeps listing them because the timeline it renders from is a +different track, often a different broadcast, from the media +(`rs/moq-mux/src/source.rs:189-199`). + +500 tells a CDN or player to retry a segment that can never be served; 404 +says it is gone. Name that condition beside the misses rather than inverting +the test into "is this retryable", which the root guide forbids. Reachable on +both rendition shapes (the catalog's own broadcast and a named sibling), since +both hold the broadcast the catalog was read from. + +Land both with regressions: a miss that crossed a session, and a bound +rendition whose publisher disconnected, each answer through +`routes.rs:255-259`. diff --git a/quest/m1/hls-closed-publisher-500.md b/quest/m1/hls-closed-publisher-500.md deleted file mode 100644 index 64c384aa67..0000000000 --- a/quest/m1/hls-closed-publisher-500.md +++ /dev/null @@ -1,30 +0,0 @@ -# [S] moq-hls: a segment whose publisher is gone answers 500, not 404 - -## Goal - -An HLS/DASH segment whose media broadcast has closed answers `404`, like every -other "that group is not available" case, instead of `500`. - -## Plan - -`export::rendition::is_cache_miss` classifies a group fetch failure as a 404 -only for the `NotFound`, `Old`, and `Evicted` wire codes. A publisher that -disconnects resets the fetch with code 0, which arrives as -`moq_net::Error::Remote(0)`, so `Rendition::segment` returns `Err` and the -serve path answers `500`. - -Observed end to end against a local relay: with a rendition bound to a -publisher that then disconnected, every segment still listed in the playlist -window answered `500` (`hls request failed err=moq: remote error: code=0`) -until it aged out of the window. The playlist keeps listing those segments -because the timeline it renders from is a different track, and often a -different broadcast, from the media. - -Both statuses are wrong in different directions, so decide deliberately: `500` -tells a CDN or player to retry a segment that can never be served, while `404` -says it is gone. Reaching for a broader "is this retryable" classification is -what the root guide warns against, so name the codes that mean the media is -gone rather than inverting the test. - -Reachable on both rendition shapes (the catalog's own broadcast and a named -sibling), since both hold the broadcast the catalog was read from. diff --git a/quest/m1/hls-playable.md b/quest/m1/hls-playable.md deleted file mode 100644 index 0020b5d546..0000000000 --- a/quest/m1/hls-playable.md +++ /dev/null @@ -1,31 +0,0 @@ -# [M] Playable - -## Goal - -Long-lived broadcasts cannot become permanently unplayable. A 24/7 broadcast is -the normal CDN case, so the origin must not degrade with uptime. - -## Plan - -The mechanism that remains is the timeline's shape, not the 500s originally -reported downstream. A media timeline is a `moq_json::stream` on one -never-rolled group (`rs/moq-mux/src/timeline.rs`), so a long-lived broadcast's -index grows without bound, held by the publisher and read from the start by -every new origin; #3493 measured it at 2.8 MB/h in a 24 h `moq import ts` -soak. The `dev` branch already publishes one timeline per broadcast as a -`moq_json::window` with a bounded checkpoint (#3240), so the shape is fixed -there and reaches main with the [merge](/quest/m1/merge-dev.md). An aged-out group already maps to 404 rather than 500 -([moq#2615](https://github.com/moq-dev/moq/pull/2615)), so re-verify the -reported symptom before chasing it. - -The fix is the archive questline's [archive timeline](/quest/m1/archive/timeline.md) -rather than a second mechanism here: the merged `moq_json::Window` bounds and -rolls the timeline, so a joiner reads one current group instead of history since -startup. The exporter's playlist window then derives from that shared Window. - -Prove it with a long-running broadcast rather than a unit test: a fresh viewer -joining a broadcast that has been up for days must get a playlist promptly. - -## Required - -- [Archive timeline](/quest/m1/archive/timeline.md) - the timeline must adopt the merged Window before a long-lived broadcast can stay playable diff --git a/quest/m1/hls-sibling-epoch-identity.md b/quest/m1/hls-sibling-epoch-identity.md index 06b1af37cc..112c68eb00 100644 --- a/quest/m1/hls-sibling-epoch-identity.md +++ b/quest/m1/hls-sibling-epoch-identity.md @@ -1,30 +1,34 @@ -# [M] Bind sibling media to the epoch described by its catalog +# [S] A replaced sibling publisher restarts its rendition ## Goal -An HLS export must not serve a replacement sibling publisher's restarted groups -under a catalog and timeline describing the previous publisher, including when -the replacement happens before the sibling request resolves. +An HLS export never serves a replacement sibling publisher's restarted groups +under timeline rows produced for the previous publisher. When the sibling +broadcast is replaced, the exporter drops that rendition's rows, re-binds, and +lists only rows produced after. ## Plan -`moq_mux::Source::bind` starts one request at rendition construction and retains -its result. This closes the first-segment lookup window, but does not establish -an epoch relationship between two separate broadcasts. A dynamic handler can -answer an already queued request with a replacement broadcast; a remote route -also resolves asynchronously. A replacement already present at construction is -indistinguishable from the sibling intended by the catalog. - -Define how a catalog reference identifies the intended media epoch, and how an -export validates that identity before serving groups. Merely awaiting the -request before advertising timeline rows does not validate existing timeline -records against the publisher that answers. Coordinate catalog format changes -with the matching hang draft and JS implementation if an identifier is needed. - -Add a deterministic regression with a held catalog and timeline, a pending -sibling request, and a handler that answers with a replacement after timeline -rows arrive. Also cover a replacement already present when the export starts. +`moq_mux::Source::bind` (`rs/moq-mux/src/source.rs:185-187`) starts one request +at rendition construction and retains its result, so the sibling a rendition +serves is the one the origin resolved. No epoch is needed to tell a +replacement apart: a bound sibling whose publisher is replaced by a different +first hop already ends with `Error::Dropped` +(`rs/moq-net/src/model/origin.rs:2852-2866`, test +`different_first_hop_ends_the_subscription` :4902). A same-hop replacement +re-splices at a group boundary invisibly to subscribers (`origin.rs:3663-3668`) +and needs nothing from the exporter. + +On that end, the rendition clears its window, binds the sibling again through +`Source::bind`, and admits only timeline rows that arrive after the new bind +resolved. Rows listed for the old publisher are never served from the new one; +a segment request for one answers 404. + +Add a deterministic regression with a held catalog and timeline, a bound +sibling, and a replacement through a different first hop after timeline rows +arrive. Also cover a replacement already present when the export starts: it is +the sibling the request resolves to and serves normally. ## Related -- [Closed publisher status](/quest/m1/hls-closed-publisher-500.md) - classify unavailable media once publisher identity is known +- [HLS cache misses](/quest/m1/hls-cache-miss-codes.md) - classify a missing group once the bound publisher is known diff --git a/quest/m1/ietf-uni-stream-types.md b/quest/m1/ietf-uni-stream-types.md deleted file mode 100644 index 8d1ccb970d..0000000000 --- a/quest/m1/ietf-uni-stream-types.md +++ /dev/null @@ -1,13 +0,0 @@ -# [S] Validate IETF unidirectional stream types - -## Goal - -Accept valid padding streams and close the session for genuinely unknown stream types according to the negotiated moq-transport draft. - -## Plan - -`rs/moq-net/src/ietf/session.rs` routes every non-SETUP uni stream to `run_uni_group`, which rejects padding and unknown types alike while leaving the session alive. The stream-only rejection uses INTERNAL_ERROR because SESSION_CLOSED would falsely claim a session shutdown. - -Classify stream types before spawning a group handler. Handle PADDING according to each supported draft, including draining it where required, and propagate genuinely unknown types to the session driver. Keep ordinary group failures scoped to their streams. Add regressions for padding, unknown types causing session shutdown, and group failures preserving the session. - -Consult [draft-19 section 3.4 and section 11.5.1](https://www.ietf.org/archive/id/draft-ietf-moq-transport-19.html) and [draft-20 section 11.5.1](https://www.ietf.org/archive/id/draft-ietf-moq-transport-20.html), which explicitly permits cancelling padding streams. Check the earlier supported drafts too. diff --git a/quest/m1/js-announce.md b/quest/m1/js-announce.md index d52021cc44..101d8e2f37 100644 --- a/quest/m1/js-announce.md +++ b/quest/m1/js-announce.md @@ -5,41 +5,59 @@ `Origin.Producer` in js/net has the same three operations as the Rust model: `createBroadcast(path)` creates an unadvertised broadcast, the broadcast producer's `announce(route)` / `unannounce()` advertise its exact path once it -is populated, and `dynamic(prefix, route)` returns a handle that advertises the -prefix and yields the requests beneath it for the app to accept or reject. +is populated, and `dynamic(pattern, route)` returns a handle that advertises a +path pattern (a prefix is spelled `foo/**`) and yields the requests beneath it +for the app to accept or reject. ## Plan -On `dev`, `Origin.Producer.publish(path)` creates and announces in one step -through the local table, and `announce(prefix, provider)` takes a -`RouteProvider` that serves requests through an interface the caller -implements. Both fuse two decisions and the second is callback-shaped. +On `dev`, `Origin.Producer.publish(path)` (`js/net/src/origin.ts:237`) creates +and announces in one step through the local table, and +`announce(prefix, provider)` (`:279`) takes a `RouteProvider` (`:46`) that +serves requests through an interface the caller implements. Both fuse two +decisions and the second is callback-shaped. - Rename `publish(path)` to `createBroadcast(path)`, unadvertised. The broadcast producer gains `announce(route)` and `unannounce()`; the origin keeps the association in its table and retracts when the producer closes. Announcing again re-prices in place, so a route knob in the signals idiom (see `js/CLAUDE.md`) is the natural backing. -- js/net has no `Route` type today: `Hop[]` lives in `hop.ts` and `Cost` in - `lite/announce.ts`, and the origin never sees either. Add one (hops plus +- js/net has no `Route` type today: `Hop` lives in `hop.ts:28` and `Cost` in + `lite/announce.ts:30`, and the origin never sees either. Add one (hops plus cost) so the origin API and the wire agree, and stamp it on `announce.Event` so consumers can read it back. -- `dynamic(prefix, route)` returns a `Dynamic` handle: `update(route)`, +- `dynamic(pattern, route)` returns a `Dynamic` handle: `update(route)`, `close()` to retract and reject, and `requested()` as an async iterator of - requests with `accept(broadcast)` and `reject(error)`. `RouteProvider` is - removed; `forward.ts` and the session code drive the handle instead. -- Consumers: `js/publish` (whose `announce` attribute becomes the flip rather - than a gate on running at all), `js/watch`, `js/boy`, `js/clock`, `demo/web`, - and the `doc/lib/js/@moq` pages that show `publish(path)` or - `announce(prefix, provider)`. + requests with `accept(broadcast)` and `reject(error)`. The pattern is parsed + by `Path.Pattern` (`js/net/src/path.ts:526`), the same dialect the Rust + model takes, so the API breaks once; until + [Advertise](/quest/m1/wildcard/advertise.md) lands, anything but a + prefix-shaped pattern is refused. `RouteProvider` is removed; + `connection/forward.ts` and the session code drive the handle instead. +- `StreamCode` (`js/net/src/error.ts:71-95`) gains `NoCapacity: 0x30`, the + refusal a dynamic handle sends when it could serve a request but has no room + (`drafts/draft-lcurley-moq-lite.md:311`); the Rust side already has it. +- Consumers: `js/publish` (`js/publish/src/broadcast.ts:212`, whose `announce` + attribute becomes the flip rather than a gate on running at all), + `js/moq-boy` (`js/moq-boy/src/game.ts:303`), `js/clock` + (`js/clock/src/main.ts:81`), and the docs: `doc/lib/js/net.md` shows + `origin.publish` (`:24`) and `announce(prefix, provider)` (`:41`), and + `doc/lib/js/publish.md:34` describes the `announce` attribute. Both pages + state the order the model wants: create, `dynamic()` for tracks served on + demand, populate, then announce, because an exact-path subscribe before the + tracks exist is refused and announcing only makes a path discoverable. Tests: `origin.test.ts` and `integration.test.ts` cover create then announce, -a handle serving a request under its prefix, and close rejecting queued -requests; `reload.test.ts` keeps the announce state across a reload. +a handle serving a request under `live/**`, a non-prefix pattern refused, and +close rejecting queued requests with `NoCapacity`; +`js/net/src/connection/reload.test.ts` keeps the announce state across a +reload. Branch from `dev`, where the origin table lives; the rename is breaking. ## Related -- [#3190](/quest/m1/3190-align-origin-broadcast-creation-naming-across-language.md) - the native bindings half -- [#2318](/quest/m1/2318-js-net-remaining-capability-gaps-vs-rs-moq-net-setup-role.md) - other js/net gaps vs rs/moq-net +- [#3190](/quest/m1/3190-align-origin-broadcast-creation-naming-across-language.md) - the native bindings half, and the Rust `dynamic(pattern, route)` signature +- [#2774](/quest/m1/2774-collapse-reload-and-shared-into-one-connection-class.md) - rewrites `connection/reload.ts` and `pool.ts`, which this touches; land one before the other +- [Advertise](/quest/m1/wildcard/advertise.md) - lifts the prefix-only refusal so `dynamic()` accepts any pattern +- [#2318](/quest/m2/2318-js-net-remaining-capability-gaps-vs-rs-moq-net-setup-role.md) - other js/net gaps vs rs/moq-net diff --git a/quest/m1/js-close-classification.md b/quest/m1/js-close-classification.md index 22ea948cf9..c68e339f74 100644 --- a/quest/m1/js-close-classification.md +++ b/quest/m1/js-close-classification.md @@ -13,9 +13,11 @@ tolerating every error the page reports. Branch from dev, which already has what main lacks: `js/net` on dev carries `StreamError` and `SessionError` with code registries, and `StreamCode.Cancel` -is the routine unsubscribe. On main only `RemoteError` exists, with code `0` -whenever the transport dropped the stream without one, so the classification -cannot be built there. +(`0x1`) is the routine unsubscribe. `js/net/src/error.ts:156` already shows +the intended consumer test, `err instanceof StreamError && err.code === +StreamCode.Cancel`. On main only `RemoteError` exists, with code `0` whenever +the transport dropped the stream without one, so the classification cannot be +built there. Today every path is the same path. A subscription aborted by a name change or a departing publisher rethrows out of the `js/hang` group reader, lands in @@ -24,9 +26,11 @@ whatever the session constructed. `js/watch/src/video/decoder.ts` logs a bare `DOMException` with no prefix and carries `// TODO bubble up error`. The consequence is a test that cannot be strict: `test/smoke/clients/js/media.ts` drives unsubscribe/rejoin and publisher-stop/republish, both of which abort -subscriptions on purpose, so `waitFrozen(.., tolerateErrors)` drains every -page error for the duration of the transition and a genuine decoder or -transport fault reads exactly like the abort the case asked for. +subscriptions on purpose, so its `waitFrozen(.., tolerateErrors)` +(`media.ts:139-163`) drains every page error for the duration of the +transition, and a genuine decoder or transport fault reads exactly like the +abort the case asked for. The blanket drain is `WaitProps.tolerateErrors` in +`waitFor` (`test/smoke/clients/js/harness.ts:207`, `:219`, `:227`). - Decide what `@moq/hang` and `@moq/watch` expose when a track, broadcast, or session ends by request: the dev `StreamError` with `Cancel` reaching @@ -42,10 +46,10 @@ transport fault reads exactly like the abort the case asked for. the same `DOMException` a broken decoder would, so the classification has to come from the stream's end, not the exception. - Tighten `media.ts`: tolerate the classified end, fail immediately on - anything else, and delete the blanket drain in `harness.ts`. + anything else, and delete `tolerateErrors` from `harness.ts`. ## Related -- [#2318](/quest/m1/2318-js-net-remaining-capability-gaps-vs-rs-moq-net-setup-role.md) - the other js/net gaps against the Rust model +- [#2318](/quest/m2/2318-js-net-remaining-capability-gaps-vs-rs-moq-net-setup-role.md) - the other js/net gaps against the Rust model - [Media QA on other engines](/quest/m2/browser-media-qa-engines.md) - the same harness, another axis - [Failure artifacts](/quest/m0/qa-failure-artifacts.md) - shared trace and sample output diff --git a/quest/m1/json-compressed-delta-test-timeout.md b/quest/m1/json-compressed-delta-test-timeout.md deleted file mode 100644 index a4455c9067..0000000000 --- a/quest/m1/json-compressed-delta-test-timeout.md +++ /dev/null @@ -1,19 +0,0 @@ -# [S] Make the compressed-delta cache regression reliable under load - -## Goal - -The encoded-size regression in `js/json/src/snapshot/snapshot.test.ts` completes -within the existing test budget on a busy developer machine. - -## Plan - -While validating #3448, `a compressed delta is gated on its encoded size, not its -plaintext` exceeded Bun's five-second timeout in `just test default -origin/dev` with Bun 1.3.13. An isolated run with host Bun 1.2.23 also timed out. -CI passed on the same source. The machine was concurrently compiling Rust; -contention is a hypothesis, not a confirmed diagnosis. - -Trace the producer completion and compression work before changing the test. -Preserve the assertion that encoded frames cannot evict the group's snapshot, -and verify the test still fails if the encoded-size guard is removed. Prefer a -smaller deterministic fixture or explicit completion over raising the timeout. diff --git a/quest/m1/ladder/controller.md b/quest/m1/ladder/controller.md deleted file mode 100644 index e0057726e5..0000000000 --- a/quest/m1/ladder/controller.md +++ /dev/null @@ -1,47 +0,0 @@ -# [L] Ladder controller - -## Goal - -One controller owns every generated rung's share, encoder target, and stalled -state for one output bandwidth domain, so a ladder adapts to its uplink -instead of encoding every live rung at its ceiling. - -## Plan - -Add an optional bandwidth input to the transcode configuration and wire the -CLI's publisher session into it. Supplying none preserves today's fixed-rate -behavior exactly and never publishes congestion-induced `stalled` state, which -is what keeps this additive. - -The controller subdivides that estimate across the ladder and applies the -band boundary from the [questline](/quest/m1/ladder/README.md), including the -lowest rung's `max / 3` case. `moq_transcode::Ladder` already ranks the rungs -by configured maximum, lowest first, so the next lower rendition the formula -reads is the preceding entry, and the lowest rung is the one with none. -Assign descending `track::Info::priority` down -the ladder: the allocator already fills a tier before the next sees a bit, so -that alone protects lower rungs' allocation without touching the scheduler. - -Three things the implementation has to keep honest: - -- **Requested and applied targets are different numbers.** Catalog state - follows what the encoder accepted. A transient rate-control failure keeps - the last applied target and retries on a later material movement. -- **`BitrateUnsupported` is an explicit fallback, not a silent one.** Such an - encoder keeps its configured maximum, publishes `stalled: true` whenever the - allocation is below it, and clears only when the full maximum fits again. It - reclaims no encoder work by design, and it must be visible in logs and tests - rather than pretending the target was applied. -- **An idle rung must be able to recover.** Only demanded rungs consume - allocation, but a rung that loses all demand while stalled cannot be left - permanently stalled. Evaluate its hypothetical share against the current - estimate and active lower-priority reservations, without giving it a real - share or encoding probe traffic. - -Acceptance: one demanded rung reaching its configured maximum on a permissive -uplink; several rungs sharing one uplink with lower ones protected; a -supported rung adapting down, clamping, stalling, and recovering without its -advertised maximum moving; the 5 Mbps over 2.5 Mbps boundary landing near -3.33 Mbps; the default 350 kbps lowest rung stalling near 117 kbps; an -unsupported encoder holding its maximum and not recovering early; and no -bandwidth input preserving existing behavior exactly. diff --git a/quest/m1/ladder/fetch.md b/quest/m1/ladder/fetch.md deleted file mode 100644 index 6906e684aa..0000000000 --- a/quest/m1/ladder/fetch.md +++ /dev/null @@ -1,32 +0,0 @@ -# [M] Fetch and catalog - -## Goal - -An uncached FETCH encodes at the ladder's shared applied target instead of -opening a fresh encoder at the configured maximum, and a source catalog -refresh cannot erase current rung state. - -## Plan - -Two independent leaks of controller state, both cheap to close once the -controller owns it. - -The FETCH path opens a new encoder at the configured maximum for every -requested group, so a stalled ladder still burns full-rate encodes on demand. -Make it read the controller's applied target and participate in the same -allocation. A stalled rung stays manually fetchable, and cache hits are -untouched. - -Catalog mutations publish full HANG, HANGZ, and MSF snapshots today. Coalesce -every rung state change from one controller iteration into a single -publication, and make a later source catalog snapshot compose with current -generated-rung state rather than overwrite it. - -Acceptance: a fresh FETCH using the shared applied target while a cache hit -encodes nothing, and one source catalog refresh leaving stalled rung state -intact. - -## Required - -- [Controller](/quest/m1/ladder/controller.md) - there is no shared applied - target to read until the controller owns one diff --git a/quest/m1/ladder/transmit.md b/quest/m1/ladder/transmit.md deleted file mode 100644 index de316ee6e4..0000000000 --- a/quest/m1/ladder/transmit.md +++ /dev/null @@ -1,28 +0,0 @@ -# [M] Transmission order - -## Goal - -Lower renditions win the publisher-side tie-break among subscriptions at equal -subscriber priority, so transport shedding drops the top of the ladder first. - -## Plan - -The allocator divides by the publisher's `track::Info::priority`, but the -local send queue ranks by each subscription's own priority, so today a -congested uplink sheds every rung alike. That is what the -`BitrateUnsupported` fallback leans on when it cannot reclaim encoder work: -without it, an unsupported encoder degrades the whole ladder equally instead -of protecting the bottom. - -Subscriber priority stays the primary authority. Rendition order only breaks -ties among subscriptions the subscriber ranked equally, so a subscriber -asking for a higher rendition ahead of a lower one still gets what it asked -for. - -Cover equal subscriber priority, conflicting subscriber priority where the -subscriber's order must win, and a custom ladder order. - -## Related - -- [Hierarchical stream scheduling](/quest/m2/quic/scheduler.md) - supplies the - fair subscription buckets beneath this rendition policy diff --git a/quest/m1/merge-dev.md b/quest/m1/merge-dev.md index d8ba3ebc79..d6d99dbf7d 100644 --- a/quest/m1/merge-dev.md +++ b/quest/m1/merge-dev.md @@ -1,4 +1,4 @@ -# [L] Merge dev into main +# [XL] Merge dev into main ## Goal @@ -9,13 +9,21 @@ closing keyword, and the release that follows is the one moq.pro adopts. ## Plan -As of 2026-09-05 dev is 243 commits ahead of main and main 16 ahead of dev. -Merge main into dev first and resolve there, then open the merge PR from dev -with the list below as closing keywords. Run `just check-all`, `just test all`, -`just test smoke-full`, and `just bench origin/main` on the merged tree. The -breaking-change targeting rules in CONTRIBUTING.md govern the release that -follows, and [Dart announce](/quest/m1/dart-announce.md) unblocks when this -lands. +As of 2026-09-09 dev is 285 commits ahead of main and main 6 ahead of dev. The +six are a docs pass (#3557), a quest plan (#3560), and CI repairs (#3553, +#3554, #3555, #3556), so the main-into-dev merge is trivial. Merge main into +dev first and resolve there, then open the merge PR from dev with the list +below as closing keywords. Run `just check-all`, `just test all`, +`just test smoke-full`, and `just bench origin/main` on the merged tree. + +Also soak HLS on the merged tree: a fresh viewer joining a `moq import ts` +broadcast that has been up for days must get a playlist promptly. The bounded +`moq_json::window` timeline (#3240) is what makes that hold, and only a long +run proves it. + +The breaking-change targeting rules in CONTRIBUTING.md govern the release +that follows. Dart lands on that surface with #3190, so nothing waits on this +merge for it. ## Required @@ -23,6 +31,7 @@ lands. - [#3190](/quest/m1/3190-align-origin-broadcast-creation-naming-across-language.md) - every native binding on that surface - [JS announce](/quest/m1/js-announce.md) - js/net on that surface - [Gap discontinuity](/quest/m1/gap-discontinuity.md) - so the lost-reset regression does not ship +- [Advertise](/quest/m1/wildcard/advertise.md) - so `dynamic(prefix, route)` takes a path pattern before the announce API is published ## Closes @@ -51,11 +60,7 @@ lands. - [#2217](https://github.com/moq-dev/moq/issues/2217) - moq-ffi: the announce handle carries the lifecycle (announce-handle, #3190) - [#980](https://github.com/moq-dev/moq/issues/980) - dual-stack binding on main; happy eyeballs in `moq-tokio::resolve` (#2749) - [#2153](https://github.com/moq-dev/moq/issues/2153) - go: the wrapper caught up on main; hops landed on dev (#2168) -- [#679](https://github.com/moq-dev/moq/issues/679) - QUIC receive is spread across thread-per-core workers, each on its own socket (#2875) +- [#679](https://github.com/moq-dev/moq/issues/679) - QUIC receive is spread across thread-per-core workers, each on its own socket (#2921) - [#1073](https://github.com/moq-dev/moq/issues/1073) - the origin lifecycle is caller-driven: `origin::Driver` plus `moq_tokio::origin::spawn` (#2897, #2901) -- [#2155](https://github.com/moq-dev/moq/issues/2155) - js/net: subscriptions take an options object with start and end groups and `update()`; ordering became a handle +- [#2155](https://github.com/moq-dev/moq/issues/2155) - js/net: subscriptions take a `Subscription` options object with `startGroup`, `endGroup`, and `update()` (#2716); ordering became a handle (#3099) - [#3493](https://github.com/moq-dev/moq/issues/3493) - the timeline is a `moq_json::window` with a bounded checkpoint, so a 24/7 importer no longer retains every record (#3240) - -## Related - -- [Dart announce](/quest/m1/dart-announce.md) - waits on this merge, so it cannot be required here diff --git a/quest/m1/monotonic-timeline.md b/quest/m1/monotonic-timeline.md index df1d8a70ac..455bfeefd6 100644 --- a/quest/m1/monotonic-timeline.md +++ b/quest/m1/monotonic-timeline.md @@ -5,10 +5,10 @@ A track's timestamps never fall below the live edge its earlier groups reached. A publisher that has to reset (a flush, a seek, a source restart) declares a discontinuity by leaving a gap in the group sequence and -continues forward from where it was; it can no longer rewind. Inside a group timestamps still reorder freely, -since B-frames present before the frames that precede them in decode order, -and open-GOP leading pictures still qualify because they sit above the -previous group's reach. +continues forward from where it was; it cannot rewind. Inside a group +timestamps still reorder freely, since B-frames present before the frames +that precede them in decode order, and open-GOP leading pictures still qualify +because they sit above the previous group's reach. Consumers stop detecting and re-anchoring on undeclared rewinds. A group that breaks the rule is a malformed track, not a timeline event. @@ -16,13 +16,16 @@ breaks the rule is a malformed track, not a timeline event. ## Plan Today both container consumers (`Rewind` in -`rs/moq-mux/src/container/consumer.rs` and in -`js/hang/src/container/consumer.ts`) classify a newer group whose timestamps -land before the live edge as a rewind, drop the reneged buffer, and bump the -same counter a declared discontinuity bumps. The hang draft says a declared -discontinuity applies "whether the resumed timestamps move backward or -forward". That machinery is what goes; how a publisher declares one is -[Gap discontinuity](/quest/m1/gap-discontinuity.md). +`rs/moq-mux/src/container/consumer.rs:83` and in +`js/hang/src/container/consumer.ts:76`) classify a newer group whose +timestamps land before the live edge as a rewind, drop the reneged buffer, and +bump the same counter a declared discontinuity bumps. The hang draft +(`drafts/draft-lcurley-moq-hang.md:507`) says a declared discontinuity applies +"whether the resumed timestamps move backward or forward". That machinery is +what goes; how a publisher declares one is +[Gap discontinuity](/quest/m1/gap-discontinuity.md). This quest changes the +hang container rule in both languages but nothing that is dev-only; it ranks +in m1 only because it requires gap-discontinuity. - **Publisher side, in both container producers.** The track producer refuses a frame whose timestamp is below the live edge established by the groups @@ -38,24 +41,29 @@ forward". That machinery is what goes; how a publisher declares one is classification in both languages. A group below the live edge aborts the track as malformed. The discontinuity counter stays, counting declared discontinuities only; `js/watch` keeps resetting its decoders on it - ([#3056](/quest/m1/3056-watch-video-decoder-captures-the-rewind-generation-at.md)). + ([#3056](/quest/m2/3056-watch-video-decoder-captures-the-rewind-generation-at.md)). - **Draft.** `drafts/draft-lcurley-moq-hang.md` loses the backward clause and states the rule: after a discontinuity the timeline continues forward. The moq-lite draft is unchanged; enforcement lives above the relay, in the media layer that knows what a group is. -- **TS export.** [moq#3375](https://github.com/moq-dev/moq/pull/3375) lands - first and keys its reset on the discontinuity counter, which keeps working - once the counter only counts declared discontinuities. After it, remove the - `last_psi` / `last_si` / `last_pcr` reset a forward jump makes redundant and - reword its docs; keep `discontinuity_indicator` on the PCR packet, since a - PCR jump over 100 ms without it is a TR 101 290 error whichever direction - the jump goes. +- **TS export.** `Exporter::rewind(backwards)` + (`rs/moq-mux/src/container/ts/export.rs:928-952`) restarts the program + clock on a source discontinuity, clearing `last_pcr`, `last_psi`, and every + `si[*].last_emit`; it is keyed on the source's discontinuity counter + (`:696`), which keeps working once that counter only counts declared + discontinuities. The `backwards` flag (decided at `:581` and `:1145`, and + what fences peers across a backward boundary at `:948`) disappears with + backward rewinds, so the reset takes no argument and its docs lose the + backward case; keep `pcr_discontinuity`, the `discontinuity_indicator` on + the PCR packet, since a PCR jump over 100 ms without it is a TR 101 290 + error whichever direction the jump goes. Tests, in both languages: the producer refuses a group below the live edge; -the consumer aborts on one; a group with reordered B-frames is accepted; an open-GOP group whose -leading pictures sit below its keyframe but above the previous group passes; -a declared discontinuity followed by a forward jump passes and bumps the -counter once; moqsink's flush re-anchor produces a forward timeline. +the consumer aborts on one; a group with reordered B-frames is accepted; an +open-GOP group whose leading pictures sit below its keyframe but above the +previous group passes; a declared discontinuity followed by a forward jump +passes and bumps the counter once; moqsink's flush re-anchor produces a +forward timeline. Branch from `dev`, where the container consumers carry the current `Rewind` state. @@ -63,11 +71,9 @@ state. ## Required - [Gap discontinuity](/quest/m1/gap-discontinuity.md) - settles how a publisher declares the discontinuity this rule continues from -- [moq#3375](https://github.com/moq-dev/moq/pull/3375) has merged, so the TS export reset it adds is keyed on the discontinuity counter before this quest trims it ## Related -- [Duration marker](/quest/m2/duration-marker.md) - the empty frame that closes a group, which is not a discontinuity -- [#3056](/quest/m1/3056-watch-video-decoder-captures-the-rewind-generation-at.md) - the watch decoder reset that keeps mattering for declared discontinuities +- [Duration marker](/quest/m1/duration-marker.md) - the empty frame that closes a group, which is not a discontinuity +- [#3056](/quest/m2/3056-watch-video-decoder-captures-the-rewind-generation-at.md) - the watch decoder reset that keeps mattering for declared discontinuities - [#3115](/quest/m2/3115-moqsink-the-publication-has-no-generation-so-a-flush.md) - moqsink's generation model after EOS, the same publisher -- [#3375](https://github.com/moq-dev/moq/pull/3375) - the TS export fix this trims once it lands diff --git a/quest/m1/perf/3122-moq-uring-2-5-of-relay-cpu-is-vdso-clock-reads-the-drive.md b/quest/m1/perf/3122-moq-uring-2-5-of-relay-cpu-is-vdso-clock-reads-the-drive.md deleted file mode 100644 index dc1a373a18..0000000000 --- a/quest/m1/perf/3122-moq-uring-2-5-of-relay-cpu-is-vdso-clock-reads-the-drive.md +++ /dev/null @@ -1,57 +0,0 @@ -# [M] moq-uring: ~2.5% of relay CPU is vdso clock reads; the drive loop and its callers each re-read Instant::now() - -## Goal - -Implement and verify the behavior tracked in [#3122](https://github.com/moq-dev/moq/issues/3122) -within the issue's stated scope and boundaries. - -## Plan - -Use the public issue's scope, implementation notes, and acceptance criteria -below as the starting plan. Reconcile paths and assumptions with the current -tree before implementation. - -### Issue context - -Profiling the io\_uring relay (`dev` @ `fc57e0175`, `perf record -F 499`, relay process only) shows `[vdso]` as a top-5 DSO, at roughly 3x its share on the tokio worker path: - -| DSO | video, io\_uring | video, tokio workers | chat, io\_uring | -|---|---|---|---| -| `moq-relay` | 65.89% | 68.61% | 68.96% | -| `[kernel.kallsyms]` | 22.64% | 22.97% | 20.49% | -| `libc.so.6` | 5.74% | 4.48% | 6.03% | -| **`[vdso]`** | **2.95%** | **0.72%** | **2.55%** | - -That is `clock_gettime`. Roughly 2.5% of relay CPU spent reading the clock. - -#### Where - -The drive loop reads it once per turn: - -```rust -// rs/moq-uring/src/worker.rs, block_on -self.shared.timers.borrow_mut().fire(Instant::now()); -``` - -and then callers read it again independently on the same turn, e.g.: - -```rust -// rs/moq-uring/src/quic/connection.rs -fn arm_keep_alive(&mut self) { - let at = self.keep_alive_every - .and_then(|every| std::time::Instant::now().checked_add(every)); - self.keep_alive.set(at); -} -``` - -plus quiche's own `Instant::now()` calls inside `on_timeout` / `timeout`. Because the driver re-polls per GSO train rather than per ingress event (#3120), each of those turns pays for its own clock reads. - -The same profile also shows the timer heap itself at ~1.6%: `::set` 0.92% plus `btree::search::search_tree` 0.65%. `timer::Heap` is a `BTreeMap<(Instant, u64), Rc>`, so every QUIC timeout re-arm is an O(log n) map removal and insertion with `Rc` traffic. The design note in #2875 called for a timer wheel here; the landed implementation is the BTreeMap. Not urgent at these connection counts, but it is on the same hot path and grows with it. - -#### Suggestion - -`moq_net::runtime::Runtime` already carries a defaulted `now()`, which is a natural place to hand the current turn's instant down instead of having each layer re-read it. Sampling once per drive turn and passing it through `fire`, the keep-alive arming, and the quiche timeout calls should recover most of that 2.5%. - -## Closes - -- [#3122](https://github.com/moq-dev/moq/issues/3122) - close this issue when the quest finishes diff --git a/quest/m1/perf/egress-requeue.md b/quest/m1/perf/egress-requeue.md deleted file mode 100644 index 1f584a1e0e..0000000000 --- a/quest/m1/perf/egress-requeue.md +++ /dev/null @@ -1,39 +0,0 @@ -# [S] The egress driver's requeue stops re-walking every ready stream - -## Goal - -Sending a deep backlog on one connection no longer re-runs the whole of -`Driver::poll` per GSO train. The driver separates "there is more to send" -from "readiness changed", so a requeue for the transmit pool walks nothing, -and the one-train-per-turn cadence becomes a measured budget instead of a -hardcoded fairness choice. - -## Plan - -Branch from dev. Profiled in #3120: `kio::waiter::WaiterList::register` is -the hottest symbol on the io_uring relay (4.6% video, 6.7% chat), because -`Connection::flush` stages one train (up to 63 segments) and then wakes -itself, and each of those turns walks quiche's `readable()` and `writable()` -iterators, removing and re-registering a waiter per ready stream. The same -sweep calls `stream_capacity` for every finishing stream per turn. - -- Separate the requeue from readiness. After a train, the driver asks only - for another transmit turn; the readiness walk runs when quiche reports a - readiness change, or only for the streams whose readiness changed since - the last turn. `state.finishing.retain(..)` stops calling - `stream_capacity` per finishing stream per turn. -- Add a trains-per-turn budget to `Driver::flush`. The one-train-then-requeue - shape is deliberate fairness across connections on the shared socket; keep - fairness by bounding the budget, and sweep 1, 2, and 4 trains per turn - under the fanout and single-heavy-connection shapes to see whether the - extra turn latency is real. -- Both changes stay independently ablatable, and the quinn driver gets the - same treatment where it has the same shape. - -Acceptance: `WaiterList::register` share in the `perf` profile, CPU per Gbps -and throughput ceiling via `just bench BASE` on Linux. Latency must not -regress at the swept budget. - -## Closes - -- [#3120](https://github.com/moq-dev/moq/issues/3120) - close this issue when the quest finishes diff --git a/quest/m1/perf/ingest-batch.md b/quest/m1/perf/ingest-batch.md deleted file mode 100644 index 9a857260aa..0000000000 --- a/quest/m1/perf/ingest-batch.md +++ /dev/null @@ -1,42 +0,0 @@ -# [S] Batch the relay ingest write path - -## Goal - -Relay ingest pays a full group mutex acquisition, a waiter-list wake fanout, -and a real clock read for every received QUIC chunk. Egress is already -amortized (the `Prefetch` refills eight frames under one lock and stamps the -charge and stats once per batch); ingest has no equivalent. Make a burst of -received chunks pay one lock, wake, and clock cycle. - -## Plan - -Mechanism, per the 2026-09 survey: - -- Both wire ingests drain a frame's payload through - `coding::Reader::poll_read_frame`. The group lock, the charge clock read, and - the waiter drain used to run per chunk; they now run once at the poll boundary - and once at `frame_commit`. -- The batched machinery exists but is unused here: - `group::Producer::write_frames` takes a `frame::Buffer` and pays one lock - per batch (benched at roughly 5x for N=8 in `rs/moq-net/benches/group.rs`), - but the ingest path streams chunks through `create_frame_owned` instead. - -Remaining: - -- Where whole frames are available in one poll turn, feed them through - `write_frames`/`frame::Buffer` instead of frame-at-a-time creation. This is - the larger half: a small frame that arrives whole still pays a - `create_frame_owned` plus a `frame_commit`, two lock acquisitions where the - batch API pays one for the whole burst. -- The per-chunk `stats` bumps on the same loop, which `write` still pays - individually. - -Done: the notification cadence. `coding::Reader::poll_read_frame` owns the -payload drain for both wire ingests, so the wake happens once where the loop -yields (or once per `WAKE_BUDGET` bytes, since transport readiness alone is not -a bound on time), and `frame_commit` restarts the retention clock so the -deferral can never lose a stamp. - -Acceptance: ingest CPU per Gbps on the video shape and the chat shape -(`just bench BASE` on Linux), plus `rs/moq-net/benches/group.rs`. Frame -delivery latency at the live edge must not regress. diff --git a/quest/m1/plan-av-clock.md b/quest/m1/plan-av-clock.md index 2e7750a2cb..3d4bb79864 100644 --- a/quest/m1/plan-av-clock.md +++ b/quest/m1/plan-av-clock.md @@ -1,42 +1,42 @@ -# [S] Plan: the audio playhead drives video sync while audio plays +# [M] The audio playhead drives Sync.reference while audio plays ## Goal -A written decision, recorded in this file and then executed as its own quest, -on how `js/watch` keeps audio and video in sync. Today the audio ring is -depth-driven and free-running on the AudioContext clock while video paces -against `Sync.reference`, a wall clock anchored at the earliest arrival. -Nothing ties them; A/V sync is emergent from both targeting the same delay, -so a ring that re-buffers or skips drifts against video until the next -re-anchor. The outcome is an audio-master clock: `Sync.reference` derives -from the audio playhead while audio plays and falls back to the wall clock -when muted or video-only. +`js/watch` keeps audio and video in sync from one clock. While audio plays, +`Sync.reference` derives from the audio playhead; muted or video-only playback +falls back to the wall clock. Today the ring is depth-driven and free-runs on +the AudioContext clock while video paces against a wall clock anchored at the +earliest arrival, so a ring that re-buffers or skips drifts against video until +the next re-anchor. ## Plan -Branch from dev; the `Sync` inputs are public `@moq/watch` API and #3517 -already reshaped them there. - -Two API shapes were on the table on 2026-09-07 and neither was chosen: - -- Per-track handles: `sync.track("audio")` / `sync.track("video")`, each - reporting its own advertised delay and measured spread and one of them - nominated as the clock source. Recommended then, because it removes the - `audio`/`video`/`audioSpread`/`videoSpread` quartet of inputs and lets a - third track (text) join without another pair. -- `delay` as a floor only: keep the flat inputs, drop `"auto"`, and let the - audio playhead be the reference whenever an audio track is active. - -Decide with the estimator in hand: what the audio decoder can expose as its -playhead across the isolated and postMessage ring paths, how the video -decoder's per-frame `sync.wait()` reads it without a cross-thread hop per -frame, and what happens at the transitions (mute, audio track end, the ring -re-stalling). Record the verdict here, re-title this file as the -implementation quest, and re-estimate it. +Settled: per-track handles. `sync.track("audio")` and `sync.track("video")` +each report their advertised delay and measured spread, and one is nominated +as the clock source. #3517 adopts that shape before it merges, so `SyncInput` +(`js/watch/src/sync.ts:21-46`, today `delay`, `buffer`, `probe`, `audio`, +`video`) breaks once, and a third track joins without another pair of inputs. + +Recommendations for the implementation: + +- Playhead source. On the SharedArrayBuffer path the worklet's sample counter + is the playhead (`js/watch/src/audio/shared-ring-buffer.ts`). On the + postMessage path (`js/watch/src/audio/ring-buffer.ts`) the worklet posts an + estimate and the main thread extrapolates between posts. +- Video reads a locally extrapolated clock, re-synced once per audio quantum, + so the per-frame `sync.wait()` (`js/watch/src/video/decoder.ts:332`) never + crosses a thread. +- Transitions. On mute or audio track end the reference falls back to the + wall clock at the last audio-derived value, so video does not jump. A ring + re-stall reads as the playhead pausing, and the reference pauses with it. +- Reset coupling stays: `` already flushes the ring alongside + `sync.reset()` (`js/watch/src/element.ts:301`, `:620-621`). +- The text renderer is the third track: it reads `sync.now()` + (`js/watch/src/text/renderer.ts:261`) for the cues it drains at `:273-278`. ## Required -- PR #3517 has merged to `dev` +- #3517 merges carrying the per-track shape ## Related diff --git a/quest/m1/route-cold-cost.md b/quest/m1/route-cold-cost.md index 7522497eb6..7f32086e64 100644 --- a/quest/m1/route-cold-cost.md +++ b/quest/m1/route-cold-cost.md @@ -12,9 +12,12 @@ seeding only a production cost still sets one number. ## Plan On dev `rs/moq-ffi/src/origin.rs` maps the pair onto one scalar in each -direction: `From` reports `cost.warm` and drops `cold`, while -`TryFrom` calls `with_cost(u64)`, which sets both halves. -`route_order` ranks on cold, so an understated cold wins ties it should lose. +direction: `From` reports `cost.warm` and drops `cold` (:42), +while `TryFrom` calls `with_cost(u64)` (:51), which sets both halves +(`Cost::new`, rs/moq-net/src/model/origin.rs:449). `route_order` ranks warm +first and breaks ties on cold (origin.rs:633-639; `Cost` derives `Ord` over +`(warm, cold)` at :424; drafts/draft-lcurley-moq-lite.md:409), so an +understated cold wins ties it should lose. - `MoqRoute` gains `cold: Option` with a uniffi default of `None`, meaning "same as `cost`" when omitted, which is right for a publisher seeding a @@ -22,14 +25,20 @@ direction: `From` reports `cost.warm` and drops `cold`, while conversions become lossless: `From` fills both fields, `TryFrom` builds `Cost { warm: cost, cold: cold.unwrap_or(cost) }`. - Additive in every generated binding. `rs/libmoq` exposes no route over C, so - `moq.h` is untouched. `py/moq-rs`, `swift`, `kt`, `go/wrapper/moq`, and - `doc/lib/{py,swift,kt,go}` show the field wherever they show `cost`; dart - follows once dev merges, as it exists only on main. + `moq.h` is untouched. The wrappers describe a route as hops and cost and + gain the field there: py/moq-rs/moq/origin.py:51,72,258; + go/wrapper/origin.go:72,189 and go/wrapper/types.go:41; + swift/Sources/Moq/Origin.swift:60,208; + kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt:124. The + `doc/lib/{py,swift,kt,go}/index.md` pages do not mention cost today, so the + field is added wherever each shows a route. `dart/` is on dev but has no + route surface yet; it gains the field with + [#3190](/quest/m1/3190-align-origin-broadcast-creation-naming-across-language.md), + which brings dart onto the announce API. - Tests: a route observed through the announcement stream and announced again compares equal including cold; an omitted cold equals warm. -Land with or after [#3190](/quest/m1/3190-align-origin-broadcast-creation-naming-across-language.md), -which rewrites the same records. Branch from dev. +Land with or after #3190, which rewrites the same records. Branch from dev. ## Required diff --git a/quest/m1/tokio-transport-feature.md b/quest/m1/tokio-transport-feature.md index 5faa20bb31..5c81e1fc61 100644 --- a/quest/m1/tokio-transport-feature.md +++ b/quest/m1/tokio-transport-feature.md @@ -3,25 +3,35 @@ ## Goal `cargo clippy -p moq-tokio --no-default-features -- -D warnings` passes, and so -does each single-feature build, so the configurations `just rs features` compiles -per crate are held to the same bar as the default one. +does each single-feature build, so the configurations `just rs tokio-features` +compiles are held to the same bar as the default one. ## Plan -The backend-less build compiles, but emits dead-code warnings, every one of them -an item whose only callers sit behind a transport gate: `listen::Member`, -`listen::Config::validate`, `quic::Config::validate` with `MAX_IDLE_TIMEOUT` and -`validate_idle_timeout`, `Client`'s `timeout` field, `server::Parts::Member`, -`tls::Peers::contains_raw`, `tls::CustomRoots::load`, and -`tls::Certificates::empty`. +The backend-less build compiles with 11 warnings, every one an item whose only +callers sit behind a transport gate: + +- unused imports `Member` and `Shard` (rs/moq-tokio/src/listen.rs:150) +- `Client::timeout` never read (client.rs:75) +- `listen::Config::validate` (listen.rs:305) +- `quic::Config::validate` (quic.rs:450), `MAX_IDLE_TIMEOUT` (:488), + `validate_idle_timeout` (:494), `MAX_VARINT` (:503), `validate_windows` + (:511) +- `tls::Peers::contains_raw` (tls.rs:395), `tls::CustomRoots::load` (:657), + `tls::Certificates::empty` (:1602) Gating each by hand would spell the six-way "has a transport" `any(...)` in several more places, which is what makes this its own change rather than a follow-on edit. Give the crate a private `_transport` feature, the way `_certs` already covers the serving side, enabled from `noq`, `quinn`, `quiche`, `iroh`, -`websocket`, and `tcp`. Collapse the long `any(...)` gates already in `lib.rs`, -`client.rs`, and `server.rs` onto it too, so the crate has one spelling of the -idea rather than two. +`websocket`, and `tcp`. Collapse onto it only the gates that spell exactly that +set: `lib.rs:57` and `:91`, and `client.rs:110` and `:126`. The narrower gates +stay as they are, because they mean something else: `client.rs:63` excludes +`iroh` on purpose, and `server.rs:21` excludes `tcp`/`uds`. `server::Parts` +already carries targeted `expect(dead_code)` attributes (server.rs:120-134) +and needs nothing. -Then swap `cargo check` for `clippy -- -D warnings` in the per-crate passes of -`just rs features`, so the bar holds once it is met. +Then swap `cargo check` for `clippy -- -D warnings` in `just rs tokio-features` +(rs/justfile:401-428) so the bar holds once it is met. Leave the loop at :419 +alone: it deliberately expects `quinn`/`noq` without a crypto provider to fail +the build, and asserts on the error text. diff --git a/quest/m1/uring-tcp/relay.md b/quest/m1/uring-tcp/relay.md deleted file mode 100644 index 2ba1f143ed..0000000000 --- a/quest/m1/uring-tcp/relay.md +++ /dev/null @@ -1,31 +0,0 @@ -# [M] Relay stream listeners on the workers - -## Goal - -The relay serves its WebSocket sessions, and its HTTP routes, from the -io_uring workers rather than the shared tokio runtime. A qmux media session is -then pinned to one thread for its whole life, exactly like a QUIC one. - -## Plan - -`uring::Workers::bind` currently refuses `listen`'s `tcp`/`unix` listeners and -tells the operator to serve them from a separate tokio server. Replace that -refusal with real support: each worker binds its own listener in the reuseport -group and runs the router from -[stream](/quest/m1/uring-tcp/stream.md) on it. - -The split of work stays what `uring.rs` already documents: the worker owns -everything transport-shaped, while authentication and session supervision run -on the shared tokio runtime that owns the HTTP client, the timers, and the -origins. A qmux session handle is `Send + Sync` however its transport is -driven, which is what makes that handoff free here too. - -Keep the ops and web listeners' behavior identical: the same routes, the same -CORS scoping, the same landing-page fallback, and the same -`/certificate.sha256` fingerprint. Extend `tests/runtime_uring.rs` to prove a -WebSocket session and an HTTP route both work when served from a worker. - -## Required - -- [Stream](/quest/m1/uring-tcp/stream.md) - the module and adapters this - serves from diff --git a/quest/m2/wildcard/README.md b/quest/m1/wildcard/README.md similarity index 85% rename from quest/m2/wildcard/README.md rename to quest/m1/wildcard/README.md index 0f63541412..bd0b71686b 100644 --- a/quest/m2/wildcard/README.md +++ b/quest/m1/wildcard/README.md @@ -27,6 +27,13 @@ across the fleet in resident memory. ## Plan +The line sits in m1 so the announce API breaks once: `dynamic(pattern, route)` +takes a path Pattern from day one in +[#3190](/quest/m1/3190-align-origin-broadcast-creation-naming-across-language.md) +and [js-announce](/quest/m1/js-announce.md), with a prefix spelled `foo/**` +and anything else refused until Advertise lands. Advertise alone gates the +merge; Resolve and Demand are additive and may land on main. + ### What already exists, and what does not Route cost already names this case: "The original publisher seeds it with its @@ -35,8 +42,9 @@ would have to start working (a cold transcoder)" (`drafts/draft-lcurley-moq-lite.md`). `moq_token::Claims.publish` and `origin::Producer` gain versioned patterns through [Path patterns](/quest/m2/path-patterns/README.md), so advertisements reuse the -same exact containment check. [#2925](https://github.com/moq-dev/moq/pull/2925) -has since replaced `RouteCost` with `Cost { warm, cold }`. +same exact containment check. `Cost { warm, cold }` +(`rs/moq-net/src/model/origin.rs:426`) is the route cost since +[#2925](https://github.com/moq-dev/moq/pull/2925). [moq#3225](https://github.com/moq-dev/moq/pull/3225) moved a long way toward this. An announcement is now a route over a path *prefix*, `origin::Prefix` is @@ -45,18 +53,21 @@ into, and matching is segment-wise intersection in one place. So the wire, the model, and every binding already speak in covering claims rather than in per-broadcast announcements. -The routing table exists too, which it did not when this questline was written. -`Consumer::request_broadcast` resolves a local broadcast first, then -`best_server`: the longest covering prefix, filtered by the requester's excluded -hop, ordered by `route_order`, served on demand by the session that announced it -and cached per prefix in `ServeState.served`. That is the split-horizon-safe +The routing table exists too. `Consumer::request_broadcast` resolves a local +broadcast first, then `best_server`: the longest covering prefix, filtered by +the requester's excluded hop, ordered by `route_order` +(`rs/moq-net/src/model/origin.rs:633`), served on demand by the session that +announced it and cached per prefix in `ServeState.served` (`:764`). That is the split-horizon-safe lookup the old `origin::Dynamic` could not provide, and it is what -[Resolve](/quest/m2/wildcard/resolve.md) now extends rather than replaces. +[Resolve](/quest/m1/wildcard/resolve.md) now extends rather than replaces. -Route matching, by contrast, is prefix-only. The pattern matcher itself -exists: `moq_net::path` and `@moq/net`'s `Path` module own the shared -matching, containment, specificity, and rebasing that -[Advertise](/quest/m2/wildcard/advertise.md) builds on. +Route matching, by contrast, is prefix-only (`origin::Prefix`, +`rs/moq-net/src/model/origin.rs:498`). The pattern matcher itself exists: +`moq_net::path::{Pattern, Patterns, Segment}` (`rs/moq-net/src/path/pattern.rs:172`, +`patterns.rs:14`, `pattern.rs:39`) and `Path.Pattern` / `Path.Patterns` in +`js/net/src/path.ts` (`:526`, `:846`) own the shared matching, containment, +specificity, and rebasing that [Advertise](/quest/m1/wildcard/advertise.md) +builds on. What is genuinely missing, beyond patterns themselves, is content identity. Announcement `Epoch` was specified into lite-06 by @@ -141,7 +152,7 @@ field. composer waiting for an announcement that only demand would produce. The browser player currently enforces the opposite (`js/watch`'s `#isPathAnnounced` hides a catalog rendition with no exact-path - announcement); [Demand](/quest/m2/wildcard/demand.md) makes a covering wildcard count as + announcement); [Demand](/quest/m1/wildcard/demand.md) makes a covering wildcard count as availability there. - **Refusal is a typed stream reset, with no negative cache.** An advertiser resets a subscribe it will not serve, and the reset carries which KIND of @@ -209,14 +220,14 @@ worker pool, and a segment ending in `.pro` is the one predicate every source rule matcher excludes, so platform output is never recursively transcoded or recorded. -An earlier revision of this questline published contributions at mirrored paths -in reserved namespaces (`.transcode//...`) hidden by a new origin-consumer -overlay, because prefix-only matching needs the variable part of a path -trailing. That overlay was not a view transform: `pid/foo` and -`.transcode/pid/foo` are separate tree leaves with separate broadcast fronts, -so it had to build a logical front across roots that re-owned route selection, -content identity, the split-horizon guard, and splicing. The suffix pattern -deletes all of it while keeping what the mirror bought: +The rejected alternative publishes contributions at mirrored paths in reserved +namespaces (`.transcode//...`) hidden by an origin-consumer overlay, +because prefix-only matching needs the variable part of a path trailing. That +overlay is not a view transform: `pid/foo` and `.transcode/pid/foo` are +separate tree leaves with separate broadcast fronts, so it has to build a +logical front across roots that re-owns route selection, content identity, the +split-horizon guard, and splicing. The suffix pattern needs none of it while +keeping what the mirror buys: - **The grant needs no transform.** The customer addresses `foo.hang/transcode.pro`, a descendant of `foo.hang`, so an existing grant @@ -235,14 +246,14 @@ deletes all of it while keeping what the mirror bought: worker-versus-worker collisions are ordinary route selection at one tree node, not a cross-root front. -What the mirror bought and this deliberately gives up: a customer holding +What the mirror buys and this deliberately gives up: a customer holding `publish: ["pid/"]` CAN publish `foo.hang/transcode.pro` themselves, competing with or forging platform output. Both then resolve at one path, cost decides, and a live customer broadcast beats the worker's standby seed. That is confined to their own namespace, self-sabotage of their own catalog, never another project's, and is cheaper to allow and document than a reserved-name registry or a token transform. The mirror's SUBSCRIBE-only overlay asymmetry existed to -prevent exactly this and is gone with it. +prevent exactly this and goes with it. The archive is the same shape at the source path itself: a recording IS the broadcast, served from storage through the catch-all pattern. A wildcard names @@ -252,12 +263,12 @@ than announce state. ## Quests -- [Advertise](/quest/m2/wildcard/advertise.md) - moq-net encodes, forwards, and +- [Advertise](/quest/m1/wildcard/advertise.md) - moq-net encodes, forwards, and authorizes wildcard advertisements, without yet resolving one into a subscription -- [Resolve](/quest/m2/wildcard/resolve.md) - a relay resolves a subscribe or +- [Resolve](/quest/m1/wildcard/resolve.md) - a relay resolves a subscribe or FETCH for an unannounced path against the best matching wildcard -- [Demand](/quest/m2/wildcard/demand.md) - the browser player subscribes to a +- [Demand](/quest/m1/wildcard/demand.md) - the browser player subscribes to a catalog-referenced broadcast a wildcard covers, breaking the lazy-rendition deadlock diff --git a/quest/m2/wildcard/advertise.md b/quest/m1/wildcard/advertise.md similarity index 62% rename from quest/m2/wildcard/advertise.md rename to quest/m1/wildcard/advertise.md index f44c3a3fa7..66b98e64f3 100644 --- a/quest/m2/wildcard/advertise.md +++ b/quest/m1/wildcard/advertise.md @@ -8,8 +8,15 @@ resolving one into a subscription. ## Plan Following the draft, this goes in `rs/moq-net/src/lite/announce.rs` alongside -`AnnounceBroadcast`, gated on the lite-06 version check the route cost already -uses so older peers neither send nor receive it. +`AnnounceBroadcast` (`:38`), gated on the lite-06 version check the route cost +already uses so older peers neither send nor receive it. + +This starts after +[#3190](/quest/m1/3190-align-origin-broadcast-creation-naming-across-language.md) +makes `dynamic(pattern, route)` take a `moq_net::path::Pattern` in Rust and +the bindings, refusing anything that is not prefix-shaped; +[js-announce](/quest/m1/js-announce.md) mirrors that in js/net. This quest +lifts the refusal on the Rust side; no signature changes. The draft settled on a message of its own: ANNOUNCE_PATTERN (type 0x3 on the announce stream) carries the pattern as typed segments (kind 0 literal, 1 @@ -31,7 +38,8 @@ aligned with that binding, including capability gating and withdrawal tests. That gate is NOT sufficient on its own. `moq-lite-06-wip` is one ALPN with no sub-version, and `AnnounceBroadcast::decode` rejects an unknown message type -outright (`DecodeError::InvalidMessage`), which kills the announce stream. A +outright (`DecodeError::InvalidMessage`, `:159`), which kills the announce +stream. A relay running an earlier Lite06 build therefore negotiates the same version and then drops the session when a newer peer sends a wildcard. Land the DECODE side so an unknown announce type is tolerated before any build emits one, and treat @@ -44,28 +52,37 @@ re-demonstrates that it drops the stream, which is why emission has to wait. The state has a home already. Since [moq#3225](https://github.com/moq-dev/moq/pull/3225) a route is a flat -`RouteEntry` keyed by an opaque `origin::Prefix`, deliberately built as the -extension point a pattern type slots into: matching is segment-wise -intersection in one place, so a pattern extends `Prefix` internally without +`RouteEntry` (`rs/moq-net/src/model/origin.rs:737`) keyed by an opaque +`origin::Prefix` (`:498`), deliberately built as the extension point a pattern +type slots into: matching is segment-wise intersection in one place +(`intersect_prefix`, `:838`), so a pattern extends `Prefix` internally without touching `Route` or any signature. Teach `Prefix` the dialect the shared -matcher (`moq_net::path::Pattern`) provides rather than -adding a parallel table beside it. The set stays small either way (one entry per +matcher provides, `moq_net::path::{Pattern, Patterns, Segment}` +(`rs/moq-net/src/path/pattern.rs:172`, `patterns.rs:14`, `pattern.rs:39`; the +js matcher is `Path.Pattern` in `js/net/src/path.ts:526`), rather than adding +a parallel table beside it. The set stays small either way (one entry per advertiser per service, not per broadcast). A wildcard forwards like an announcement: accumulate the sending peer's declared -link cost onto its single varint (`Cost::charged` adds to both halves of a -broadcast's pair; a wildcard has one value because it can never be warm), append +link cost onto its single varint (`Cost::charged`, `origin.rs:467`, adds to +both halves of a broadcast's pair; a wildcard has one value because it can +never be warm), append the upstream hop id, discard one whose reconstructed path contains the receiver's own origin, and apply the same per-subscriber exclusion so a wildcard is never advertised back through a path that flows through the subscriber. Retraction and replacement reuse the id-referencing forms. Bound what a session may advertise by pattern containment against -`Producer`'s granted patterns, which already carry the publish scope the token -granted. An advertisement not contained by them is refused rather than -clamped, so a misconfigured worker fails loudly instead of quietly advertising -less than it thinks. The same exact containment handles literal-headed and -leading-star patterns without a special authorization rule. +`Producer`'s scope, which carries the publish scope the token granted. That +scope is prefix-based today (`PathPrefixes`, `origin.rs:19`, clamped by +`clamp_prefix` at `:1383`) until +[path-patterns/origin](/quest/m2/path-patterns/origin.md) makes it a pattern +set, so the check here is that the advertised pattern is contained by +`prefix/**` for one of the producer's prefixes; the same `Pattern` containment +serves both. An advertisement not contained is refused rather than clamped, so +a misconfigured worker fails loudly instead of quietly advertising less than +it thinks. The same exact containment handles literal-headed and leading-star +patterns without a special authorization rule. Wildcards are visible to subscribers, and #3225 already made that safe: an announcement is a covering claim, `Consumer::announced` yields @@ -83,3 +100,12 @@ refusal, root and descendant residuals from a `**` rebase, duplicates aggregated into one entry, and withdrawal firing only when the last advertiser leaves. Cover that a pattern and a literal prefix coexist in one route table and that a literal-only deployment behaves exactly as it does today. + +## Required + +- [#3190](/quest/m1/3190-align-origin-broadcast-creation-naming-across-language.md) - the Pattern parameter this quest widens + +## Related + +- [js-announce](/quest/m1/js-announce.md) - the js/net signature this quest widens +- [path-patterns/origin](/quest/m2/path-patterns/origin.md) - replaces the prefix scope the containment check runs against diff --git a/quest/m2/wildcard/demand.md b/quest/m1/wildcard/demand.md similarity index 69% rename from quest/m2/wildcard/demand.md rename to quest/m1/wildcard/demand.md index 5b2c3ff9e0..e3baaa824d 100644 --- a/quest/m2/wildcard/demand.md +++ b/quest/m1/wildcard/demand.md @@ -9,17 +9,18 @@ player never demands what it hides. ## Plan -The gate is JS-only, and it is already half fixed. -[moq#3225](https://github.com/moq-dev/moq/pull/3225) made `#isPathAnnounced` in -`js/watch/src/broadcast.ts` prefix-aware: it holds the set of announced -prefixes and accepts any that covers the path, so a route at `room/` already -makes `room/alice/cam.hang` selectable without naming it. What it cannot do is -match a pattern, since it tests with `Path.hasPrefix`. +The gate is JS-only, and it is already prefix-aware. +[moq#3225](https://github.com/moq-dev/moq/pull/3225) made `#isPathAnnounced` +(`js/watch/src/broadcast.ts:216`) hold the set of announced prefixes and accept +any that covers the path, so a route at `room/` already makes +`room/alice/cam.hang` selectable without naming it. What it cannot do is match +a pattern, since it tests with `Path.hasPrefix` (`:223`). So the remaining work is narrow: teach the JS client the wildcard -advertisement (`js/net`'s announce handling, mirroring what -[advertise](/quest/m2/wildcard/advertise.md) does in moq-net) and make the -covering test use the shared pattern matching rather than prefix containment. +advertisement (`js/net/src/announced.ts` and `js/net/src/lite/announce.ts`, +mirroring what [advertise](/quest/m1/wildcard/advertise.md) does in moq-net) +and make the covering test use `Path.Pattern` (`js/net/src/path.ts:526`) +rather than prefix containment. Withdrawal of the last covering wildcard hides the rendition again, the same reactive shape announcements have today. @@ -27,7 +28,7 @@ Do not simply delete the gate. It exists so the player does not subscribe to absent broadcasts and so renditions appear and disappear reactively with announcements. The Rust side needs nothing here: `moq-mux::Source` resolves references through `request_broadcast`, which -[resolve](/quest/m2/wildcard/resolve.md) teaches to consult patterns. +[resolve](/quest/m1/wildcard/resolve.md) teaches to consult patterns. Two existing soft spots to not reintroduce: the first evaluation runs before the announcement stream has populated, briefly hiding cross-broadcast @@ -44,5 +45,5 @@ nothing visibly. ## Required -- [Resolve](/quest/m2/wildcard/resolve.md) - recognizing the wildcard is useless +- [Resolve](/quest/m1/wildcard/resolve.md) - recognizing the wildcard is useless until the relay routes the resulting subscribe through it diff --git a/quest/m2/wildcard/resolve.md b/quest/m1/wildcard/resolve.md similarity index 88% rename from quest/m2/wildcard/resolve.md rename to quest/m1/wildcard/resolve.md index 56b02e436e..17f390345e 100644 --- a/quest/m2/wildcard/resolve.md +++ b/quest/m1/wildcard/resolve.md @@ -8,14 +8,15 @@ matching wildcard. ## Plan [moq#3225](https://github.com/moq-dev/moq/pull/3225) built the table this quest -was going to need. `Consumer::request_broadcast` resolves a local broadcast -first, then `best_server`, which filters routes to those covering the path, -drops any whose hop chain contains the requester's excluded hop, keeps the -longest covering prefix, and orders the survivors by `route_order`. The winning -session serves the request on demand, and `ServeState.served` caches the result -per path so repeat requests share one upstream subscription. The old -`origin::Dynamic` objection (one shared FIFO, no requester identity, no route -chain to check) no longer applies, because that is not the path a route takes. +needs. `Consumer::request_broadcast` resolves a local broadcast first, then +`best_server`, which filters routes to those covering the path, drops any whose +hop chain contains the requester's excluded hop, keeps the longest covering +prefix, and orders the survivors by `route_order` +(`rs/moq-net/src/model/origin.rs:633`). The winning session serves the request +on demand, and `ServeState.served` (`:764`) caches the result per path so +repeat requests share one upstream subscription. A route never passes through +`origin::Dynamic`'s shared FIFO, so requester identity and the hop chain are +both available to selection. So this quest extends a working table rather than standing one up: teach the route entries to hold a pattern instead of only a literal prefix, and teach @@ -96,4 +97,4 @@ Tests, at the process level with real sessions rather than an in-process stand-i ## Required -- [Advertise](/quest/m2/wildcard/advertise.md) +- [Advertise](/quest/m1/wildcard/advertise.md) diff --git a/quest/m2/2075-mirror-catalog-reservation-gating-in-moq-hang-js-hang.md b/quest/m2/2075-mirror-catalog-reservation-gating-in-moq-hang-js-hang.md new file mode 100644 index 0000000000..b9afec3c44 --- /dev/null +++ b/quest/m2/2075-mirror-catalog-reservation-gating-in-moq-hang-js-hang.md @@ -0,0 +1,40 @@ +# [M] Mirror catalog reservation gating in @moq/publish (js/publish) + +## Goal + +A browser publisher's first catalog snapshot is complete: renditions declared +in separate ticks (video, then audio) never emit a partial catalog that a +one-shot consumer (fMP4, MPEG-TS) locks onto. Wire and catalog schema are +unchanged; the gate is publisher-side timing only. + +## Plan + +Rust already gates: `catalog::Producer::reserve()` returns a `Reserved` +(`rs/moq-mux/src/catalog/producer.rs:360`), each rendition holds a clone until +its config is set, and the catalog is withheld until the last one drops, so +exactly one complete snapshot publishes (#2072, on main). + +The JS target is `CatalogProducer` in `js/publish/src/catalog.ts:15-46`: +`mutate` (`:20-25`) pushes every edit to every subscriber at once, and `serve` +(`:33-46`) seeds a new subscriber with the current value. The gate point is +the catalog rebuild in `js/publish/src/broadcast.ts:171-195`, which writes +whatever renditions have resolved so far. + +Settle the design, then implement: + +- TS shape: `reserve()` returning a handle released when the rendition's config + is set or its effect cleans up, an options flag, or an explicit + `complete()`. The `signals`/`Effect` lifecycle may suggest its own idiom. +- Withhold the initial publish until every reservation resolves, then publish + incrementally; producers that never reserve keep publishing incrementally. +- Whether JS needs the shared gate across several importers, or only + complete-first-snapshot for one producer. + +Additive on `@moq/publish`, so it lands on main. If the chosen shape changes +`mutate` or `serve`, it is breaking and returns to m1 on dev. +`demo/web/src/publish.ts:418` drives `catalog.mutate` directly and follows any +change. + +## Closes + +- [#2075](https://github.com/moq-dev/moq/issues/2075) - close this issue when the quest finishes diff --git a/quest/m2/2318-js-net-remaining-capability-gaps-vs-rs-moq-net-setup-role.md b/quest/m2/2318-js-net-remaining-capability-gaps-vs-rs-moq-net-setup-role.md new file mode 100644 index 0000000000..a06c864a6e --- /dev/null +++ b/quest/m2/2318-js-net-remaining-capability-gaps-vs-rs-moq-net-setup-role.md @@ -0,0 +1,39 @@ +# [S] js/net: declare a track end ahead of the live edge and observe it + +## Goal + +A browser publisher can end a track at a declared boundary ahead of the live +edge, and a browser consumer can await that boundary, matching Rust's +`finish_at` and `finished()`. + +## Plan + +Additive on `@moq/net`, so it belongs on main; main gains `final()` with the +dev merge, so start after that. + +- Add `Track.Producer.finishAt(final)`, mirroring `finish_at` + (`rs/moq-net/src/model/track.rs:1322`): the boundary must exceed the highest + produced sequence, groups below it are still accepted, groups at or above it + are refused. +- The lite subscriber drains SUBSCRIBE_END and drops the sequence + (`js/net/src/lite/subscriber.ts:744-757`). Feed it into the consumer's + existing `final()` (`js/net/src/track.ts:983-991`) so a remote clean end is + observable before the live edge reaches it. +- Add an awaitable `finished()` twin of `final()`, mirroring Rust + (`track.rs:3540`): it resolves with the boundary once known and rejects on + abort. + +The rest of #2318 landed: SETUP role (`js/net/src/lite/setup.ts:19-93`), typed +`SessionError` and `StreamError` with code registries +(`js/net/src/error.ts:15-236`), `startAt` and `endAt` (`track.ts:1034`, +`:1043`), `latest()` (`:979`), `payload` on every frame type, and the dead +`SubscribeOptions` export is gone. A producer-side prefix announce is +[JS announce](/quest/m1/js-announce.md). + +## Required + +- [Merge dev](/quest/m1/merge-dev.md) - builds on dev-only code that reaches `main` with the merge + +## Closes + +- [#2318](https://github.com/moq-dev/moq/issues/2318) - close this issue when the quest finishes diff --git a/quest/m2/2848-follow-the-bandwidth-grant-in-moq-audio-instead-of.md b/quest/m2/2848-follow-the-bandwidth-grant-in-moq-audio-instead-of.md new file mode 100644 index 0000000000..3c99a50b12 --- /dev/null +++ b/quest/m2/2848-follow-the-bandwidth-grant-in-moq-audio-instead-of.md @@ -0,0 +1,82 @@ +# [M] moq-audio's Opus encoder follows its bandwidth grant + +## Goal + +An Opus track published through `moq_audio::encode::Producer` retunes to its +share of the connection's estimate: a grant below the configured bitrate +lowers the encoder at once, room coming back raises it gradually, and a link +too small for the configured audio rate sheds audio bits instead of stalling. +The reservation's ceiling stays the configured bitrate. PCM keeps reserve-only +usage: it claims its fixed rate and never reads the grant. + +The rate policy has one home shared by every sender, so audio and video back +off the same way. + +## Plan + +Today audio reserves but never follows. The capture driver takes the +reservation once the layout reveals the encoded rate +(`rs/moq-audio/src/encode/capture.rs:340`, `:471-474`) and nothing reads its +grant; `Options::bandwidth` documents that +(`rs/moq-audio/src/encode/producer.rs:49-62`). Video already follows through +`rate::Control` (`rs/moq-video/src/encode/producer.rs:467-476`). + +- Move `rs/moq-video/src/encode/rate.rs` (`Policy`, `Control`) to + `moq_mux::rate::Control`. moq-mux is a dependency of both crates + (`rs/moq-audio/Cargo.toml:82`, `rs/moq-video/Cargo.toml:110`); its `pace.rs` + is wall-clock delivery of export frames, a different concern. moq-video is + 0.0.23, so deleting `moq_video::encode::rate` ships on main. Update + moq-video's import (`producer.rs:32`) and the `Options::bandwidth` doc + (`producer.rs:237`). +- The follow loop lives in `moq_audio::encode::Producer`, not the capture + driver: `Producer::new` (`producer.rs:255`) already takes `Options` with the + allocator (`:62`), so it reserves the configured bitrate against + `self.track().demand()`, holds a `Reservation::consumer()` plus a + `rate::Control`, and feeds each grant to `Encoder::set_bitrate` + (`rs/moq-audio/src/encode/encoder.rs:411`). Capture and moq-ffi + (`rs/moq-ffi/src/audio.rs:254`) build the Producer, so both adapt without + their own loop; the capture driver's `_reservation` goes away. Public entry + points for a manual ceiling stay `Producer::set_bitrate` (`producer.rs:295`) + and `Encoder::set_bitrate`. +- Floor: `set_opus_bitrate` refuses anything outside + `opus::bitrate_floor(codec_rate, frame_size).max(500)` to + `300_000 * channels` (`encoder.rs:345-346`, + `rs/moq-audio/src/opus.rs:137-143`). `Policy::min` defaults to a tenth of + the ceiling (`rate.rs:55-60`); for Opus it is the codec floor, so a grant + below it clamps there and never errors. The reservation's ceiling is the + configured bitrate; only the policy target moves. +- PCM: `pcm::bitrate(sample_rate, channels)` is `pub(crate)` + (`rs/moq-audio/src/pcm.rs:9`), `Config::bitrate` is refused for it + (`encoder.rs:269-272`) and so is `set_bitrate` (`encoder.rs:412-414`). A PCM + Producer reserves its fixed rate and runs no policy. That is the same + reserve-only usage passthrough imports need + ([#2859](/quest/m1/2859-passthrough-imports-reserve-no-bandwidth-so-a-co-resident.md)), + so nothing new is added for it. +- Priority is unchanged: `PRIORITY` puts audio at 80 and video at 60 + (`rs/hang/src/catalog/priority.rs:21-26`), so the allocator fills audio's + reservation before video sees a bit and audio is squeezed only once the link + cannot carry audio alone. Worth doing for that tail, not worth blocking on. + +Tests: the `Control` unit tests move with the module; an Opus Producer whose +grant drops below its configured bitrate reports the lower `bitrate()` after +one policy step, holds it on a `None` grant, and ramps back when the grant +returns; a grant below the Opus floor clamps at the floor; a PCM Producer +ignores every grant. + +This targets 0.0.x crates and needs the allocator on main, so it starts after +the dev merge. + +## Required + +- [Merge dev](/quest/m1/merge-dev.md) - builds on dev-only code that reaches `main` with the merge + +## Closes + +- [#2848](https://github.com/moq-dev/moq/issues/2848) - close this issue when the quest finishes + +## Related + +- [#2815](/quest/m1/2815-lift-adaptive-stage-refusal.md) - two capture stages sharing one allocator +- [#2859](/quest/m1/2859-passthrough-imports-reserve-no-bandwidth-so-a-co-resident.md) - reserve-only usage for passthrough tracks +- [Binding rate control](/quest/m1/binding-rate-control.md) - the bindings consume the same `moq_mux::rate::Control` +- [#2709](/quest/m1/2709-per-broadcast-bandwidth-estimates-and-reservation.md) - the JS twin diff --git a/quest/m2/2924-moq-relay-tls-rotation-is-not-atomic-across-thread-per.md b/quest/m2/2924-moq-relay-tls-rotation-is-not-atomic-across-thread-per.md new file mode 100644 index 0000000000..5dcd1580a6 --- /dev/null +++ b/quest/m2/2924-moq-relay-tls-rotation-is-not-atomic-across-thread-per.md @@ -0,0 +1,86 @@ +# [XL] moq-relay: TLS rotation is not atomic across thread-per-core QUIC workers + +## Goal + +Every tokio QUIC worker serves the same certificate at the same moment, a +rotation applies to the whole group at once, `/certificate.sha256` is +authoritative for every worker, and `--listen-tls-generate` works with +`runtime.workers`. + +## Plan + +Follow-up from #2921 (M1 part 1 of #2875), which added `runtime.workers` and +documented this rather than fixing it. + +### Mechanism + +Each tokio QUIC worker builds its own listener with `listen::Config::init`, +so each independently: + +- loads the `listen.tls.cert` / `listen.tls.key` files, +- spawns its own `tls::reload_certs` watcher, +- snapshots its own mTLS client roots. + +`Workers` keeps the *first* worker's `Certificates` handle, and that is the +one `/certificate.sha256` publishes. + +Three consequences: + +1. **Rotation is not atomic.** During a reload, workers can be serving + different certificates. Both are valid, so TLS still completes, but the + group is briefly inconsistent and the published fingerprint may match only + some of them. +2. **A failed watcher diverges permanently.** `tls::reload_certs` logs and + continues when it cannot watch; that worker then serves the old + certificate indefinitely while its siblings rotate, and nothing surfaces + the split. +3. **N redundant watchers** on the same files, one per worker. + +The mTLS roots have the same shape: `--listen-tls-root` is snapshotted per +worker. + +#2921 rejects `--listen-tls-generate` with workers, since that case is not +merely inconsistent but broken: each worker would generate a *different* +self-signed certificate while the fingerprint endpoint advertises one of +them. + +### What exists + +`tls::Listen::identity: Option` (rs/moq-tokio/src/tls.rs:1240) is +the in-memory served identity, but it is not the handle this needs: + +- Its only constructor is `Identity::generate` (:294). Nothing builds one + from on-disk PEM, so the relay cannot load once and hand the result to N + listeners. +- It is static. An `Identity` has no reload; the watcher only follows + `cert`/`key` paths. +- It is additive. `ServeCerts::load_certs` (tls.rs:2805) pushes it onto + the same list as the `cert`/`key` files and the `generate` hostnames + (:2832-2834), so it is served *alongside* disk material, not instead of it. + +The io_uring path is the prior art: `uring::Workers::bind` reads exactly one +certificate/key pair once, on the shared runtime, and hands every worker the +same material (rs/moq-relay/src/uring.rs:118-120). It refuses `tls.generate` +for the same reason the tokio group does (:126-128), and it does not reload. + +### Direction + +Give `moq-tokio` one served-identity handle that is loadable from PEM or +generated, hot-reloadable, and shared by reference: the relay loads and +watches once on the shared runtime and every listener (tokio workers and +io_uring workers alike) resolves certificates through the same handle. +Rotations then apply to the group at once, a watcher failure is one failure, +and `--listen-tls-generate` with workers is "generate once, share it". The +mTLS roots ride the same handle. + +Sized XL because it reshapes `tls::Listen` (a published `moq-tokio` API used +by every binary), touches both worker runtimes, and needs a rotation test +that proves every worker flips in one step. + +## Required + +- [Merge dev](/quest/m1/merge-dev.md) - builds on dev-only code that reaches `main` with the merge + +## Closes + +- [#2924](https://github.com/moq-dev/moq/issues/2924) - close this issue when the quest finishes diff --git a/quest/m2/2964-quic-workers-dropping-one-split-server-resizes-the.md b/quest/m2/2964-quic-workers-dropping-one-split-server-resizes-the.md new file mode 100644 index 0000000000..9397037bf0 --- /dev/null +++ b/quest/m2/2964-quic-workers-dropping-one-split-server-resizes-the.md @@ -0,0 +1,67 @@ +# [M] QUIC workers: dropping one split() Server resizes the reuseport group + +## Goal + +`moq_tokio::worker::Workers` can no longer lose one reuseport socket while +its siblings keep serving: `split(self)` consumes the group and returns a +handle that owns every socket, each `Server` shares it, and the group stops +when any member's `run()` future completes. + +## Plan + +Found in the second review round on #2921. The worker group is off by default +and Linux-only, so this is not urgent, but it is the last unenforced part of +the "bound once, in order, never resized" invariant that connection-ID +steering rests on. + +### Mechanism + +`Workers::split` (rs/moq-tokio/src/worker/group.rs:193) returns +`Vec<(Server, Spawner<'_>)>`. The `Spawner` borrows the group, so a caller +cannot drop a worker's *thread* on its own. But `Server` owns the +`quinn::Endpoint`, and therefore the socket, and `Server::listen(mut self)` +consumes it, so the accept loop has to own it. That leaves two ways to take +one socket out of the group: + +1. Drop a returned `Server` without running it. +2. Let the future built from one `Server` return while its siblings keep + serving. + +Either way Linux moves the last socket in the reuseport array into the +vacated slot. The cBPF filter still reduces modulo the original count, and +connection IDs encoding the moved member now select an index past the end of +the array, so the kernel falls back to hashing the 4-tuple. Live sessions on +a worker that never failed get misrouted. The `split` docs (group.rs:185-192) +say so and point here. + +`moq-relay` already does the right thing: `Relay::run` ends everything on the +first worker to finish (rs/moq-relay/src/relay.rs:359-385) and then calls +`Workers::shutdown` after the select (:428-448). An embedder gets no such +guarantee. + +### Design + +`Spawner::run` already takes a `FnOnce() -> Future` builder (group.rs:289), +so the builder shape is settled. What changes is ownership: + +- `split(self)` consumes `Workers` and returns a group handle that owns every + socket. No socket can be dropped alone because no caller holds one. +- Each `Server` holds a clone of that handle, so running or dropping a + `Server` never closes its socket; the group does, all at once. +- The group stops when any member's `run()` future completes, matching what + `Relay::run` does by hand today. +- No callback parameters. The builder passed to `run` stays a builder. + +Regression: a test that drops one `Server` and lets the others serve, then +proves the socket count and the steering filter are unchanged and a session +on a surviving worker keeps its route. + +## Required + +- [Reuseport group formation](/quest/m2/reuseport-group.md) - the + `moq_sock::shard::Group` side of the same invariant; the group handle here + is built on a group that is complete before it serves + +## Closes + +- [#2964](https://github.com/moq-dev/moq/issues/2964) - close this issue when the quest finishes diff --git a/quest/m2/2991-net-coalesce-dynamic-tracks-and-preserve-sequences-across.md b/quest/m2/2991-net-coalesce-dynamic-tracks-and-preserve-sequences-across.md new file mode 100644 index 0000000000..0db7792501 --- /dev/null +++ b/quest/m2/2991-net-coalesce-dynamic-tracks-and-preserve-sequences-across.md @@ -0,0 +1,90 @@ +# [M] net: coalesce dynamic tracks and preserve sequences across replacements + +## Goal + +A broadcast has one logical dynamic track per name in both languages: at most +one pending or live producer, one on-demand request that every subscriber fans +out from, and a group and datagram sequence namespace that survives the +producer being replaced. Behavior only, so it ships on main. + +## Plan + +Dynamic tracks should have one logical identity per broadcast and track name, +but the Rust and JavaScript models violate different parts of that invariant. + +### Rust resets sequences when a dynamic producer is replaced + +A closed dynamic track is removed from the broadcast's weak cache. The next +subscription creates a fresh `track::Request` (`rs/moq-net/src/model/track.rs:3779`), +and `Request::new` creates a fresh `TrackState`. Because `max_sequence` +(`:199`) is empty, both `append_group` (`:1184`) and `append_datagram` +(`:1216`) restart at sequence 0. + +That conflicts with the relay's logical track splicing. +`resume::Producer::takeover` (`rs/moq-net/src/model/resume.rs:328`) retains +the previous live edge and starts a replacement at `latest + 1`. Groups from a +restarted producer are therefore filtered until its counter catches up, +causing the same playback stall fixed for JavaScript in #2953. + +The takeover tests in `resume.rs` (`takeover_computes_boundary` `:2313`, +`takeover_splices_mid_group` `:3257`, +`takeover_splices_a_replacement_that_resends_the_head` `:3346`, +`takeover_rolls_past_a_finished_group` `:3467`, +`takeover_after_empty_segment_keeps_live_edge` `:3617`) create their +replacement groups with explicit sequences, so none of them exercises +`append_group()` on a restarted producer; using it there would create group 0 +and leave the subscriber stalled. Those are the tests to extend. Explicit group +or datagram writes can raise the old producer's shared sequence edge further, +making the catch-up window longer. + +### JavaScript permits concurrent same-name dynamic producers + +`BroadcastProducer.subscribe()` calls the internal `subscribe` with +`register = false` (`js/net/src/broadcast.ts:49-55`). Multiple publishing-side +subscriptions for the same name therefore enqueue independent requests and +create independent `track.Producer` instances. + +#2953 made those concurrent producers share a sequence allocator. That +prevents duplicate sequence allocation, but concurrent producers are the wrong +model. Publishing-side subscriptions should coalesce like +`BroadcastConsumer.subscribe()` and Rust's `broadcast::Consumer::track`: one +pending or live producer per broadcast and name, one on-demand request, and +multiple subscribers fanning out from it. + +### Desired behavior + +- A broadcast has at most one pending or live dynamic track producer per track + name. +- Concurrent JavaScript publishing-side subscriptions for the same name emit + one request and share its accepted producer. +- Subscription options from all subscribers remain aggregated on that request. + `track::Request` already does this in Rust: it carries `prev_subscription` + (`track.rs:3786`) and re-combines the aggregate whenever a subscriber + changes (`:3905-3919`). +- After that producer closes, a later request creates a new producer but + continues the group and datagram sequence namespace for that broadcast and + name. +- Explicit group and datagram writes advance the shared allocator. +- A new broadcast generation starts each track at sequence 0. +- Rust and JavaScript expose the same lifecycle and sequencing behavior. + +The sequence allocator is shared across producer incarnations without sharing +the closed producer's cache or terminal state. + +### Regression coverage + +- JavaScript: two `BroadcastProducer.subscribe()` calls for one name produce + one request and both subscribers receive from the accepted producer. +- JavaScript: remove or replace #2953's concurrent-producer test, since + concurrent same-name producers should not be representable. +- Rust and JavaScript: close a dynamic producer after group and datagram + sequences have advanced, re-request the same name, and verify the + replacement appends at the next sequence. +- Rust relay model: extend the `resume.rs` takeover tests above so a + replacement produced with `append_group()` is delivered immediately rather + than filtered until catch-up. +- Both implementations: verify a separate broadcast generation starts at 0. + +## Closes + +- [#2991](https://github.com/moq-dev/moq/issues/2991) - close this issue when the quest finishes diff --git a/quest/m2/3056-watch-video-decoder-captures-the-rewind-generation-at.md b/quest/m2/3056-watch-video-decoder-captures-the-rewind-generation-at.md new file mode 100644 index 0000000000..b53cf62c18 --- /dev/null +++ b/quest/m2/3056-watch-video-decoder-captures-the-rewind-generation-at.md @@ -0,0 +1,39 @@ +# [S] watch: the video decoder resets on a declared discontinuity + +## Goal + +A frame submitted before a declared discontinuity never surfaces after it. +The decoder is reset and reconfigured when the container consumer reports a +discontinuity, the way the audio decoder already does, so queued pictures from +the old epoch are discarded rather than parked against the new clock. + +## Plan + +`#onDiscontinuity` (`js/watch/src/video/decoder.ts:528-535`) clears +`timestamp`, clears the buffered ranges, and calls `sync.reset()`, but never +`decoder.reset()`, so chunks still queued in the `VideoDecoder` keep decoding. +The output callback's generation guard reads the counter when the frame comes +out (`:312`), not when its chunk went in, so a frame decoded after the bump +compares the new value against itself at `:335` and passes. It then waits +against the re-anchored clock for the full distance between the two timelines. + +- Call `decoder.reset()` and re-`configure()` in `#onDiscontinuity`, as the + audio decoder does (`js/watch/src/audio/decoder.ts:374-376`). WebCodecs + `reset()` discards queued outputs, so stale frames never surface. +- Keep the post-await guard. `reset()` cannot cancel a callback that already + holds a frame and is parked in `Promise.race([wait, effect.cancel])` + (`decoder.ts:332-334`); `sync.reset()` releases exactly that wait, and the + guard is what stops it writing `timestamp` and `frame` after the reset. +- The regression needs a real WebCodecs decoder, so it lives in a browser + harness rather than a bun unit test. + +The decoder is the same on main, so the fix lands there once the timeline +quest below has settled what a discontinuity is. + +## Required + +- [Monotonic timeline](/quest/m1/monotonic-timeline.md) - settles what a discontinuity is before the decoder's reaction to one is pinned + +## Closes + +- [#3056](https://github.com/moq-dev/moq/issues/3056) - close this issue when the quest finishes diff --git a/quest/m2/3126-moq-bench-every-readme-example-fails-to-parse-and.md b/quest/m2/3126-moq-bench-every-readme-example-fails-to-parse-and.md new file mode 100644 index 0000000000..85cf247736 --- /dev/null +++ b/quest/m2/3126-moq-bench-every-readme-example-fails-to-parse-and.md @@ -0,0 +1,32 @@ +# [S] moq-bench: per-interval latency percentiles + +## Goal + +A steady-state window of a `moq-bench` run has its own latency distribution: +the `--startup` ramp no longer bakes into `latency_p50_ms`, `p90`, `p99`, and +`max`, so two JSONL lines can be joined over a window the way the counters +already can. + +## Plan + +`Latency` (`rs/moq-bench/src/stats.rs:208-253`) accumulates buckets for the +whole run and snapshots cumulative percentiles. The README documents them as +cumulative (`rs/moq-bench/README.md:47-48`) and tells the reader to skip the +ramp (`:152`), which the counters allow and latency does not: p99 reads as +pure ramp artifact (hundreds of ms against a 1-2 ms steady p50) because a few +first groups landed while the swarm was still connecting. + +- Emit per-interval percentiles (or the interval histogram) beside the + cumulative ones: keep the previous snapshot's buckets, diff, and compute + p50/p90/p99 over the delta. `latency_samples` is already per-line. +- Document the new fields and update the methodology paragraph. + +The README examples parse today (`--connect`, `README.md:66-71`; `--file` is +a real flag, `rs/moq-bench/src/config.rs:95-98`; the released +`--client-connect` spelling is refused by name, `config.rs:389-395`). + +moq-bench is 0.0.x, so this lands on main. + +## Closes + +- [#3126](https://github.com/moq-dev/moq/issues/3126) - close this issue when the quest finishes diff --git a/quest/m2/709-automatic-letsencrypt-support.md b/quest/m2/709-automatic-letsencrypt-support.md index a79ae41df6..0be4481749 100644 --- a/quest/m2/709-automatic-letsencrypt-support.md +++ b/quest/m2/709-automatic-letsencrypt-support.md @@ -43,7 +43,7 @@ dependency. under `acme.dir`, and the existing `notify` file watcher swaps it in on the next handshake, so no new `moq-tokio` surface is needed and per-worker rotation rides - [TLS rotation atomicity](/quest/m1/2924-moq-relay-tls-rotation-is-not-atomic-across-thread-per.md). + [TLS rotation atomicity](/quest/m2/2924-moq-relay-tls-rotation-is-not-atomic-across-thread-per.md). One file is what makes the rotation atomic: the watcher reloads both paths on every event and refuses a mismatched pair, so separate key and chain files would open a window where a new chain meets the old key, and a @@ -117,7 +117,7 @@ reload. ## Required -- [TLS rotation atomicity](/quest/m1/2924-moq-relay-tls-rotation-is-not-atomic-across-thread-per.md) - every supported worker must share one reloadable identity before ACME can promise renewal +- [TLS rotation atomicity](/quest/m2/2924-moq-relay-tls-rotation-is-not-atomic-across-thread-per.md) - every supported worker must share one reloadable identity before ACME can promise renewal ## Closes diff --git a/quest/m2/933-video-rotation-metadata-not-propagated-from-mobile-camera.md b/quest/m2/933-video-rotation-metadata-not-propagated-from-mobile-camera.md new file mode 100644 index 0000000000..f8dbd0bcb9 --- /dev/null +++ b/quest/m2/933-video-rotation-metadata-not-propagated-from-mobile-camera.md @@ -0,0 +1,32 @@ +# [S] Set catalog rotation from the live camera's orientation + +## Goal + +A phone held in portrait publishes a catalog `rotation`, so `` +shows the picture upright instead of the viewer guessing from the aspect +ratio. Repro: `` on an iPhone in portrait, watched from a +desktop; the catalog says 640x480 with no `rotation` and the picture is +sideways. + +## Plan + +The rest of #933 is done: the renderer applies `catalog.video.rotation` +(`js/watch/src/video/renderer.ts:187-198`) and file import rotates stored +footage (`js/publish/src/source/file.ts:283`, `:303-305`). Live camera capture +never sets the field. + +- Add `rotation: Getter` beside `flip` in the broadcast inputs + (`js/publish/src/broadcast.ts:25-26`) and write `section.rotation` next to + `section.flip` (`:178`). +- Detect orientation in `js/publish/src/source/camera.ts` (the track's + settings, `screen.orientation`, or `VideoFrame.rotation`) and re-publish on + change. +- Wire the signal through `` beside `#flip` + (`js/publish/src/element.ts:121-122`). +- `demo/web` sets neither `flip` nor `rotation`, so nothing changes there. + +Additive on `@moq/publish`, so it lands on main. + +## Closes + +- [#933](https://github.com/moq-dev/moq/issues/933) - close this issue when the quest finishes diff --git a/quest/m2/README.md b/quest/m2/README.md index 5c178e82ce..931e41af32 100644 --- a/quest/m2/README.md +++ b/quest/m2/README.md @@ -16,10 +16,14 @@ is an unreleased dependency, or whose symptom nobody is hitting, sits next to the feature it shares code with instead of holding a rank in m0 that nothing can act on. Each still carries its own plan and regression test. +Work that builds on dev-only code but breaks nothing and gates nothing (the +io_uring stream sessions, the perf line, the QUIC worker and quiche quests) +also sits here and starts on `main` after the dev merge, as does anything +targeting a `0.0.x` crate. + ## Quests - [Generation](/quest/m2/hls-generation.md) - init URLs follow the rendition config and segment URLs carry an embedder-supplied generation, so caching can be re-enabled -- [Wildcard](/quest/m2/wildcard/README.md) - a service advertises a path pattern it could serve instead of enumerating broadcasts - [Path patterns](/quest/m2/path-patterns/README.md) - one versioned matcher for every predicate over broadcast paths: tokens, origins, interest - [OBS native codecs](/quest/m2/obs-moq-video/README.md) - remove FFmpeg decoding dependencies, deliver GPU frames, and use native audio/video encoders - [Audio codecs](/quest/m2/audio-codecs/README.md) - a broadcast that plays in the browser plays natively: platform decoders and encoders behind a backend seam, HE-AAC, and channel layouts up to 7.1 @@ -28,10 +32,16 @@ can act on. Each still carries its own plan and regression test. - [Drain](/quest/m2/drain/README.md) - relay restarts drain sessions over GOAWAY instead of hard-dropping them - [Custom QUIC](/quest/m2/quic/README.md) - noq as the upstream for per-stream ACK progress, reliable reset, hierarchical scheduling, and qmux +- [Stream sessions](/quest/m2/uring-tcp/README.md) - serve WebSocket and HTTP from the io_uring workers, where io_uring pays off most +- [Perf](/quest/m2/perf/README.md) - eliminate measured hot-path costs across moq-uring, kio, and the moq-net model +- [#2924](/quest/m2/2924-moq-relay-tls-rotation-is-not-atomic-across-thread-per.md) - every QUIC worker shares one reloadable served identity, so rotation is atomic and generate works with workers +- [#2964](/quest/m2/2964-quic-workers-dropping-one-split-server-resizes-the.md) - `split(self)` returns a group handle that owns every socket and stops when any member finishes +- [Reuseport group](/quest/m2/reuseport-group.md) - `moq_sock::shard::Group` exposes no socket before the filter is attached and retains all of them - [Bandwidth estimate release](/quest/m2/web-transport-bandwidth-estimate.md) - web-transport-quinn reports quinn's BBR bandwidth estimate and ships a release carrying it - [#2847](/quest/m2/2847-the-quinn-backends-send-bandwidth-estimate-is-cwnd-rtt.md) - quinn backend: bump to the releases that report the controller bandwidth estimate instead of cwnd/rtt - [Safari WebTransport](/quest/m2/safari-webtransport.md) - WebKit browsers return to WebTransport once WebKit 319818 ships fixed - [Benchmark comparisons](/quest/m2/performance-comparisons.md) - retained evidence, repeated paired runs, and uncertainty for performance claims +- [#3126](/quest/m2/3126-moq-bench-every-readme-example-fails-to-parse-and.md) - moq-bench reports per-interval latency percentiles so the ramp leaves the steady state - [Relay profiling](/quest/m2/performance-profiles.md) - reproducible CPU and allocation captures under the existing workloads - [Browser benchmarks](/quest/m2/browser-benchmarks.md) - measure JS transport, container, decode, and render costs in an identified browser - [Reader buffering](/quest/m2/stream-buffering.md) - measure and bound repeated prefix copying under fragmented input @@ -41,11 +51,20 @@ can act on. Each still carries its own plan and regression test. - [Route gauge](/quest/m2/route-gauge.md) - an operator sees how many routes a relay holds for a path - [PoP skipping](/quest/m2/pop-skipping/README.md) - short cold paths for unpopular broadcasts without losing warm backhaul dedup - [Route cost in the JS origin](/quest/m2/route-cost.md) - the browser origin ranks routes by cost and hops like Rust instead of newest-first +- [JS abandonment](/quest/m2/js-subscribe-abandonment.md) - a viewer returning during IETF subscribe setup keeps its track across microtasks +- [IETF stream types](/quest/m2/ietf-uni-stream-types.md) - padding streams are discarded stream-only and an unknown uni type closes the session, per draft-21 +- [Control timeout code](/quest/m2/control-timeout-code.md) - an unanswered control request resets with CONTROL_TIMEOUT on lite and INTERNAL_ERROR on IETF +- [#2991](/quest/m2/2991-net-coalesce-dynamic-tracks-and-preserve-sequences-across.md) - one dynamic producer per track name in both languages, with the sequence namespace surviving a replacement +- [JS track end](/quest/m2/2318-js-net-remaining-capability-gaps-vs-rs-moq-net-setup-role.md) - js/net declares a track end ahead of the live edge and observes the publisher's SUBSCRIBE_END - [E2EE](/quest/m2/e2ee/README.md) - TypeScript and Rust peers interoperate over encrypted broadcasts no relay can decrypt - [SEI](/quest/m2/sei/README.md) - H.26x SEI moves into its own track, readable without subscribing to video - [Processor](/quest/m2/processor/README.md) - a customer-run worker publishes an on-demand contribution with scoped access - [Timeline wall](/quest/m2/timeline-wall.md) - every built-in publisher anchors its timeline to wall time; the anchor is data, never a sync source -- [Duration marker](/quest/m2/duration-marker.md) - a video group ends with an empty frame that closes its last frame's duration; audio never writes one +- [#3056](/quest/m2/3056-watch-video-decoder-captures-the-rewind-generation-at.md) - watch: the video decoder resets on a declared discontinuity +- [#933](/quest/m2/933-video-rotation-metadata-not-propagated-from-mobile-camera.md) - the catalog rotation follows the live camera's orientation +- [#2075](/quest/m2/2075-mirror-catalog-reservation-gating-in-moq-hang-js-hang.md) - @moq/publish gates the first catalog snapshot until every reserved track is described +- [#2848](/quest/m2/2848-follow-the-bandwidth-grant-in-moq-audio-instead-of.md) - the Opus producer follows its bandwidth grant through `moq_mux::rate::Control`, moved out of moq-video +- [Ladder](/quest/m2/ladder/README.md) - a transcode ladder adapts to the uplink it publishes over, instead of encoding every live rung at its ceiling - [LOC duration marker](/quest/m2/loc-duration-marker.md) - LOC producers write the marker once released consumers skip it - [#2278](/quest/m2/2278-watch-absolute-wall-clock-latency-target-for-synchronized.md) - hang: a timeline consumer exposes the wall anchor, and the library never syncs playback on it - [Time stretch](/quest/m2/watch-audio-time-stretch.md) - js/watch: the audio ring converges by time-stretching instead of skipping or going silent @@ -73,6 +92,7 @@ can act on. Each still carries its own plan and regression test. - [Dart leaks](/quest/m2/dart-leak.md) - the generated Dart bindings leak native memory on every call - [Dart publish](/quest/m2/dart-publish.md) - the packages are built and dry-run clean but exist nowhere consumers can install from - [Dart codec parity](/quest/m2/dart-codecs.md) - Dart is the one binding that cannot originate media +- [libmoq fetch](/quest/m2/libmoq-fetch.md) - libmoq gains group fetch and a pixel format and size knob on the video decoder - [Direct3D11 render import](/quest/m2/render-d3d11.md) - Windows presents without downloading every frame to system memory - [#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 - [#2907](/quest/m2/2907-bind-the-browser-through-moq-ffi-uniffi-instead-of-a.md) - Bind the browser through moq-ffi/UniFFI instead of a second hand-written wasm API diff --git a/quest/m2/control-timeout-code.md b/quest/m2/control-timeout-code.md new file mode 100644 index 0000000000..82fbdd8d98 --- /dev/null +++ b/quest/m2/control-timeout-code.md @@ -0,0 +1,57 @@ +# [S] A control timeout is not a delivery timeout + +## Goal + +A request stream torn down because the peer never answered resets with +CONTROL_TIMEOUT rather than DELIVERY_TIMEOUT. Both languages agree on the +moq-lite code, the IETF wire says INTERNAL_ERROR, and a relay carrying the +reset across a hop does not change what it says. + +## Plan + +`DELIVERY_TIMEOUT` describes content that missed its deadline (draft-20 section +3.3.4, and the same claim moq-lite makes with 0x2). Every local timeout uses it +today, including the ones that never touched content: + +- `js/net/src/ietf/subscriber.ts:434` aborts the request stream with the + `SUBSCRIBE_OK` timer's `TimeoutError`, and `js/net/src/ietf/publisher.ts` + does the same with the PUBLISH_NAMESPACE response timer (both through + `withTimeout`). `localStreamCode` in `js/net/src/error.ts:288` maps every + `TimeoutError` to `StreamCode.DeliveryTimeout`. +- Rust does the same through `From<&Error> for StreamError` + (`rs/moq-net/src/error.rs:587`): `Error::Timeout` becomes + `StreamError::DeliveryTimeout`. + +So a peer that opens a stream and goes quiet is told its content was late. + +Decided: + +- **The code is 0x31 CONTROL_TIMEOUT**, in moq-lite's own 48-63 range + (`drafts/draft-lcurley-moq-lite.md:268`; 0x30 NO_CAPACITY at `:311`, 0x32 + goes to [group overflow](/quest/m1/group-overflow-abort.md)). It mirrors the + session code 0x11 CONTROL_MESSAGE_TIMEOUT (`:287`, `SessionError::Timeout` + at `rs/moq-net/src/error.rs:79`): one condition, two scopes. Add the row to + the stream table and `StreamError::ControlTimeout` beside `DeliveryTimeout`. +- **`Error::Timeout` is not split.** Only the wire code differs: the control + paths that time out a response abort their stream with + `StreamError::ControlTimeout` directly instead of through the `From<&Error>` + mapping, and `ControlTimeout` decodes back to `Error::Timeout` the way + `DeliveryTimeout` does (`error.rs:511`). js/net mirrors it with + `StreamCode.ControlTimeout: 0x31` (`js/net/src/error.ts:71-95`), and the two + control timers pass it explicitly rather than relying on the `TimeoutError` + mapping, which keeps meaning delivery. +- **IETF stays INTERNAL_ERROR.** The moq-transport registry has no value for + it: `to_stream_code` (`rs/moq-net/src/ietf/error.rs:78`) falls through at + `:92`, and `sharedStreamCode` (`js/net/src/error.ts:281`) gates the same + way. Nothing to add there. +- **Ships on main.** `StreamError` is `#[non_exhaustive]` (`error.rs:125`), + so the variant is additive, and a wire change alone does not need dev. + +Tests: `stream_codes_round_trip` (`error.rs:691`) gains `ControlTimeout` in +its registered list; the js `error.test.ts` mirror does the same; a subscribe +whose `SUBSCRIBE_OK` never arrives resets with 0x31 on lite and INTERNAL_ERROR +on every IETF draft. + +## Related + +- [Group overflow](/quest/m1/group-overflow-abort.md) - takes 0x32, the neighbouring code in the same range diff --git a/quest/m2/e2ee/README.md b/quest/m2/e2ee/README.md index a8e0756fe4..f93cd331dd 100644 --- a/quest/m2/e2ee/README.md +++ b/quest/m2/e2ee/README.md @@ -66,4 +66,4 @@ The profile starts from IETF Secure Objects where its object model maps exactly, ## Related - [archive](/quest/m1/archive/README.md) - protected broadcasts are deliberately outside recording and replay formats -- [HLS playable](/quest/m1/hls-playable.md) - stock HLS and DASH require plaintext media and exclude `.e2ee` broadcasts +- [Merge dev](/quest/m1/merge-dev.md) - its HLS soak (a fresh viewer joining a days-old broadcast, playable since #3240) covers plaintext broadcasts only; stock HLS and DASH exclude `.e2ee` broadcasts diff --git a/quest/m2/ietf-uni-stream-types.md b/quest/m2/ietf-uni-stream-types.md new file mode 100644 index 0000000000..9efb7e0ed5 --- /dev/null +++ b/quest/m2/ietf-uni-stream-types.md @@ -0,0 +1,42 @@ +# [S] Validate IETF unidirectional stream types + +## Goal + +Accept valid padding streams and close the session for genuinely unknown +stream types according to the negotiated moq-transport draft. Behavior only, +so it ships on main. + +## Plan + +`rs/moq-net/src/ietf/session.rs` routes every non-SETUP uni stream to +`run_uni_group` (`:708`), which rejects padding and unknown types alike while +leaving the session alive. That stream-only rejection reaches the wire as +INTERNAL_ERROR on both branches, because nothing registers a code for it: on +dev the handler maps a session-scoped error to `StreamError::Internal` and +aborts the reader (`:697-704`); on main it is `reader.stop(to_stream_code(&err))` +(`:583` there), which falls through to INTERNAL_ERROR the same way. + +draft-21 settles what each stream type means: a stream whose type the +endpoint does not recognize MUST close the session, and a padding stream +(type 0x132B3E28) MUST be discarded, which an endpoint may do by cancelling +it. The tree negotiates up to draft-20 (`rs/moq-net/src/ietf/version.rs:12`), +so apply that split to every supported draft and check the earlier ones for +the padding type value and whether draining is required. + +- Classify stream types before spawning a group handler. Handle PADDING per + draft, draining it where required and otherwise cancelling it with a + stream-only code, and propagate a genuinely unknown type to the session + driver as a protocol violation. Keep ordinary group failures scoped to their + streams. +- The test `unknown_uni_type_does_not_claim_the_session_closed` + (`session.rs:1267`) asserts the current behavior, an INTERNAL_ERROR stop and + no session close, and flips: an unknown type now closes the session and + stops nothing on its own. +- Add a padding test asserting a stream-only cancel with no session close, and + keep `a_group_for_a_retired_alias_is_stopped_with_cancelled` (`:1255`), which + pins that a dropped group never closes the session. + +Consult [draft-21 section 11.5](https://www.ietf.org/archive/id/draft-ietf-moq-transport-21.html) +for the wording, and [draft-19 section 3.4 and section 11.5.1](https://www.ietf.org/archive/id/draft-ietf-moq-transport-19.html) +plus [draft-20 section 11.5.1](https://www.ietf.org/archive/id/draft-ietf-moq-transport-20.html) +for the drafts the tree negotiates. diff --git a/quest/m1/js-subscribe-abandonment.md b/quest/m2/js-subscribe-abandonment.md similarity index 51% rename from quest/m1/js-subscribe-abandonment.md rename to quest/m2/js-subscribe-abandonment.md index 7f969c0572..df3ed49137 100644 --- a/quest/m1/js-subscribe-abandonment.md +++ b/quest/m2/js-subscribe-abandonment.md @@ -7,12 +7,16 @@ of receiving an abandonment error. ## Plan -- In `js/net/src/ietf/subscriber.ts`, `waitAbandoned` checks demand before - resolving through `Promise.race`. Another microtask can attach a viewer before - the outer catch closes the producer. Reproduce that ordering in a subscriber - regression test. -- Recheck demand and commit the close in the same synchronous continuation. - When demand returns, retain the existing setup operation and timeout budget. +The setup path is the same on main, so the fix lands there. + +- `waitAbandoned` (`js/net/src/ietf/subscriber.ts:423-428`) checks demand and + resolves through `Promise.race`; another microtask can attach a viewer before + the catch closes the producer at `:449`. Reproduce that ordering in + `js/net/src/ietf/subscriber.test.ts`: the case at `:673` covers only the + established serving loop. +- Recheck demand and commit the close in the same synchronous continuation, + the way the serving loop does (`:511-523`). When demand returns, keep the + existing setup operation and timeout budget. - Cover abandonment before SUBSCRIBE_OK, demand returning before the commit, and late setup completion. Verify cancellation and alias cleanup still happen exactly when owed. diff --git a/quest/m1/ladder/README.md b/quest/m2/ladder/README.md similarity index 60% rename from quest/m1/ladder/README.md rename to quest/m2/ladder/README.md index f291c2dbf8..6462236dd2 100644 --- a/quest/m1/ladder/README.md +++ b/quest/m2/ladder/README.md @@ -23,19 +23,26 @@ the all-stalled lowest fallback; and routing, decoder, and presentation identities are split so a metadata-only change cannot rebuild WebCodecs. What remains is the publisher side. The allocator on `dev` -([moq#2854](https://github.com/moq-dev/moq/pull/2854)) already divides a -connection's estimate by `track::Info::priority`, filling a tier before the -next sees a bit and splitting max-min fair within one. So a controller that -assigns descending priorities down the ladder gets correct allocation with no -scheduler change. Transmission order is the separate question, and the -allocator's own docs say so: it "is not what orders the local send queue: that -ranks by each subscription's own priority". +([moq#2854](https://github.com/moq-dev/moq/pull/2854)) divides a connection's +estimate by `track::Info::priority`, filling a tier before the next sees a +bit and splitting max-min fair within one. A controller that assigns +descending priorities down the ladder gets correct allocation from that +alone. Send order is the same number, not a separate question: +`track::Info::priority` is documented as the tie-break between subscriptions +of equal subscriber priority (`rs/moq-net/src/model/track.rs:100-102`), but +`Priority::cmp` never reads it (`rs/moq-net/src/lite/priority.rs:48-62`), so +today a congested uplink sheds every rung alike. The controller honors it +there too, so one priority decides what to produce and what to send first. + +This line is m2: `moq-transcode` is 0.0.17, and it needs the allocator on +main, which arrives with the dev merge. ### Adaptive bands -`VideoConfig.bitrate` stays the configured maximum and never follows the -instantaneous target. For a rendition with configured maximum `max` and the -next lower rendition's `lower`: +`moq_transcode::Rung::bitrate` (`rs/moq-transcode/src/ladder.rs:14-17`) stays +the configured maximum and never follows the instantaneous target. For a +rendition with configured maximum `max` and the next lower rendition's +`lower`: ```text stall = (max + 2 * lower) / 3 @@ -48,9 +55,12 @@ is successfully applied. Catalog state follows the last target the encoder *accepted*, not the one the controller requested, so a transient rate-control failure retains the last applied target rather than lying. -Start with the existing rate controller's five percent hysteresis, immediate -decreases, and gradual upward ramp. No second re-entry threshold and no dwell -timer until measurements show the catalog state flaps. +Start with the shared rate policy's five percent hysteresis, immediate +decreases, and gradual upward ramp (`rate::Control`, moving to +`moq_mux::rate` with +[#2848](/quest/m2/2848-follow-the-bandwidth-grant-in-moq-audio-instead-of.md)). +No second re-entry threshold and no dwell timer until measurements show the +catalog state flaps. ### Non-goals @@ -62,12 +72,10 @@ encoders on every target change. ## Quests -- [Controller](/quest/m1/ladder/controller.md) - one controller owns every - rung's share, target, and stalled state -- [Fetch and catalog](/quest/m1/ladder/fetch.md) - uncached FETCH encodes at +- [Controller](/quest/m2/ladder/controller.md) - one controller owns every + rung's share, target, stalled state, and send order +- [Fetch and catalog](/quest/m2/ladder/fetch.md) - uncached FETCH encodes at the shared applied target, and rung state survives a source catalog refresh -- [Transmission order](/quest/m1/ladder/transmit.md) - lower renditions win - the publisher-side tie-break among equal subscriber priorities ## Closes @@ -75,6 +83,6 @@ encoders on every target change. ## Related -- [#2848](/quest/m1/2848-follow-the-bandwidth-grant-in-moq-audio-instead-of.md) - the other sender that reserves but never follows its grant +- [#2848](/quest/m2/2848-follow-the-bandwidth-grant-in-moq-audio-instead-of.md) - the other sender that reserves but never follows its grant - [#2859](/quest/m1/2859-passthrough-imports-reserve-no-bandwidth-so-a-co-resident.md) - passthrough tracks that reserve nothing at all - [#2857](/quest/m1/binding-rate-control.md) - non-Rust publishers cannot reach rate control regardless of what the ladder does diff --git a/quest/m2/ladder/controller.md b/quest/m2/ladder/controller.md new file mode 100644 index 0000000000..e2deaad064 --- /dev/null +++ b/quest/m2/ladder/controller.md @@ -0,0 +1,80 @@ +# [L] Ladder controller + +## Goal + +One controller owns every generated rung's share, encoder target, stalled +state, and send order for one output bandwidth domain, so a ladder adapts to +its uplink instead of encoding every live rung at its ceiling, and transport +shedding drops the top of the ladder first. + +## Plan + +Add an optional bandwidth input to the transcode configuration and wire the +CLI's publisher session into it. `transcode` is not stageable +(`rs/moq-cli/src/args.rs:593-596`), so it dials its own session +(`rs/moq-cli/src/main.rs:236`, `rs/moq-cli/src/transcode.rs:130-140`) and +that session is the whole bandwidth domain. Supplying no input preserves +today's fixed-rate behavior exactly and never publishes congestion-induced +`stalled` state, which is what keeps this additive. + +The controller subdivides that estimate across the ladder and applies the +band boundary from the [questline](/quest/m2/ladder/README.md), including the +lowest rung's `max / 3` case. `moq_transcode::Ladder` is ascending: `new` +sorts by configured maximum (`rs/moq-transcode/src/ladder.rs:101`) and +`rungs()` is lowest first (`:125-128`), so the next lower rendition the +formula reads is the preceding entry and the lowest rung is the one with +none. + +### One priority for allocation and send order + +Assign descending `track::Info::priority` down the ladder. Every rung is +stamped `PRIORITY.video` today (`rs/moq-transcode/src/rung.rs:133`, +`rs/moq-transcode/src/lib.rs:352`, `:409`). The allocator fills a tier +before the next sees a bit, so that alone protects lower rungs' allocation. + +Honor the same number in `Priority::cmp` +(`rs/moq-net/src/lite/priority.rs:48-62`): subscriber priority stays first +(`:51-53`), then the publisher's `track::Info::priority` breaks the tie, +then the subscribe-id fallback (`:56-59`), then newest group. That is what +`Info::priority` already claims to do (`rs/moq-net/src/model/track.rs:100-102`), +and it is what the `BitrateUnsupported` fallback leans on: without it, an +encoder that cannot retune degrades the whole ladder equally instead of +protecting the bottom. A subscriber asking for a higher rendition ahead of a +lower one still gets what it asked for. Fix the allocator doc that presents +send order as unrelated (`rs/moq-net/src/model/bandwidth.rs:229-238`). + +### Keep honest + +- **Requested and applied targets are different numbers.** Catalog state + follows what the encoder accepted. A transient rate-control failure keeps + the last applied target and retries on a later material movement. +- **`BitrateUnsupported` is an explicit fallback, not a silent one.** Mirror + moq-video's handling (`rs/moq-video/src/encode/producer.rs:383-386`): such + an encoder keeps its configured maximum, publishes `stalled: true` whenever + the allocation is below it, and clears only when the full maximum fits + again. It reclaims no encoder work by design, and it must be visible in + logs and tests rather than pretending the target was applied. +- **An idle rung must be able to recover.** Only demanded rungs consume + allocation, but a rung that loses all demand while stalled cannot be left + permanently stalled. Evaluate its hypothetical share against the current + estimate and active lower-priority reservations, without giving it a real + share or encoding probe traffic. + +Acceptance: one demanded rung reaching its configured maximum on a permissive +uplink; several rungs sharing one uplink with lower ones protected; a +supported rung adapting down, clamping, stalling, and recovering without its +advertised maximum moving; the 5 Mbps over 2.5 Mbps boundary landing near +3.33 Mbps; the default 350 kbps lowest rung stalling near 117 kbps; an +unsupported encoder holding its maximum and not recovering early; no +bandwidth input preserving existing behavior exactly; and for send order, +equal subscriber priority where the lower rung wins, conflicting subscriber +priority where the subscriber's order wins, and a custom ladder order. + +## Required + +- [Merge dev](/quest/m1/merge-dev.md) - builds on dev-only code that reaches `main` with the merge + +## Related + +- [Hierarchical stream scheduling](/quest/m2/quic/scheduler.md) - supplies the + fair subscription buckets beneath this rendition policy diff --git a/quest/m2/ladder/fetch.md b/quest/m2/ladder/fetch.md new file mode 100644 index 0000000000..4e529ecc2a --- /dev/null +++ b/quest/m2/ladder/fetch.md @@ -0,0 +1,35 @@ +# [M] Fetch and catalog + +## Goal + +An uncached FETCH encodes at the ladder's shared applied target instead of +opening a fresh encoder at the configured maximum, and a source catalog +refresh cannot erase current rung state. + +## Plan + +Two independent leaks of controller state, both cheap to close once the +controller owns it. + +The FETCH path opens a new encoder per requested group at +`Rung::bitrate`: `spawn_fetch` and `fetch` +(`rs/moq-transcode/src/rung.rs:460-481`) call `encode`, which sets +`config.bitrate = Some(self.info.bitrate)` (`:107-116`), so a stalled ladder +still burns full-rate encodes on demand. Make it read the controller's +applied target and participate in the same allocation. A stalled rung stays +manually fetchable, and cache hits are untouched. + +Catalog mutations publish full HANG, HANGZ, and MSF snapshots today +(`rs/moq-mux/src/catalog/producer.rs:86-113`). Coalesce every rung state +change from one controller iteration into a single publication, and make a +later source catalog snapshot compose with current generated-rung state +rather than overwrite it. + +Acceptance: a fresh FETCH using the shared applied target while a cache hit +encodes nothing, and one source catalog refresh leaving stalled rung state +intact. + +## Required + +- [Controller](/quest/m2/ladder/controller.md) - there is no shared applied + target to read until the controller owns one diff --git a/quest/m2/libmoq-fetch.md b/quest/m2/libmoq-fetch.md new file mode 100644 index 0000000000..6af744712e --- /dev/null +++ b/quest/m2/libmoq-fetch.md @@ -0,0 +1,32 @@ +# [M] libmoq: fetch_group and the video format knob + +## Goal + +A C embedder can fetch one cached group by sequence, and can ask the video +decoder for the pixel format and target size it wants instead of always +receiving packed I420 at stream resolution. Both are additive on `moq.h`, so +they ship on main. + +## Plan + +- Fetch: moq-ffi's `MoqTrackConsumer::fetch_group` + (rs/moq-ffi/src/consumer.rs:271) has no C mirror; `rs/libmoq/src/api.rs` + has no `fetch` symbol at all. Add a group fetch that delivers through the + same frame callback, handle, and terminal-status contract as + `moq_consume_track`, decoded through the container or raw like the FFI. +- Video format knob: `moq_consume_video` (api.rs:2366-2380) takes a catalog + index and a max age and delivers encoded frames; decoding runs on + `moq-video` with NVIDIA and VAAPI on (rs/libmoq/Cargo.toml:32) behind + `moq_decode_video` (rs/libmoq/src/video.rs:618). Its decode-side config + `moq_video_decoder_output` (video.rs:125-140) carries only `max_age_ms`: output is always tightly packed I420 at + stream resolution. Add the pixel format and target size there, which is + what the struct was left in place for. `moq play` (rs/moq-cli, the `play` + feature) is the worked example of the shape. + +Each addition regenerates `moq.h`, touches `cpp/obs/src` only if used, and +updates `doc/lib/c/index.md`. That page's capability list (:39) already +claims group fetch for C; the fetch symbol makes it true. + +## Related + +- [#2152](/quest/m1/2152-libmoq-c-abi-catch-up-with-the-moq-ffi-surface.md) - the dev half: dynamic track serving and server-side accept diff --git a/quest/m2/loc-duration-marker.md b/quest/m2/loc-duration-marker.md index df9b7c7603..e03e73eff9 100644 --- a/quest/m2/loc-duration-marker.md +++ b/quest/m2/loc-duration-marker.md @@ -8,7 +8,7 @@ LOC consumer skips it. ## Plan -[Duration marker](/quest/m2/duration-marker.md) lands the consumer-side skip +[Duration marker](/quest/m1/duration-marker.md) lands the consumer-side skip in `rs/moq-mux/src/container/loc` and `js/loc` but leaves LOC producers alone, because a released LOC consumer submits an empty payload to the decoder. When the bullet below clears, have the LOC producers write the marker at `cut` and @@ -16,5 +16,5 @@ the bullet below clears, have the LOC producers write the marker at `cut` and ## Required -- [Duration marker](/quest/m2/duration-marker.md) - the consumer-side skip and the contract +- [Duration marker](/quest/m1/duration-marker.md) - the consumer-side skip and the contract - A release of `moq-mux` and `@moq/loc` whose consumers skip an empty LOC payload has shipped diff --git a/quest/m2/mobile/README.md b/quest/m2/mobile/README.md index 046c34b35f..12744722fe 100644 --- a/quest/m2/mobile/README.md +++ b/quest/m2/mobile/README.md @@ -32,5 +32,5 @@ follow that. ## Related -- [#933](/quest/m1/933-video-rotation-metadata-not-propagated-from-mobile-camera.md) - rotation metadata from a mobile camera +- [#933](/quest/m2/933-video-rotation-metadata-not-propagated-from-mobile-camera.md) - rotation metadata from a mobile camera - [Video hardware validation](/quest/m3/video-hardware.md) - physical hardware evidence for each claimed GPU path diff --git a/quest/m2/path-patterns/README.md b/quest/m2/path-patterns/README.md index 30eadb45f7..a6c76de504 100644 --- a/quest/m2/path-patterns/README.md +++ b/quest/m2/path-patterns/README.md @@ -91,5 +91,5 @@ CAT cannot represent `pid/*/chat`. ## Related -- [Wildcard advertisements](/quest/m2/wildcard/README.md) - routing adopts the +- [Wildcard advertisements](/quest/m1/wildcard/README.md) - routing adopts the matcher while retaining its own cost, pool, refusal, and resolution work diff --git a/quest/m2/perf/3122-moq-uring-2-5-of-relay-cpu-is-vdso-clock-reads-the-drive.md b/quest/m2/perf/3122-moq-uring-2-5-of-relay-cpu-is-vdso-clock-reads-the-drive.md new file mode 100644 index 0000000000..5cf8c631c3 --- /dev/null +++ b/quest/m2/perf/3122-moq-uring-2-5-of-relay-cpu-is-vdso-clock-reads-the-drive.md @@ -0,0 +1,64 @@ +# [M] moq-uring: ~2.5% of relay CPU is vdso clock reads; the drive loop and its callers each re-read Instant::now() + +## Goal + +A worker drive turn reads the clock once and hands that instant down, so +`[vdso]` falls from its ~2.5% share of io_uring relay CPU to the tokio path's +~0.7%, with the QUIC timeout and keep-alive arming unchanged in effect. + +## Plan + +Profiling the io_uring relay (`dev` @ `fc57e0175`, `perf record -F 499`, +relay process only) shows `[vdso]` as a top-5 DSO, at roughly 3x its share on +the tokio worker path: + +| DSO | video, io_uring | video, tokio workers | chat, io_uring | +|---|---|---|---| +| `moq-relay` | 65.89% | 68.61% | 68.96% | +| `[kernel.kallsyms]` | 22.64% | 22.97% | 20.49% | +| `libc.so.6` | 5.74% | 4.48% | 6.03% | +| **`[vdso]`** | **2.95%** | **0.72%** | **2.55%** | + +That is `clock_gettime`. Roughly 2.5% of relay CPU spent reading the clock. +The profile is the quiche flavor; re-measure on the default backend (noq +through the `quinn/` module) before and after. + +Where the reads are: + +- The drive loop reads once per turn to fire timers + (`self.shared.timers.borrow_mut().fire(Instant::now())`, + rs/moq-uring/src/worker.rs:183). +- The quiche driver reads again on the same turn in `arm_keep_alive` + (rs/moq-uring/src/quic/quiche/connection.rs:850-855), plus quiche's own + `Instant::now()` inside `on_timeout` / `timeout`. +- The default backend has no `arm_keep_alive`; it reads the clock for + `close` (rs/moq-uring/src/quic/quinn/connection.rs:239), `handle_timeout` + (:671), and `poll_transmit` (:786). The last one runs once per GSO train, + since `flush` stages one train per turn (see + [Egress requeue](/quest/m2/perf/egress-requeue.md)). + +The same profile shows the timer heap at ~1.6%: +`::set` 0.92% plus +`btree::search::search_tree` 0.65%. `timer::Heap` is a +`BTreeMap<(Instant, u64), Rc>` (rs/moq-uring/src/timer.rs:19-20), so +every QUIC timeout re-arm is an O(log n) map removal and insertion with `Rc` +traffic. The #2875 design note called for a timer wheel. Not urgent at these +connection counts, but it is on the same hot path and grows with it. + +`moq_net::runtime::Runtime::now` (rs/moq-net/src/runtime.rs:128) is the +natural place to hand the current turn's instant down instead of having each +layer re-read it. Sample once per drive turn and pass it through `fire`, the +keep-alive arming, `handle_timeout`, `poll_transmit`, and the quiche timeout +calls. + +Acceptance: `[vdso]` share in the `perf` profile on both flavors, relay CPU +via `just bench BASE` on Linux, and the existing keep-alive and idle-timeout +tests unchanged. + +## Required + +- [Merge dev](/quest/m1/merge-dev.md) - builds on dev-only code that reaches `main` with the merge + +## Closes + +- [#3122](https://github.com/moq-dev/moq/issues/3122) - close this issue when the quest finishes diff --git a/quest/m1/perf/3129-moq-uring-write-the-webtransport-stream-header-at-open.md b/quest/m2/perf/3129-moq-uring-write-the-webtransport-stream-header-at-open.md similarity index 87% rename from quest/m1/perf/3129-moq-uring-write-the-webtransport-stream-header-at-open.md rename to quest/m2/perf/3129-moq-uring-write-the-webtransport-stream-header-at-open.md index 346f4ec806..6a30e7009b 100644 --- a/quest/m1/perf/3129-moq-uring-write-the-webtransport-stream-header-at-open.md +++ b/quest/m2/perf/3129-moq-uring-write-the-webtransport-stream-header-at-open.md @@ -2,17 +2,12 @@ ## Goal -Implement and verify the behavior tracked in [#3129](https://github.com/moq-dev/moq/issues/3129) -within the issue's stated scope and boundaries. +A web-mode `SendStream` (rs/moq-uring/src/quic/web.rs) has its WebTransport +header on the wire before `poll_open_uni` hands it back, so `finish()` never +owes one and the `finishing` state machine goes away. ## Plan -Use the public issue's scope, implementation notes, and acceptance criteria -below as the starting plan. Reconcile paths and assumptions with the current -tree before implementation. - -### Issue context - Follow-up to #3105 (item 4), deliberately left out of #3110. Raised by Codex reviewing that PR and verified against the code. ##### Where it stands @@ -36,6 +31,10 @@ The reason it is not a small change: `poll_open_uni` would have to hold a half-o Worth confirming the trade too: making `open` block on credit moves the backpressure earlier, which is more correct but changes when a caller learns about it. +## Required + +- [Merge dev](/quest/m1/merge-dev.md) - builds on dev-only code that reaches `main` with the merge + ## Closes - [#3129](https://github.com/moq-dev/moq/issues/3129) - close this issue when the quest finishes diff --git a/quest/m1/perf/3199-moq-uring-remove-sq-indirection-and-per-enter-ring-fd.md b/quest/m2/perf/3199-moq-uring-remove-sq-indirection-and-per-enter-ring-fd.md similarity index 80% rename from quest/m1/perf/3199-moq-uring-remove-sq-indirection-and-per-enter-ring-fd.md rename to quest/m2/perf/3199-moq-uring-remove-sq-indirection-and-per-enter-ring-fd.md index 7057ad810e..af1cf3c992 100644 --- a/quest/m1/perf/3199-moq-uring-remove-sq-indirection-and-per-enter-ring-fd.md +++ b/quest/m2/perf/3199-moq-uring-remove-sq-indirection-and-per-enter-ring-fd.md @@ -2,17 +2,12 @@ ## Goal -Implement and verify the behavior tracked in [#3199](https://github.com/moq-dev/moq/issues/3199) -within the issue's stated scope and boundaries. +The worker ring is built with `IORING_SETUP_NO_SQARRAY` and its fd registered +with `IORING_REGISTER_RING_FDS`, a kernel-gated test pins both, and the +measured relay CPU at fixed load is neutral or better. ## Plan -Use the public issue's scope, implementation notes, and acceptance criteria -below as the starting plan. Reconcile paths and assumptions with the current -tree before implementation. - -### Issue context - Follow-up to #2875. `Worker::new` enables `SINGLE_ISSUER`, `DEFER_TASKRUN`, and `COOP_TASKRUN`, but the ring still has an SQ array and each `io_uring_enter` resolves the normal ring fd. Both costs are avoidable below the existing Linux 6.12 floor. @@ -33,6 +28,10 @@ Follow-up to #2875. Run the existing io-uring echo and relay workloads before and after. Record relay CPU, cycles, cache misses, `io_uring_enter` calls, and throughput at fixed load. Keep each optimization independently ablatable and retain it only if the measured result is neutral or positive. +## Required + +- [Merge dev](/quest/m1/merge-dev.md) - builds on dev-only code that reaches `main` with the merge + ## Closes - [#3199](https://github.com/moq-dev/moq/issues/3199) - close this issue when the quest finishes diff --git a/quest/m1/perf/3200-moq-uring-batch-completion-wakeups-with-min-timeout.md b/quest/m2/perf/3200-moq-uring-batch-completion-wakeups-with-min-timeout.md similarity index 80% rename from quest/m1/perf/3200-moq-uring-batch-completion-wakeups-with-min-timeout.md rename to quest/m2/perf/3200-moq-uring-batch-completion-wakeups-with-min-timeout.md index f108450cfb..cc5ebd3724 100644 --- a/quest/m1/perf/3200-moq-uring-batch-completion-wakeups-with-min-timeout.md +++ b/quest/m2/perf/3200-moq-uring-batch-completion-wakeups-with-min-timeout.md @@ -1,18 +1,14 @@ -# [S] moq-uring: batch completion wakeups with MIN_TIMEOUT +# [M] moq-uring: batch completion wakeups with MIN_TIMEOUT ## Goal -Implement and verify the behavior tracked in [#3200](https://github.com/moq-dev/moq/issues/3200) -within the issue's stated scope and boundaries. +`maybe_park` (rs/moq-uring/src/worker.rs:363) waits with a CQE batch target +and a `min_wait_usec`, the three return paths are tested, and the benchmark +matrix shows fewer wakeups per message without a p99 regression before any +default changes. ## Plan -Use the public issue's scope, implementation notes, and acceptance criteria -below as the starting plan. Reconcile paths and assumptions with the current -tree before implementation. - -### Issue context - Follow-up to #2875. The worker requires `IORING_FEAT_MIN_TIMEOUT`, but `maybe_park` currently waits for one completion and does not set `min_wait_usec`. The kernel therefore wakes the thread for each first CQE even when a few microseconds of coalescing could amortize enter, CQ, and task-dispatch overhead. @@ -39,6 +35,10 @@ The kernel returns when `N` CQEs arrive, when `t` expires after partial progress Benchmark chat, 1:1 video, and fanout workloads with `N = 1/4/8/16` and `t = 0/5/10/20 us`. Record CQEs per wake, enters per second, CPU per message, p50, p99, and p999 latency. Pick no production default until the latency budget and CPU win are both demonstrated. +## Required + +- [Merge dev](/quest/m1/merge-dev.md) - builds on dev-only code that reaches `main` with the merge + ## Closes - [#3200](https://github.com/moq-dev/moq/issues/3200) - close this issue when the quest finishes diff --git a/quest/m1/perf/3201-moq-uring-use-sendmsg-zc-for-large-udp-gso-trains.md b/quest/m2/perf/3201-moq-uring-use-sendmsg-zc-for-large-udp-gso-trains.md similarity index 80% rename from quest/m1/perf/3201-moq-uring-use-sendmsg-zc-for-large-udp-gso-trains.md rename to quest/m2/perf/3201-moq-uring-use-sendmsg-zc-for-large-udp-gso-trains.md index 7d2935b594..5e8c6bdf92 100644 --- a/quest/m1/perf/3201-moq-uring-use-sendmsg-zc-for-large-udp-gso-trains.md +++ b/quest/m2/perf/3201-moq-uring-use-sendmsg-zc-for-large-udp-gso-trains.md @@ -1,18 +1,13 @@ -# [S] moq-uring: use SENDMSG_ZC for large UDP GSO trains +# [M] moq-uring: use SENDMSG_ZC for large UDP GSO trains ## Goal -Implement and verify the behavior tracked in [#3201](https://github.com/moq-dev/moq/issues/3201) -within the issue's stated scope and boundaries. +GSO trains above a measured byte threshold go out as `SENDMSG_ZC`, the TX +lease outlives the notification CQE on every path, and the sweep shows it +beats `SendMsg` end to end before it is on by default. ## Plan -Use the public issue's scope, implementation notes, and acceptance criteria -below as the starting plan. Reconcile paths and assumptions with the current -tree before implementation. - -### Issue context - Follow-up to #2875. The UDP path already assembles up to 64 KiB GSO trains in stable pool buffers, then submits `SendMsg` and recycles the buffer at the first CQE. Large trains are the promising case for `SENDMSG_ZC`; individual QUIC datagrams are likely below the copy-avoidance crossover. @@ -34,6 +29,10 @@ The UDP path already assembles up to 64 KiB GSO trains in stable pool buffers, t Sweep the threshold across realistic chat and media packet trains. Record relay CPU, goodput, CQEs per send, copy-fallback rate, TX-pool pressure, p99 latency, and memory residency at fixed offered load. Enable it by default only where the end-to-end result beats regular `SendMsg`. +## Required + +- [Merge dev](/quest/m1/merge-dev.md) - builds on dev-only code that reaches `main` with the merge + ## Closes - [#3201](https://github.com/moq-dev/moq/issues/3201) - close this issue when the quest finishes diff --git a/quest/m1/perf/3202-moq-uring-use-fixed-file-slots-for-worker-udp-sockets.md b/quest/m2/perf/3202-moq-uring-use-fixed-file-slots-for-worker-udp-sockets.md similarity index 77% rename from quest/m1/perf/3202-moq-uring-use-fixed-file-slots-for-worker-udp-sockets.md rename to quest/m2/perf/3202-moq-uring-use-fixed-file-slots-for-worker-udp-sockets.md index a18bee14f4..77422151a9 100644 --- a/quest/m1/perf/3202-moq-uring-use-fixed-file-slots-for-worker-udp-sockets.md +++ b/quest/m2/perf/3202-moq-uring-use-fixed-file-slots-for-worker-udp-sockets.md @@ -1,18 +1,14 @@ -# [S] moq-uring: use fixed-file slots for worker UDP sockets +# [M] moq-uring: use fixed-file slots for worker UDP sockets ## Goal -Implement and verify the behavior tracked in [#3202](https://github.com/moq-dev/moq/issues/3202) -within the issue's stated scope and boundaries. +Worker UDP sockets live in a ring-owned fixed-file table and every socket SQE +addresses a `types::Fixed` slot (today `types::Fd`, rs/moq-uring/src/udp.rs:934, +:976, :1014), with slot teardown ordered so a stale SQE can never hit a reused +fd. ## Plan -Use the public issue's scope, implementation notes, and acceptance criteria -below as the starting plan. Reconcile paths and assumptions with the current -tree before implementation. - -### Issue context - Follow-up to #2875. Every UDP SQE currently uses `types::Fd`, so the kernel resolves and takes references on the socket fd for each receive, send, and cancellation path. Worker sockets are long-lived and naturally fit a ring-owned fixed-file table. @@ -34,6 +30,10 @@ Registered files let SQEs address a stable table slot with `types::Fixed`. This Benchmark steady-state send/receive traffic and high socket-churn workloads. Record CPU, cycles, instructions, throughput, and socket lifetime cost. Keep the implementation only if the hot-path win justifies the slot-lifecycle complexity. +## Required + +- [Merge dev](/quest/m1/merge-dev.md) - builds on dev-only code that reaches `main` with the merge + ## Closes - [#3202](https://github.com/moq-dev/moq/issues/3202) - close this issue when the quest finishes diff --git a/quest/m1/perf/3203-moq-uring-add-opt-in-napi-busy-polling.md b/quest/m2/perf/3203-moq-uring-add-opt-in-napi-busy-polling.md similarity index 64% rename from quest/m1/perf/3203-moq-uring-add-opt-in-napi-busy-polling.md rename to quest/m2/perf/3203-moq-uring-add-opt-in-napi-busy-polling.md index 824e7bc6be..96530d046a 100644 --- a/quest/m1/perf/3203-moq-uring-add-opt-in-napi-busy-polling.md +++ b/quest/m2/perf/3203-moq-uring-add-opt-in-napi-busy-polling.md @@ -1,21 +1,16 @@ -# [S] moq-uring: add opt-in NAPI busy polling +# [M] moq-uring: add opt-in NAPI busy polling ## Goal -Implement and verify the behavior tracked in [#3203](https://github.com/moq-dev/moq/issues/3203) -within the issue's stated scope and boundaries. +A worker can opt into io_uring NAPI busy polling from relay configuration, +off by default, reported honestly when unsupported, with bare-metal latency +and idle-cost numbers recorded before it is recommended anywhere. ## Plan -Use the public issue's scope, implementation notes, and acceptance criteria -below as the starting plan. Reconcile paths and assumptions with the current -tree before implementation. - -### Issue context - Follow-up to #2875. -The relay already pins one worker and one ring per core. io\_uring NAPI busy polling can keep a worker close to the NIC receive path and reduce wakeup latency, at the cost of continuously consuming CPU and power while it polls. +The relay already pins one worker and one ring per core. io_uring NAPI busy polling can keep a worker close to the NIC receive path and reduce wakeup latency, at the cost of continuously consuming CPU and power while it polls. #### Proposal @@ -30,6 +25,10 @@ The relay already pins one worker and one ring per core. io\_uring NAPI busy pol Measure on bare metal with the deployment NIC and queue affinity configured, not loopback. Compare disabled and several busy-poll durations under low, medium, and saturated load. Record p50, p99, and p999 packet latency, relay CPU, CPU idle residency, interrupts, drops, goodput, and power if available. Ship only as opt-in unless fleet-level data shows an acceptable idle-cost tradeoff. +## Required + +- [Merge dev](/quest/m1/merge-dev.md) - builds on dev-only code that reaches `main` with the merge + ## Closes - [#3203](https://github.com/moq-dev/moq/issues/3203) - close this issue when the quest finishes diff --git a/quest/m1/perf/3204-moq-uring-register-tx-pool-buffers-for-zero-copy-sends.md b/quest/m2/perf/3204-moq-uring-register-tx-pool-buffers-for-zero-copy-sends.md similarity index 71% rename from quest/m1/perf/3204-moq-uring-register-tx-pool-buffers-for-zero-copy-sends.md rename to quest/m2/perf/3204-moq-uring-register-tx-pool-buffers-for-zero-copy-sends.md index e807f31935..5e1d601ca4 100644 --- a/quest/m1/perf/3204-moq-uring-register-tx-pool-buffers-for-zero-copy-sends.md +++ b/quest/m2/perf/3204-moq-uring-register-tx-pool-buffers-for-zero-copy-sends.md @@ -1,18 +1,13 @@ -# [S] moq-uring: register TX-pool buffers for zero-copy sends +# [M] moq-uring: register TX-pool buffers for zero-copy sends ## Goal -Implement and verify the behavior tracked in [#3204](https://github.com/moq-dev/moq/issues/3204) -within the issue's stated scope and boundaries. +TX-pool buffers are registered as fixed buffers once per worker, eligible +zero-copy sends use the fixed index, and the same #3201 matrix shows a +measurable gain over plain `SendMsgZc` or the change is dropped. ## Plan -Use the public issue's scope, implementation notes, and acceptance criteria -below as the starting plan. Reconcile paths and assumptions with the current -tree before implementation. - -### Issue context - Follow-up to #2875 and dependent on the `SENDMSG_ZC` experiment in #3201. The TX pool already owns stable `Box<[u8]>` allocations and grows lazily. If zero-copy send wins, registering those allocations lets send SQEs reference fixed buffers and can reduce repeated page accounting on the large-train path. @@ -32,7 +27,7 @@ Compare #3201 with and without registered buffers using the same threshold and w ## Required -- [#3201: moq-uring: use SENDMSG\_ZC for large UDP GSO trains](/quest/m1/perf/3201-moq-uring-use-sendmsg-zc-for-large-udp-gso-trains.md) - complete the prerequisite issue first +- [#3201: moq-uring: use SENDMSG_ZC for large UDP GSO trains](/quest/m2/perf/3201-moq-uring-use-sendmsg-zc-for-large-udp-gso-trains.md) - complete the prerequisite issue first ## Closes diff --git a/quest/m1/perf/3205-moq-uring-register-reusable-io-uring-enter-wait-arguments.md b/quest/m2/perf/3205-moq-uring-register-reusable-io-uring-enter-wait-arguments.md similarity index 71% rename from quest/m1/perf/3205-moq-uring-register-reusable-io-uring-enter-wait-arguments.md rename to quest/m2/perf/3205-moq-uring-register-reusable-io-uring-enter-wait-arguments.md index 330577f9da..0d2261e660 100644 --- a/quest/m1/perf/3205-moq-uring-register-reusable-io-uring-enter-wait-arguments.md +++ b/quest/m2/perf/3205-moq-uring-register-reusable-io-uring-enter-wait-arguments.md @@ -1,18 +1,14 @@ -# [S] moq-uring: register reusable io_uring_enter wait arguments +# [M] moq-uring: register reusable io_uring_enter wait arguments ## Goal -Implement and verify the behavior tracked in [#3205](https://github.com/moq-dev/moq/issues/3205) -within the issue's stated scope and boundaries. +Timed parking reuses one registered `io_uring_reg_wait` entry per worker via +`IORING_ENTER_EXT_ARG_REG` on Linux 6.13+, keeping today's `SubmitArgs` +path (rs/moq-uring/src/worker.rs:305, :410) as the 6.12 fallback, retained +only if cycles per enter measurably drop. ## Plan -Use the public issue's scope, implementation notes, and acceptance criteria -below as the starting plan. Reconcile paths and assumptions with the current -tree before implementation. - -### Issue context - Follow-up to #2875 and the CQ batching experiment in #3200. Timed parking currently builds a `Timespec` and `SubmitArgs` for each wait, then asks the kernel to copy the extended enter arguments. Linux 6.13 added registered wait regions and `IORING_ENTER_EXT_ARG_REG`, allowing a ring to reuse kernel-known wait storage. @@ -32,7 +28,7 @@ Measure this after the winning #3200 configuration, where enter frequency and ar ## Required -- [#3200: moq-uring: batch completion wakeups with MIN\_TIMEOUT](/quest/m1/perf/3200-moq-uring-batch-completion-wakeups-with-min-timeout.md) - complete the prerequisite issue first +- [#3200: moq-uring: batch completion wakeups with MIN_TIMEOUT](/quest/m2/perf/3200-moq-uring-batch-completion-wakeups-with-min-timeout.md) - complete the prerequisite issue first ## Closes diff --git a/quest/m1/perf/README.md b/quest/m2/perf/README.md similarity index 62% rename from quest/m1/perf/README.md rename to quest/m2/perf/README.md index d06e8a8086..98b6a01a87 100644 --- a/quest/m1/perf/README.md +++ b/quest/m2/perf/README.md @@ -14,9 +14,19 @@ outcome that abandons the quest. ## Plan -Branch every quest from dev. Facts from the 2026-09 hot-path survey, so -quests don't re-litigate them: +This line starts after the dev merge, on main. Facts from the 2026-09 +hot-path survey, so quests don't re-litigate them: +- The default `moq-uring` backend is noq, compiled through the `quinn/` + module: `quic/mod.rs` selects `quinn/mod.rs` for the `noq` feature + (rs/moq-uring/src/quic/mod.rs:49-51) and that module aliases `noq_proto as + quinn_proto` (quinn/mod.rs:26). The relay's `io-uring` feature is that + backend; `io-uring-quinn` and `io-uring-quiche` are the explicit + alternatives (rs/moq-relay/Cargo.toml:55-57). Every profile names its + backend. The quiche-only citations in + [Egress requeue](/quest/m2/perf/egress-requeue.md) and + [#3122](/quest/m2/perf/3122-moq-uring-2-5-of-relay-cpu-is-vdso-clock-reads-the-drive.md) + describe the non-default path. - Cross-thread wakeups are already cheap: one futex word per worker, at most one `futex(FUTEX_WAKE)` per park cycle, wake bursts coalesce through the `kio::Tasks` bitset. No eventfd, no MSG_RING, by design (`SINGLE_ISSUER`). @@ -35,24 +45,24 @@ The relay's `/metrics` endpoint already carries the ring-level counters row per io_uring worker. The [noq parity gate](/quest/m2/quic/noq-parity.md) benchmarks noq against the quiche backend; the zero-copy quests here stay independently measured on the -backend we ship today. +default backend. ## Quests -- [Ingest batch](/quest/m1/perf/ingest-batch.md) - relay ingest pays one lock, wake, and clock read per chunk burst instead of per chunk -- [#3122](/quest/m1/perf/3122-moq-uring-2-5-of-relay-cpu-is-vdso-clock-reads-the-drive.md) - moq-uring: ~2.5% of relay CPU is vdso clock reads; the drive loop and its callers each re-read Instant::now() -- [Cache shard](/quest/m1/perf/cache-shard.md) - stop hammering one process-global cache line from every worker -- [#3199](/quest/m1/perf/3199-moq-uring-remove-sq-indirection-and-per-enter-ring-fd.md) - moq-uring: remove SQ indirection and per-enter ring fd lookup -- [#3200](/quest/m1/perf/3200-moq-uring-batch-completion-wakeups-with-min-timeout.md) - moq-uring: batch completion wakeups with MIN_TIMEOUT -- [#3129](/quest/m1/perf/3129-moq-uring-write-the-webtransport-stream-header-at-open.md) - moq-uring: write the WebTransport stream header at open time, so finish() never owes one -- [Egress requeue](/quest/m1/perf/egress-requeue.md) - a requeue for the transmit pool stops re-walking every ready stream, and trains per turn becomes a measured budget -- [#3201](/quest/m1/perf/3201-moq-uring-use-sendmsg-zc-for-large-udp-gso-trains.md) - moq-uring: use SENDMSG_ZC for large UDP GSO trains -- [#3202](/quest/m1/perf/3202-moq-uring-use-fixed-file-slots-for-worker-udp-sockets.md) - moq-uring: use fixed-file slots for worker UDP sockets -- [#3204](/quest/m1/perf/3204-moq-uring-register-tx-pool-buffers-for-zero-copy-sends.md) - moq-uring: register TX-pool buffers for zero-copy sends -- [Send order width](/quest/m1/perf/send-order-width.md) - a wider transport send order lets a group rank itself instead of taking the queue lock -- [Priority set_track wakes](/quest/m1/perf/priority-set-track-wakes.md) - a track priority change stops waking groups that end up where they started -- [#3203](/quest/m1/perf/3203-moq-uring-add-opt-in-napi-busy-polling.md) - moq-uring: add opt-in NAPI busy polling -- [#3205](/quest/m1/perf/3205-moq-uring-register-reusable-io-uring-enter-wait-arguments.md) - moq-uring: register reusable io_uring_enter wait arguments +- [Ingest batch](/quest/m2/perf/ingest-batch.md) - relay ingest pays one lock, wake, and clock read per chunk burst instead of per chunk +- [#3122](/quest/m2/perf/3122-moq-uring-2-5-of-relay-cpu-is-vdso-clock-reads-the-drive.md) - moq-uring: ~2.5% of relay CPU is vdso clock reads; the drive loop and its callers each re-read Instant::now() +- [Cache shard](/quest/m2/perf/cache-shard.md) - stop hammering one process-global cache line from every worker +- [#3199](/quest/m2/perf/3199-moq-uring-remove-sq-indirection-and-per-enter-ring-fd.md) - moq-uring: remove SQ indirection and per-enter ring fd lookup +- [#3200](/quest/m2/perf/3200-moq-uring-batch-completion-wakeups-with-min-timeout.md) - moq-uring: batch completion wakeups with MIN_TIMEOUT +- [#3129](/quest/m2/perf/3129-moq-uring-write-the-webtransport-stream-header-at-open.md) - moq-uring: write the WebTransport stream header at open time, so finish() never owes one +- [Egress requeue](/quest/m2/perf/egress-requeue.md) - trains per turn on the egress driver becomes a measured budget instead of a hardcoded one +- [#3201](/quest/m2/perf/3201-moq-uring-use-sendmsg-zc-for-large-udp-gso-trains.md) - moq-uring: use SENDMSG_ZC for large UDP GSO trains +- [#3202](/quest/m2/perf/3202-moq-uring-use-fixed-file-slots-for-worker-udp-sockets.md) - moq-uring: use fixed-file slots for worker UDP sockets +- [#3204](/quest/m2/perf/3204-moq-uring-register-tx-pool-buffers-for-zero-copy-sends.md) - moq-uring: register TX-pool buffers for zero-copy sends +- [Send order width](/quest/m2/perf/send-order-width.md) - a wider transport send order lets a group rank itself instead of taking the queue lock +- [Priority set_track wakes](/quest/m2/perf/priority-set-track-wakes.md) - a track priority change stops waking groups that end up where they started +- [#3203](/quest/m2/perf/3203-moq-uring-add-opt-in-napi-busy-polling.md) - moq-uring: add opt-in NAPI busy polling +- [#3205](/quest/m2/perf/3205-moq-uring-register-reusable-io-uring-enter-wait-arguments.md) - moq-uring: register reusable io_uring_enter wait arguments ## Related diff --git a/quest/m1/perf/cache-shard.md b/quest/m2/perf/cache-shard.md similarity index 58% rename from quest/m1/perf/cache-shard.md rename to quest/m2/perf/cache-shard.md index 78344c4e6f..351be74f65 100644 --- a/quest/m1/perf/cache-shard.md +++ b/quest/m2/perf/cache-shard.md @@ -2,12 +2,13 @@ ## Goal -The relay creates one `cache::Pool` for the whole process, and every frame -written or read on every worker does relaxed `fetch_add`s on the same cache -line (`Inner::used`, and `Inner::access_sum`/`access_count` for the -evictable population). At fanout this is the top cross-core contention -point in the model layer. Make per-frame accounting land on per-owner state -and hit the shared line only at a coarse cadence. +The relay creates one `cache::Pool` (rs/moq-net/src/model/cache.rs) for the +whole process, and every frame written or read on every worker does relaxed +`fetch_add`s on the same cache line (`Inner::used`, cache.rs:146, and +`Inner::access_sum`/`access_count`, :156-157, for the evictable population). +At fanout this is the top cross-core contention point in the model layer. +Make per-frame accounting land on per-owner state and hit the shared line +only at a coarse cadence. ## Plan @@ -22,12 +23,11 @@ consistent win. Sharding a counter that is already only relaxed `fetch_add`s trades one cheap contended line for several lines plus a read-side fold, and the fold is what showed up. -Settled: **the duplicate clock read per frame write**. A write called -`model::clock::now()` twice, once through `Charge::touch` -> `Pool::stamp` -and again through `Track::settle` -> `expiry_due`. The write path now -threads the tick it already sampled into `Track::settle`. That is exact -duplicate removal, not a policy change: the clock is no coarser, nothing is -delayed, and eviction and staleness bounds are untouched. +Settled: **one clock read per frame write**. The write path samples +`model::clock::now()` once, in `Charge::touch` -> `Pool::stamp` +(cache.rs:736, :326), and threads that tick into `Track::settle` -> +`expiry_due` (:510, :565). Keep it that way: the clock is no coarser, nothing +is delayed, and eviction and staleness bounds are untouched. Open, and the real content of this quest: **batching the counters themselves**. The precedent is in the same file, where `cache::Track` @@ -46,17 +46,22 @@ exactness. exceed its ceiling by more than the sum of unflushed deltas, and that bound must be stated and tested. -Note that this is the half sharding was standing in front of, and it is not -the same idea: batching reduces how *often* the shared line is touched, -where sharding kept the frequency and spread the address. Measure it on its -own before concluding anything from the sharding result. +This is the half sharding was standing in front of, and it is not the same +idea: batching reduces how *often* the shared line is touched, where +sharding kept the frequency and spread the address. Measure it on its own +before concluding anything from the sharding result. Acceptance: cross-core traffic on the pool line (perf stat cycles or c2c where available), relay CPU and RSS at the fanout shape via `just bench BASE` on Linux, plus `track_parallel_write` in -`rs/moq-net/benches/track.rs` for the writer-side shape. Eviction behavior -covered by the existing cache tests plus new ones for the staleness bound. +`rs/moq-net/benches/track.rs` (:186) for the writer-side shape. Eviction +behavior covered by the existing cache tests plus new ones for the staleness +bound. + +## Required + +- [Merge dev](/quest/m1/merge-dev.md) - builds on dev-only code that reaches `main` with the merge ## Related -- [#3122](/quest/m1/perf/3122-moq-uring-2-5-of-relay-cpu-is-vdso-clock-reads-the-drive.md) - the remaining clock reads one layer down; a pool epoch driven by its per-turn timestamp is the way to drop the last per-frame read, and the model should drink from that cup rather than grow a second clock +- [#3122](/quest/m2/perf/3122-moq-uring-2-5-of-relay-cpu-is-vdso-clock-reads-the-drive.md) - the remaining clock reads one layer down; a pool epoch driven by its per-turn timestamp is the way to drop the last per-frame read, and the model should drink from that cup rather than grow a second clock diff --git a/quest/m2/perf/egress-requeue.md b/quest/m2/perf/egress-requeue.md new file mode 100644 index 0000000000..5c21d9e073 --- /dev/null +++ b/quest/m2/perf/egress-requeue.md @@ -0,0 +1,39 @@ +# [XS] Trains per turn on the egress driver becomes a measured budget + +## Goal + +Both io_uring QUIC drivers stage one GSO train per turn and then wake +themselves (`Driver::flush`, rs/moq-uring/src/quic/quiche/connection.rs:780-790 +and quinn/connection.rs:744), so a deep backlog on one connection pays a whole +driver turn per train of `TRAIN_SEGMENTS = 63` segments +(quiche/connection.rs:20, quinn/connection.rs:23; `MAX_GSO_SEGMENTS = 64` at +udp.rs:56 is the kernel cap). That cadence is a hardcoded fairness choice. +Make it a measured budget. + +## Plan + +The re-walk half of #3120 landed in #3134 (e6962b20e): the quiche driver only +sweeps readiness after ingress (`Sweep::after_ingress`, +quiche/connection.rs:627-661, regression +`transmit_continuation_skips_event_sweep` :918). The quinn driver drains an +event queue instead of walking iterators (quinn/connection.rs:697) and never +had that shape. What is left is the budget. + +- Add a trains-per-turn budget to `flush` on both backends. One train then + requeue is deliberate fairness across connections sharing a socket; keep + fairness by bounding the budget, and sweep 1, 2, and 4 trains per turn + under the fanout and single-heavy-connection shapes to see whether the + extra turn latency is real. +- Profile the default backend (noq through the `quinn/` module) and the + quiche flavor separately; the numbers in #3120 are quiche's. + +Acceptance: CPU per Gbps and throughput ceiling via `just bench BASE` on +Linux. Latency must not regress at the chosen budget. A no-win keeps 1. + +## Required + +- [Merge dev](/quest/m1/merge-dev.md) - builds on dev-only code that reaches `main` with the merge + +## Closes + +- [#3120](https://github.com/moq-dev/moq/issues/3120) - close this issue when the quest finishes diff --git a/quest/m2/perf/ingest-batch.md b/quest/m2/perf/ingest-batch.md new file mode 100644 index 0000000000..24db07a13a --- /dev/null +++ b/quest/m2/perf/ingest-batch.md @@ -0,0 +1,46 @@ +# [M] Batch the relay ingest write path + +## Goal + +Relay ingest pays a full group mutex acquisition, a waiter-list wake fanout, +and a real clock read for every received QUIC chunk. Egress is already +amortized (the `Prefetch` refills eight frames under one lock and stamps the +charge and stats once per batch); ingest has no equivalent. Make a burst of +received chunks pay one lock, wake, and clock cycle. + +## Plan + +Where the path stands: + +- Both wire ingests drain a frame's payload through + `coding::Reader::poll_read_frame` (rs/moq-net/src/coding/reader.rs:168). + The group lock, the charge clock read, and the waiter drain run once at the + poll boundary, or once per `WAKE_BUDGET` bytes (reader.rs:33, since + transport readiness alone is not a bound on time), and once at + `frame_commit` (rs/moq-net/src/model/group.rs:715), which restarts the + retention clock so the deferral can never lose a stamp. +- The batched machinery exists but is unused here: + `group::Producer::write_frames` (group.rs:534) takes a `frame::Buffer` and + pays one lock per batch, while the ingest path streams chunks through + `create_frame_owned` (group.rs:646) instead. `rs/moq-net/benches/group.rs` + compares the two as `single` against `batch32`, a `frame::Buffer::<32>` + flushed through `write_frames` (:71-73); take the before number from there. + +Remaining: + +- Where whole frames are available in one poll turn, feed them through + `write_frames`/`frame::Buffer` instead of frame-at-a-time creation. This is + the larger half: a small frame that arrives whole still pays a + `create_frame_owned` plus a `frame_commit`, two lock acquisitions where the + batch API pays one for the whole burst. +- The per-chunk `stats` bumps on the same loop, which `write` still pays + individually. + +Acceptance: ingest CPU per Gbps on the video shape and the chat shape +(`just bench BASE` on Linux), plus `group_write_frames` in +`rs/moq-net/benches/group.rs`. Frame delivery latency at the live edge must +not regress. + +## Required + +- [Merge dev](/quest/m1/merge-dev.md) - builds on dev-only code that reaches `main` with the merge diff --git a/quest/m1/perf/priority-set-track-wakes.md b/quest/m2/perf/priority-set-track-wakes.md similarity index 64% rename from quest/m1/perf/priority-set-track-wakes.md rename to quest/m2/perf/priority-set-track-wakes.md index 6555312068..51fc77341e 100644 --- a/quest/m1/perf/priority-set-track-wakes.md +++ b/quest/m2/perf/priority-set-track-wakes.md @@ -2,8 +2,8 @@ ## Goal -`lite::priority`'s `set_track` re-ranks an item as `extract` then `place`. The -extract shifts every following vec entry up one and the place shifts them back +`lite::priority`'s `set_track` (rs/moq-net/src/lite/priority.rs:342) re-ranks +an item as `extract` (:322) then `place` (:261). The extract shifts every following vec entry up one and the place shifts them back down, so an entry whose rank does not net-change is still woken: the shift up takes its parked waker, and the shift back finds nothing left to restore. @@ -17,25 +17,30 @@ double mutation in `set_track` can cancel itself out. Two shapes, cheapest first: -- Defer the reconcile. Have `update_location` record the touched id and leave - `PriorityEntry::rank` holding the last *published* rank, then compare once at +- Defer the reconcile. Have `update_location` (:236) record the touched id and + leave `PriorityEntry::rank` (:185) holding the last *published* rank, then compare once at the end of the mutation and wake only the net movers. Simple and total, but it adds a second slab lookup per shifted entry to the insert hot path, so measure - it against `priority_queue_insert_front` before taking it. + it against `priority_queue_insert_front` (rs/moq-net/benches/priority.rs:25) + before taking it. - Rotate instead of remove-and-reinsert. Moving an item within the sorted vec only shifts the entries strictly between its old and new index, so computing that range directly is both exact and less work than the two full shifts. It has to keep the vec/overflow boundary in `place` intact, including the case where the re-ranked item crosses it. -This predates the queue rework in #3298 and is not a regression from it; the -wake count on this path is unchanged. It is a cold path (a SUBSCRIBE_UPDATE +The queue rework in #3298 left the wake count on this path as it was. It is a +cold path (a SUBSCRIBE_UPDATE priority change), never group open or close. Acceptance: a test that parks each handle on its own waiter, calls `set_track` on the front entry within a range where it stays first, and asserts no other handle woke. No regression on the `priority_queue_insert_front` benches. +## Required + +- [Merge dev](/quest/m1/merge-dev.md) - builds on dev-only code that reaches `main` with the merge + ## Related -- [Send order width](/quest/m1/perf/send-order-width.md) - makes this moot if the queue goes away +- [Send order width](/quest/m2/perf/send-order-width.md) - makes this moot if the queue goes away diff --git a/quest/m1/perf/send-order-width.md b/quest/m2/perf/send-order-width.md similarity index 82% rename from quest/m1/perf/send-order-width.md rename to quest/m2/perf/send-order-width.md index 0a330bb6e7..060b954d13 100644 --- a/quest/m1/perf/send-order-width.md +++ b/quest/m2/perf/send-order-width.md @@ -1,9 +1,10 @@ -# [L] Widen the transport send order so groups stop needing a queue +# [XL] Widen the transport send order so groups stop needing a queue ## Goal `web_transport_trait::SendStream::set_priority` takes a `u8`, and that single -signature is why the Lite publisher carries a `PriorityQueue` at all. The queue +signature is why the Lite publisher carries a `PriorityQueue` +(rs/moq-net/src/lite/priority.rs:101) at all. The queue exists to compress an unbounded total order over live group streams, `(track desc, subscribe asc, group desc)`, into 256 dense ranks, which costs a session-wide lock, a vec shift, and a wake per reordered group. @@ -17,15 +18,16 @@ fairness moves to the transport scheduler where round-robin is expressible. What each backend takes natively today: -- quinn: `i32`, narrowed by the trait and widened straight back. +- quinn and noq: `i32`, narrowed by the trait and widened straight back. - browser: `sendOrder`, `long long` in the W3C spec; `web-transport-wasm` takes - an `i32`. + an `i32` (`SendStream::set_priority`, web-transport-wasm 0.6.0 send.rs:148). - quiche: `stream_priority(id, urgency: u8, incremental)`, lower urgency first. A real cap, not an artifact. - qmux: `u8`, into its own bucketed scheduler. -So the split is quinn and browsers on one side, quiche and qmux on the other. -Both sides are defaults somewhere: quiche for moq-uring, quinn for moq-tokio. +So the split is noq, quinn, and browsers on one side, quiche and qmux on the +other. noq is the default on both `moq-tokio` and `moq-uring`; quiche is an +explicit feature on each, and qmux is every fallback transport. An exact order-preserving pack of the full key needs 136 bits, but the key does not have to survive intact. Only two of its three terms are worth carrying. @@ -46,7 +48,8 @@ which two cases produce and neither is remote. The wrap: 24 bits is 194 days at a one-second GoP but about 4 days at the 20ms audio cadence this plan uses elsewhere, so a long-lived dense publisher crosses a boundary regularly. And sparse sequences, which need no wrap at all: `append_group` advances by one, but -`track::Producer::create_group` takes a caller-chosen `u64` and a relay +`track::Producer::create_group` takes a `group::Info { sequence }` the caller +chooses (rs/moq-net/src/model/track.rs:1165, group.rs:60-63) and a relay preserves upstream numbering, so two live groups can start out more than the field apart. @@ -101,14 +104,20 @@ Bit budgets, which are looser than the field types suggest: fallback is keeping the queue on the narrow backends only, at the cost of two scheduling paths to hold consistent. -Entry cost is a breaking `web-transport-trait` release, rippling through -web-transport-quinn, web-transport-wasm, qmux, iroh, and moq-net's own -`transport::poll::SendStream`. +Entry cost is a breaking `web-transport-trait` release (0.4.0 still takes +`u8` in both `lib.rs:225` and `poll.rs:202`), rippling through +web-transport-quinn, web-transport-noq, web-transport-wasm, qmux, and iroh, +plus every `set_priority` impl in moq-net's own adapters and test transports. +That release is why this is sized XL. Acceptance: the wide backends open and close a group without touching a shared lock, the existing send-order and ordering tests pass unmodified, and `just bench BASE` shows no relay regression on the narrow path. +## Required + +- [Merge dev](/quest/m1/merge-dev.md) - builds on dev-only code that reaches `main` with the merge + ## Related -- [Priority set_track wakes](/quest/m1/perf/priority-set-track-wakes.md) - dead if the queue goes away +- [Priority set_track wakes](/quest/m2/perf/priority-set-track-wakes.md) - dead if the queue goes away diff --git a/quest/m2/performance-comparisons.md b/quest/m2/performance-comparisons.md index 2ad0dc1353..5c04b1b939 100644 --- a/quest/m2/performance-comparisons.md +++ b/quest/m2/performance-comparisons.md @@ -38,5 +38,5 @@ while extending this harness rather than creating another benchmark runner. ## Related -- [Windowed latency](/quest/m1/3126-moq-bench-every-readme-example-fails-to-parse-and.md) - owns histogram/window semantics +- [Windowed latency](/quest/m2/3126-moq-bench-every-readme-example-fails-to-parse-and.md) - owns histogram/window semantics - [Relay profiling](/quest/m2/performance-profiles.md) - shares workload and artifact conventions diff --git a/quest/m2/pop-skipping/README.md b/quest/m2/pop-skipping/README.md index 4ec7901001..cd141a2b60 100644 --- a/quest/m2/pop-skipping/README.md +++ b/quest/m2/pop-skipping/README.md @@ -154,5 +154,5 @@ costs of one bidirectional session, which one `?cost=` cannot split. ## Related - [drain](/quest/m2/drain/README.md) - a second relay per PoP makes the same-PoP link price and its connection cardinality operationally important -- [wildcard](/quest/m2/wildcard/README.md) - it reuses this questline's route cost, and needs a cluster on Lite06 +- [wildcard](/quest/m1/wildcard/README.md) - it reuses this questline's route cost, and needs a cluster on Lite06 - [relay-memory](/quest/m2/relay-memory.md) - a denser mesh multiplies whatever a non-selected route costs diff --git a/quest/m2/processor/README.md b/quest/m2/processor/README.md index 8fc438fa12..ae56023f37 100644 --- a/quest/m2/processor/README.md +++ b/quest/m2/processor/README.md @@ -30,5 +30,5 @@ and custom transforms use the same worker lifecycle. - [Reference vision worker](/quest/m3/processor-vision.md) - a runnable worker publishes frame-correlated detections and proves demand, reconnect, failover, and teardown end to end -- [Wildcard advertisements](/quest/m2/wildcard/README.md) - lets a dormant +- [Wildcard advertisements](/quest/m1/wildcard/README.md) - lets a dormant processor advertise what it could serve without enumerating live sources diff --git a/quest/m2/processor/advertise-auth.md b/quest/m2/processor/advertise-auth.md index 314c041a2e..2c3a26dd81 100644 --- a/quest/m2/processor/advertise-auth.md +++ b/quest/m2/processor/advertise-auth.md @@ -28,7 +28,7 @@ publish grant. ## Required -- [Advertise](/quest/m2/wildcard/advertise.md) - supplies the wildcard message +- [Advertise](/quest/m1/wildcard/advertise.md) - supplies the wildcard message and authorization point this capability separates - [Token SDKs](/quest/m2/path-patterns/token-sdk.md) - supplies the published v1 claim writers this extension changes diff --git a/quest/m2/quic/2296-moq-native-bring-the-quiche-backend-to-quinn-noq-feature.md b/quest/m2/quic/2296-moq-native-bring-the-quiche-backend-to-quinn-noq-feature.md new file mode 100644 index 0000000000..f1076519f0 --- /dev/null +++ b/quest/m2/quic/2296-moq-native-bring-the-quiche-backend-to-quinn-noq-feature.md @@ -0,0 +1,96 @@ +# [L] moq-tokio: bring the quiche backend to quinn/noq feature parity + +## Goal + +Every `listen`/`connect` setting the quinn and noq backends honor is either +honored by the quiche backend in `moq-tokio` or refused with an error naming +the gap, and the quiche backend can serve a reuseport worker group. + +## Plan + +Most of the original audit has landed. What remains is listed below, each with +what blocks it. + +### Already done + +Do not redo these; they have tests. + +- WebTransport over HTTP/3, raw QUIC (`moqt://` / `moql://`), and the `http://` + fingerprint bootstrap. +- Protocol-version ALPN negotiation, stream counts, idle timeout, path MTU + discovery, custom roots, explicit SHA-256 pinning, disabled verification, + terminal HTTP/auth rejection, connection statistics. +- Outbound mTLS from `--connect-tls-cert` / `--connect-tls-key`, and from an + in-memory `tls::Identity`. +- Inbound optional mTLS from `--listen-tls-root`, surfaced through + `Request::peer_identity`. +- `--connect-tls-host-name`, keep-alive, `--*-quic-gso=false`, and the shared + dual-stack bind plus address-family-aware DNS selection. +- The full `tls::Server` certificate semantics, through the shared + `tls::ServeCerts` and `ez::ServerBuilder::with_cert_resolver`: every + configured cert/key pair, generated and in-memory certificates alongside the + file-backed ones, SNI selection, key/certificate validation, and hot reload + with live fingerprints. + +### Connection IDs + +The pinned `web-transport-quiche` 0.7 (Cargo.toml:209) exposes +`ez::ServerBuilder::with_listener(tokio_quiche::socket::QuicListener)` +(server.rs:112), and that listener carries a public +`cid_generator: Arc` (tokio-quiche 0.19.1, +socket/listener.rs:58). The hook exists. `QuicheServer::new` never uses it: +it builds `ServerBuilder::default().with_settings(..).with_gso(..)` +(rs/moq-tokio/src/quiche.rs:686-688), warns and ignores `lb_id` (:642-644), +drops `lb_nonce` (rs/moq-tokio/src/listen.rs:124) without a word, and refuses +any worker `Member` with `Error::ShardUnsupported` (:108, :651) behind a +comment (:647-650) that predates the hook. One change covers all three: + +- Build the `QuicListener` locally with a `cid_generator` that lays out + connection IDs exactly as quinn/noq do (QUIC-LB server id plus nonce, or the + worker index for a shard member), and hand it to `with_listener`. +- `with_listener` bypasses `with_gso`: the listener's `capabilities` are the + caller's to compute, so `--listen-quic-gso=false` must keep working through + that path. +- Retire `ShardUnsupported` once a member binds; the parity gap it names is + what keeps `--runtime-workers` quinn/noq-only today. + +### Remaining, blocked on `web-transport-quiche` / `tokio-quiche` / `quiche` + +Or on a local lower-level integration that skips their `ez` layer: + +- Make `Server::close` stop the quiche listener and close/drain active + connections. `QuicheServer::close` is a no-op, while quinn/noq send an + endpoint-wide close. `ez::Server` neither exposes a close nor tracks its + established connections. +- Match quinn/noq platform certificate verification, including mobile. + boringssl takes a concrete root list rather than a rustls verifier, so the + client path snapshots `rustls-native-certs`; iOS/Android get no roots and fail + closed. +- Honor `SSLKEYLOGFILE`, matching the rustls key logging quinn/noq install. + The `SslContextBuilder` is built inside `web-transport-quiche`'s connection + hook, which exposes no keylog callback. +- Hot reload the inbound mTLS client roots (`--listen-tls-root`). + `ez::ClientAuth` is applied once, when the listener is built (quiche.rs:705). +- Support a pinned client-fingerprint allowlist (`tls::Listen::peers`), + which currently returns `tls::Error::PeersUnsupported`. It needs a + per-handshake verify callback on the server side; boringssl's client-auth + path validates against a fixed root store instead. +- Honor `--listen-preferred-v4` / `--listen-preferred-v6`. quiche still has + TODOs for encoding/decoding the `preferred_address` transport parameter. + +### Not parity blockers + +- #2276 is noq-only multipath, which quinn does not support. +- #686 tracks congestion control/BBR, where quinn and noq do not currently + behave the same. +- #679 tracks multi-threaded UDP receive scaling, which is a reason to use + quiche rather than a parity gap. + +## Required + +- [noq parity gate](/quest/m2/quic/noq-parity.md) - decides whether quiche stays a supported backend; if it is retired this quest is abandoned with the verdict +- [Merge dev](/quest/m1/merge-dev.md) - builds on dev-only code that reaches `main` with the merge + +## Closes + +- [#2296](https://github.com/moq-dev/moq/issues/2296) - close this issue when the quest finishes diff --git a/quest/m2/quic/2853-quiche-with-a-pinned-source-port-can-dial-only-a-broken.md b/quest/m2/quic/2853-quiche-with-a-pinned-source-port-can-dial-only-a-broken.md new file mode 100644 index 0000000000..edeb961e13 --- /dev/null +++ b/quest/m2/quic/2853-quiche-with-a-pinned-source-port-can-dial-only-a-broken.md @@ -0,0 +1,55 @@ +# [S] quiche with a pinned source port can dial only a broken IPv4 address + +## Goal + +A quiche dial with a pinned non-zero `--connect-bind` port waits for the +authoritative all-family DNS answer before taking its single candidate, so a +host with a slow AAAA and a broken IPv4 path still connects over IPv6. + +## Plan + +`rs/moq-tokio/src/quiche.rs:408-410` truncates the candidate list to one +address when the source port is pinned, because a pinned port only fits one +socket at a time: + +```rust +if self.bind.port() != 0 { + candidates = candidates.with_limit(1); +} +``` + +Since #2749, that single candidate can come from the speculative IPv4-only +lookup rather than the authoritative all-family answer. `Candidates::next` +already mitigates this per RFC 8305 section 3: for the first candidate it +holds an IPv4-only answer back and waits up to `--connect-resolution-delay` +(50ms) for the full lookup (tests +`ipv4_waits_out_the_resolution_delay_for_the_full_answer` and +`ipv4_proceeds_once_the_resolution_delay_expires`, +rs/moq-tokio/src/resolve.rs:731, :754), so the platform's own RFC 6724 +ranking usually wins. + +That bounds the window rather than closing it. If AAAA is more than the +resolution delay slower than A, the wait times out and the IPv4 address is +taken. With `limit(1)` there is no second attempt, so a host whose IPv4 path +is broken and whose IPv6 path works fails to connect, where before #2749 the +dial waited for the complete resolver result and took its first (IPv6) +address. + +Narrow by construction: it needs the quiche backend, a pinned non-zero +`--connect-bind` port, a slow AAAA, and a broken IPv4 path. Every other +backend races both families, so `limit(1)` is the only place a preference +becomes an exclusion. + +The fix is one branch plus a test: on the `limit(1)` path, wait for the +authoritative answer rather than accepting the fast lane. The fast lane +exists to start dialing sooner, which is worth nothing when only one attempt +will ever be made. The code is identical on `main` +(rs/moq-native/src/quiche.rs:339), so the fix lands on either branch. + +## Required + +- [noq parity gate](/quest/m2/quic/noq-parity.md) - decides whether quiche stays a supported backend; if it is retired this quest is abandoned with the verdict + +## Closes + +- [#2853](https://github.com/moq-dev/moq/issues/2853) - close this issue when the quest finishes diff --git a/quest/m2/quic/README.md b/quest/m2/quic/README.md index e9fa63bbbd..5bc0bfe78a 100644 --- a/quest/m2/quic/README.md +++ b/quest/m2/quic/README.md @@ -62,6 +62,12 @@ transport API change, not a MoQ wire change. fork - [Probe by early retransmission](/quest/m2/quic/probe.md) - measure capacity with useful retransmissions instead of padding +- [#2296](/quest/m2/quic/2296-moq-native-bring-the-quiche-backend-to-quinn-noq-feature.md) - + the quiche backend honors every listen/connect setting quinn and noq do, or + refuses it by name, and can serve a worker group +- [#2853](/quest/m2/quic/2853-quiche-with-a-pinned-source-port-can-dial-only-a-broken.md) - + a quiche dial with a pinned source port waits for the all-family DNS answer + before taking its one candidate ## Related diff --git a/quest/m2/quic/scheduler.md b/quest/m2/quic/scheduler.md index 3a424e0797..6890a45a9f 100644 --- a/quest/m2/quic/scheduler.md +++ b/quest/m2/quic/scheduler.md @@ -71,7 +71,7 @@ fork's worth of work and its top two levels already match. Measure against the two configurations reachable without any of this, both real options: a scalar send order of `[track][group]`, which buys strict priority and newest-first while giving up fairness (see -[Send order width](/quest/m1/perf/send-order-width.md)), and a scalar of +[Send order width](/quest/m2/perf/send-order-width.md)), and a scalar of `track` alone, which lets quinn's fairness through and gives up newest-first. A congested session carrying two equal-priority tracks of different group cadence, audio against video, must keep both progressing rather than draining @@ -87,7 +87,7 @@ the same scenarios through raw QUIC and qmux. ## Required -- [Send order width](/quest/m1/perf/send-order-width.md) - the scalar lands first; the prototype is measured against it +- [Send order width](/quest/m2/perf/send-order-width.md) - the scalar lands first; the prototype is measured against it - [Establish the noq relationship](/quest/m2/quic/parent.md) - the scheduler is proposed to noq first @@ -101,5 +101,5 @@ the same scenarios through raw QUIC and qmux. - [moq#3320](https://github.com/moq-dev/moq/pull/3320) - removes the current dense-rank queue from the wide scalar path and records why a scalar cannot provide this fairness level -- [Transmission order](/quest/m1/ladder/transmit.md) - rendition priority is a - policy consumer of the same hierarchy +- [Ladder controller](/quest/m2/ladder/controller.md) - rendition priority is + a policy consumer of the same hierarchy diff --git a/quest/m2/relay-memory.md b/quest/m2/relay-memory.md index 00facff259..fc35401a32 100644 --- a/quest/m2/relay-memory.md +++ b/quest/m2/relay-memory.md @@ -36,4 +36,4 @@ degree and adds a second, more specific route per carried broadcast. ## Related - [Routes per broadcast gauge](/quest/m2/route-gauge.md) - the operator-facing count, shippable on its own -- [Perf](/quest/m1/perf/README.md) - the hot-path work that owns the remaining per-cell cost +- [Perf](/quest/m2/perf/README.md) - the hot-path work that owns the remaining per-cell cost diff --git a/quest/m2/reuseport-group.md b/quest/m2/reuseport-group.md new file mode 100644 index 0000000000..44d9bc6ad9 --- /dev/null +++ b/quest/m2/reuseport-group.md @@ -0,0 +1,40 @@ +# [M] A reuseport group is complete before it serves and keeps every socket + +## Goal + +`moq_sock::shard::Group` hands out no usable socket until every declared +member has bound and the steering filter is attached, and it retains every +socket for the served group's lifetime, so partial formation and an early +socket drop are unrepresentable rather than documented. + +## Plan + +Today the rule is left to the caller (rs/moq-sock/src/shard.rs:141-153): the +kernel numbers a reuseport group by what is in it, so closing one socket +renumbers every member after it and the filter steers their traffic to the +wrong sockets. `Member::bind` (:252-273) enforces bind order and hands the +socket straight back, and the filter goes on only when the last member binds +(:344-349), so: + +- binding fewer than all declared members exposes usable sockets before the + steering filter exists; +- dropping an earlier socket before the last bind invalidates the recorded + slot positions, and nothing notices. + +Both current runtime callers bind every member before returning the group, +which is why this has not bitten. It is still a hole in the public API. + +- Require complete formation before serving: a `Member::bind` yields a + claim, not a socket, and the group releases sockets only once every member + is in and the filter is attached. +- Retain every socket for the served group's lifetime, so a caller cannot + close one without closing the group. +- Regressions for partial formation (no socket usable before the last bind) + and for an early drop (refused, or the whole group ends). + +The runtime-side consumer of this shape is +[#2964](/quest/m2/2964-quic-workers-dropping-one-split-server-resizes-the.md). + +## Required + +- [Merge dev](/quest/m1/merge-dev.md) - builds on dev-only code that reaches `main` with the merge diff --git a/quest/m2/revalidation-updates.md b/quest/m2/revalidation-updates.md index 7033216483..4773e6c131 100644 --- a/quest/m2/revalidation-updates.md +++ b/quest/m2/revalidation-updates.md @@ -55,4 +55,4 @@ On main, additive. ## Related - [Relay auth](/quest/m2/path-patterns/relay-auth.md) - the scope contract this inherits -- [Auth verdict](/quest/m2/auth-verdict.md) - the proxy mode whose re-check this also governs +- the proxy auth verdict mode, whose re-check this also governs (no quest yet) diff --git a/quest/m2/stats-contention.md b/quest/m2/stats-contention.md index 511a96e354..f494baec36 100644 --- a/quest/m2/stats-contention.md +++ b/quest/m2/stats-contention.md @@ -40,6 +40,6 @@ Reference: [Linux cache-line contention analysis](https://www.kernel.org/doc/htm ## Related -- [Ingest batch](/quest/m1/perf/ingest-batch.md) - owns the ingress write path; this quest measures cross-worker contention and egress fanout -- [Cache shard](/quest/m1/perf/cache-shard.md) - separate shared recency-counter bottleneck +- [Ingest batch](/quest/m2/perf/ingest-batch.md) - owns the ingress write path; this quest measures cross-worker contention and egress fanout +- [Cache shard](/quest/m2/perf/cache-shard.md) - separate shared recency-counter bottleneck - [Relay profiling](/quest/m2/performance-profiles.md) - reproducible CPU attribution diff --git a/quest/m1/uring-tcp/README.md b/quest/m2/uring-tcp/README.md similarity index 51% rename from quest/m1/uring-tcp/README.md rename to quest/m2/uring-tcp/README.md index 09e263f10a..960f10e2ed 100644 --- a/quest/m1/uring-tcp/README.md +++ b/quest/m2/uring-tcp/README.md @@ -15,25 +15,31 @@ supervision. This line moves the media path, not the control plane. ## Plan +This line starts after the dev merge, on main. + The three quests below ship together as one capability, in order. The prerequisite that shapes the middle quest: **qmux sessions arrive through the axum router**. `web.rs` routes `/` and `/{*path}` to -`websocket::serve_ws` behind the `axum/ws` feature, so a WebSocket session is -an HTTP upgrade before it is a media session. There is no moving qmux onto the -ring without also running the HTTP server that upgrades it there. That is not -a reason to rewrite axum: hyper is runtime-agnostic, so implementing -`hyper::rt::{Read, Write, Executor}` over ring TCP streams keeps axum's -routers, extractors, CORS, and its WebSocket upgrade working unchanged. - -Measure before porting, the same way `echo_quiche` gated the UDP path. The -ablation's number is what justifies the rest of the line. +`websocket::serve_ws` (rs/moq-relay/src/web.rs:256-259). The gate is +moq-relay's own `websocket` feature (rs/moq-relay/Cargo.toml:43, which is +what turns on `axum/ws`) plus the runtime `resolved_ws()` check +(web.rs:257), so a WebSocket session is an HTTP upgrade before it is a media +session. There is no moving qmux onto the ring without also running the HTTP +server that upgrades it there. That is not a reason to rewrite axum: hyper is +runtime-agnostic, so implementing `hyper::rt::{Read, Write, Executor}` over +ring TCP streams keeps axum's routers, extractors, CORS, and its WebSocket +upgrade working unchanged. + +Measure before porting, the same way `echo_quiche` +(rs/moq-uring/benches/session_lite.rs) gated the UDP path. The ablation's +number is what justifies the rest of the line. ## Quests -- [Ablation](/quest/m1/uring-tcp/ablation.md) - measure ring TCP against tokio +- [Ablation](/quest/m2/uring-tcp/ablation.md) - measure ring TCP against tokio TCP under the qmux workload before committing to the port -- [Stream](/quest/m1/uring-tcp/stream.md) - a `tcp` module in `moq-uring`, and +- [Stream](/quest/m2/uring-tcp/stream.md) - a `tcp` module in `moq-uring`, and the `hyper::rt` adapters that let axum run on it -- [Relay](/quest/m1/uring-tcp/relay.md) - serve the relay's WebSocket and +- [Relay](/quest/m2/uring-tcp/relay.md) - serve the relay's WebSocket and stream listeners from the io_uring workers diff --git a/quest/m1/uring-tcp/ablation.md b/quest/m2/uring-tcp/ablation.md similarity index 65% rename from quest/m1/uring-tcp/ablation.md rename to quest/m2/uring-tcp/ablation.md index af41f6abab..b32dcc7397 100644 --- a/quest/m1/uring-tcp/ablation.md +++ b/quest/m2/uring-tcp/ablation.md @@ -8,11 +8,11 @@ this line is worth building and what shape the port should take. ## Plan -Follow `echo_quiche`: a bench over the real worker, toggling one thing at a -time, not a synthetic microbenchmark alone. Drive it with the `moq-bench` -shapes the UDP path was measured on, including the `F=0` chat shape where the -cost is per message rather than per byte, since that is where a syscall per -read hurts most. +Follow `echo_quiche` (rs/moq-uring/benches/session_lite.rs): a bench over the +real worker, toggling one thing at a time, not a synthetic microbenchmark +alone. Drive it with the `moq-bench` shapes the UDP path was measured on, +including the `F=0` chat shape where the cost is per message rather than per +byte, since that is where a syscall per read hurts most. Vary at least: tokio (epoll) against ring; multishot `recv` with a provided buffer ring against a read per completion; batched writes against a write per @@ -24,3 +24,7 @@ Exit criteria: the ablation attributes the gain (or the absence of one) to specific mechanisms, and the result is recorded where the next two quests can be judged against it. A result showing no meaningful win is a valid outcome and abandons the rest of the line. + +## Required + +- [Merge dev](/quest/m1/merge-dev.md) - builds on dev-only code that reaches `main` with the merge diff --git a/quest/m2/uring-tcp/relay.md b/quest/m2/uring-tcp/relay.md new file mode 100644 index 0000000000..cfce78c520 --- /dev/null +++ b/quest/m2/uring-tcp/relay.md @@ -0,0 +1,33 @@ +# [M] Relay stream listeners on the workers + +## Goal + +The relay serves its WebSocket sessions, and its HTTP routes, from the +io_uring workers rather than the shared tokio runtime. A qmux media session is +then pinned to one thread for its whole life, exactly like a QUIC one. + +## Plan + +`uring::Workers::bind` ignores `listen`'s `tcp`/`unix` listeners and points +the operator at a separate `init_streams` tokio server +(rs/moq-relay/src/uring.rs:116-120); the only thing it refuses is +`tls.generate` (:126-128). Replace the silent skip with real support: each +worker binds its own listener in the reuseport group and runs the router from +[stream](/quest/m2/uring-tcp/stream.md) on it. + +The split of work stays what `uring.rs` already documents (:10-15): the worker +owns everything transport-shaped, while authentication and session +supervision run on the shared tokio runtime that owns the HTTP client, the +timers, and the origins. A qmux session handle is `Send + Sync` however its +transport is driven, which is what makes that handoff free here too. + +Keep the ops and web listeners' behavior identical: the same routes, the same +CORS scoping, the same landing-page fallback, and the same +`/certificate.sha256` fingerprint. Extend `rs/moq-relay/tests/runtime_uring.rs` +to prove a WebSocket session and an HTTP route both work when served from a +worker. + +## Required + +- [Stream](/quest/m2/uring-tcp/stream.md) - the module and adapters this + serves from diff --git a/quest/m1/uring-tcp/stream.md b/quest/m2/uring-tcp/stream.md similarity index 80% rename from quest/m1/uring-tcp/stream.md rename to quest/m2/uring-tcp/stream.md index 40b98532dd..d5885bc72c 100644 --- a/quest/m1/uring-tcp/stream.md +++ b/quest/m2/uring-tcp/stream.md @@ -21,7 +21,15 @@ routers, extractors, CORS, and its WebSocket upgrade keep working, and `qmux`'s `ws` feature stays intact. The `Executor` spawns onto the worker's local task set, so a connection never leaves the thread that accepted it. +hyper becomes a first direct dependency. Today it reaches the workspace only +through `axum` and `axum-server`; add it to `[workspace.dependencies]` at the +newest stable version, per `rs/CLAUDE.md`. + Cover it the way the UDP path is covered: tests that run against whichever backend is compiled, an HTTP round trip through hyper on the worker, and a WebSocket upgrade. Everything skips loudly below the kernel floor, as the existing suite does. + +## Required + +- [Merge dev](/quest/m1/merge-dev.md) - builds on dev-only code that reaches `main` with the merge diff --git a/rs/CLAUDE.md b/rs/CLAUDE.md index bcf0b798c9..48ed06a3bc 100644 --- a/rs/CLAUDE.md +++ b/rs/CLAUDE.md @@ -5,7 +5,7 @@ The `/rs` Cargo workspace. Extends the root `CLAUDE.md`. One crate per component from the root list, named `moq-` (`hang` and `libmoq` are the exceptions). Keep them modular; a crate does one thing. Beyond that list: - `kio`: "easy async" primitives everything else polls through. -- `moq-tokio`: configures the QUIC backends (Quinn/Quiche/Noq/Iroh) and the fallback transports for native binaries, on tokio. `moq-sock` holds the socket plumbing it shares with `moq-uring`, the experimental thread-per-core io_uring runtime. +- `moq-tokio`: configures the QUIC backends (Quinn/Quiche/Noq/Iroh) and the fallback transports for native binaries, on tokio. `moq-sock` holds the socket plumbing it shares with `moq-uring`, the experimental thread-per-core io\_uring runtime. - `moq-cli` builds the `moq` binary and owns the CLI surface for the gateway crates. Binaries never carry a `-cli` suffix. `moq-net`, `moq-mux`, `moq-relay` (including the config conventions every binary shares), and `moq-ffi` have their own `CLAUDE.md`.