diff --git a/quest/m0/broadcast-epoch/README.md b/quest/m0/broadcast-epoch/README.md index b6bc106787..8f4ce2b0d0 100644 --- a/quest/m0/broadcast-epoch/README.md +++ b/quest/m0/broadcast-epoch/README.md @@ -5,15 +5,15 @@ A path and the epoch on its route are the only content identity, and no first-party publisher reuses a pair for different content. Only routes with the same epoch resume a subscription from the first frame it lacks; a route -without one keeps its subscriptions until it goes. So epochs are what make +without one keeps its subscriptions until a better route takes over. So epochs are what make failover seamless, and a restart is a new epoch at the same path: the newest epoch wins and ends subscriptions to the old one, so viewers re-request rather than stall on a replaced broadcast. The epoch rides moq-lite 07 announcements and requests as metadata, so the path never changes and every older version and moq-transport keeps working: -their routes carry no epoch, stay on one route, and see a restart as an end -and start at the same path. +their routes carry no epoch, and see a restart as an end and start at the +same path. Non-goals: pooling, which needs nothing here; a redundant pair shares an explicit epoch through [`--hop` removal](/quest/m0/broadcast-epoch/hop-removal.md). @@ -33,8 +33,10 @@ Decided: - The route's epoch is taken as given: nothing mints one by default (decided 2026-10-06). Each first-party publisher mints one per run and announces it; a replica announces a shared one. A route without one, such as a - transcoder's prefix claim, stays on the worker that first served a - subscription and is never stitched to another worker's output. + transcoder's prefix claim, is never stitched to another worker's output: a + better route takes over with a hard switch and an announcement (decided + 2026-10-07, replacing "stays on the worker that first served a + subscription", which let dead routes linger). - The newest epoch wins a prefix ahead of cost (decided 2026-10-06), and replaces the old one with a hard switch: subscriptions in flight end with `Unroutable`. When it goes and an older one is still live, the older one @@ -69,6 +71,7 @@ timeout, and killing the newest epoch falls back to a still-live older one. ## Required - [Apps](/quest/m0/broadcast-epoch/apps.md) - moq-cli, the browser publish and watch components, and demo/web restart into a new epoch and reset on the switch +- [Un-epoched takeover](/quest/m0/broadcast-epoch/unepoched-takeover.md) - a better route without an epoch takes over with a hard switch and an announcement, so a restarted lite-06 publisher reaches viewers - [Gateways](/quest/m0/broadcast-epoch/gateways.md) - RTMP, SRT, and WHIP ingest mint an epoch per incoming connection, so an encoder reconnect is a clean takeover - [TS restart](/quest/m0/broadcast-epoch/ts-restart.md) - a signalled backward TS discontinuity finishes the broadcast and continues the same input under a fresh epoch - [Bindings](/quest/m0/broadcast-epoch/bindings.md) - moq-ffi and every wrapper expose the epoch and let a publisher announce one diff --git a/quest/m0/broadcast-epoch/unepoched-takeover.md b/quest/m0/broadcast-epoch/unepoched-takeover.md new file mode 100644 index 0000000000..735090ff57 --- /dev/null +++ b/quest/m0/broadcast-epoch/unepoched-takeover.md @@ -0,0 +1,71 @@ +# [M] A better route without an epoch takes over + +## Goal + +When a path's routes carry no epoch, whichever route `route_order` ranks first +always serves it. A new winner takes over with a hard switch, as a newer epoch +does: subscriptions in flight end, and downstream sessions see the switch as an +announcement (End then Start), even when the new route's metadata matches the +old. Viewers re-request and land on the new route, never stitched across the +two. This holds on every version, lite-07 included, for any route without an +epoch. + +So a lite-06 publisher that restarts onto the same path while its old session +lingers reaches viewers within an RTT, instead of leaving them on a dead front +until a reload ([#4970](https://github.com/moq-dev/moq/pull/4970)'s open +decision, and the second case in +[#4999](https://github.com/moq-dev/moq/pull/4999)). + +## Plan + +Decided 2026-10-07 while re-planning #4999 and #5001: + +- Routes without an epoch are no longer sticky. Since + [#4942](https://github.com/moq-dev/moq/pull/4942), a front without an epoch + stays on its first route (`pick`'s `Some(None)` arm in + `rs/moq-net/src/model/origin.rs`, the module docs in `model/front.rs`), so a + dead route lingers and a new request joins it. Before #4942 a better route + was re-announced, and this restores that. +- The switch is hard, like a newer epoch's: the old front ends and its tracks + in flight end, so nothing splices bytes from two publishers. That keeps the + README's "never stitched" rule while dropping "stays on the worker that + first served a subscription". +- Any new winner takes over, including one decided by the rendezvous-hash + tiebreak. Without an epoch a relay can't tell a restarted publisher from an + extra replica, so there is no narrower rule that fixes restarts. +- Consequence, accepted: any change of winner restarts playback for the paths + it moves, on every version. That includes a cost change (a GOAWAY drain + re-price) and a change to an equal-cost pool (a transcoder worker joining, a + relay peer reconnecting), which moves the paths the hash now gives the new + route. Routes with an epoch are unchanged. +- A change of winner between routes without an epoch always reaches downstream + as an announcement, even with identical metadata. Today the announce cursor + hides it ("a reconnect under an identical route is invisible"). Two routes + with the same epoch and identical metadata are a seamless failover and stay + invisible. +- Rejected: `@moq/watch` resubscribing on `Internal` or `SessionClosed` + (#4999), since players recover on announcements, not errors; re-announcing + only once the old front dies, since viewers stay blank until the old session + times out; taking over only on a strictly better route, which leaves an + equal-cost restart blank; the newest winning ties, which lets a joining + worker take every tied path; and waiting for lite-07 to be the default. + +Tests to flip: `better_route_keeps_the_incumbent` in +`rs/moq-net/tests/route_change.rs`, and `identical_reannounce_is_invisible` in +`origin.rs` for routes without an epoch. `route_dies_without_an_epoch` keeps +its expectation (a dead route ends its subscriptions), but its `standby()` +must price `B` strictly worse, or it takes over before the trigger; extend it +so a re-request lands on `B`. js/net's origin already swaps to a better entry +and closes the old front (`route()` in `js/net/src/origin.ts`), and its +announce cursor emits End and Start on a new winner, so the JS half is likely +tests only. + +Wire: no new messages, but a behavior change for other relays. +`drafts/draft-lcurley-moq-lite.md` says a subscription between routes without +an Epoch "stays on its route and ends with it". Replace that with: the relay +ends it when another route wins, and announces the change. + +## Related + +- [Apps](/quest/m0/broadcast-epoch/apps.md) - #4970 drives `@moq/watch` and `moq play` from announcements, which this relies on +- [Remove `--hop`](/quest/m0/broadcast-epoch/hop-removal.md) - drops the per-session hop stamp, so restarted routes often have identical metadata diff --git a/quest/m1/README.md b/quest/m1/README.md index 1ea8e567a7..7cf1a846f4 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -156,6 +156,9 @@ blocks. The quests that gated m0 lines moved under them. - [Route wakes](/quest/m1/route-wakes.md) - a route change wakes only the fronts it can move, so pool churn stops scaling with served paths - [Copy-walk bench](/quest/m1/admission-bench.md) - a front's walk over its copies on a route change is benchmarked over tracks and copies - [Publish channel count](/quest/m1/publish-audio-channel-count.md) - forcing a channel count on an Audio.Capture stops costing the subscriber gaps of silence +- [JS request deadline](/quest/m1/js-request-deadline.md) - each JS request, PUBLISH_NAMESPACE included, has one fatal 10 s timer from create to answer and fails fast without stream credit, so nothing queues or retries +- [Rust request credit](/quest/m1/rs-request-credit.md) - a moq-net request fails at once without stream credit instead of waiting +- [qmux no-wait opens](/quest/m1/qmux-no-wait.md) - @moq/qmux rejects an over-limit create when waitUntilAvailable is false, like Chrome - [E2EE](/quest/m1/e2ee/README.md) - TypeScript and Rust peers interoperate over encrypted broadcasts no relay can decrypt - [#3056](/quest/m1/3056-watch-video-decoder-captures-the-rewind-generation-at.md) - watch: the video decoder resets on a declared discontinuity - [#933](/quest/m1/933-video-rotation-metadata-not-propagated-from-mobile-camera.md) - the catalog rotation follows the live camera's orientation diff --git a/quest/m1/js-request-deadline.md b/quest/m1/js-request-deadline.md new file mode 100644 index 0000000000..c84970040b --- /dev/null +++ b/quest/m1/js-request-deadline.md @@ -0,0 +1,65 @@ +# [M] JS requests: one 10 s deadline from create to answer + +## Goal + +Every `@moq/net` request that waits for an answer runs under one fatal 10 s +timer, from creating its stream to the peer's first answer. That covers +subscribe, standalone track info, and fetch on lite and moq-transport, plus the +IETF publisher's PUBLISH_NAMESPACE. When it fires, the request fails, and any +stream it created is reset, including one that opens later. A create the +transport refuses, or one still pending when the timer fires, fails with a +local "no stream credit" error. A request the peer opened but never answered +fails with `ControlTimeout`. Announce interest and SubscribeNamespace bound +their create the same way but have no answer deadline, since they are +long-lived. + +Nothing retries either failure. A request without credit is refused, which +sheds load instead of queueing work the caller can't see, and a timeout is +terminal. + +## Plan + +Decided 2026-10-07, re-planning the external +[#4999](https://github.com/moq-dev/moq/pull/4999) and +[#5001](https://github.com/moq-dev/moq/pull/5001): + +- Fail fast without credit. Request opens pass `waitUntilAvailable: false`. + Chrome already rejects an over-limit create at once with a `NetworkError` + (crbug.com/487117768), and stays terminal. Firefox ignores the flag and + queues, so the one timer bounds it; a stream that opens after that is reset. + `@moq/qmux` gets the same fix upstream. +- One timer, not two. `OPEN_TIMEOUT_MS` (`openWithin` in + `js/net/src/stream.ts`) stays the default for `Stream.open` and + `Writer.open`, which SETUP, probe, and group streams inherit. Request sites + opt out of it, and their setup timer (`SUBSCRIBE_SETUP_TIMEOUT_MS` in lite, + `SUBSCRIBE_OK_TIMEOUT_MS` in IETF, PUBLISH_NAMESPACE's own) covers the + create as well as the answer. +- PUBLISH_NAMESPACE is in scope with no retry: drop the IETF publisher's loop + that re-offers a failed advertisement. +- The credit error is local, with no wire code. Propose its name in the PR; + it is a new public `@moq/net` error. +- `@moq/watch` keeps ending a track on either failure. Rejected: #4999's + resubscribe on a local timeout, since retrying adds a create per attempt and + the timeout is meant to be fatal. Rejected: #5001's unbounded wait for a + slot. +- Keep [#5002](https://github.com/moq-dev/moq/pull/5002)'s handling of a timer + that fires mid-setup: the TRACK stream is reset, and a SUBSCRIBE stream that + opens afterwards is reset with nothing written. +- Fix the stale text: "Chrome ~100" and "silently blocks" (IETF) or "the open + blocks" (lite) in the subscribers' timeout comments (the cap is the peer's + MAX_STREAMS; the Rust relay grants 10,000), `stream.ts`'s + `QuotaExceededError` claim and its "matches the subscribe budget" note, and + the "(browser stream limit reached?)" hint in the timeout messages. + +Test with mocked time: a transport with a stream limit, covering an immediate +rejection, a queued create that times out with the credit error and is reset +when it opens, and an opened request with no answer that fails with +`ControlTimeout`. Cover lite and IETF draft 17. + +Public API: one new error. Wire: none. + +## Related + +- [Rust request credit](/quest/m1/rs-request-credit.md) - the same rule in moq-net +- [qmux no-wait opens](/quest/m1/qmux-no-wait.md) - the WebSocket fallback stops queueing over-limit creates +- [JS GOAWAY requests](/quest/m1/js-goaway-requests.md) - adds a check at every open site this touches diff --git a/quest/m1/qmux-no-wait.md b/quest/m1/qmux-no-wait.md new file mode 100644 index 0000000000..8bf7364474 --- /dev/null +++ b/quest/m1/qmux-no-wait.md @@ -0,0 +1,15 @@ +# [S] qmux refuses over-limit creates without waiting + +## Goal + +`@moq/qmux` (the WebSocket fallback, and so Safari) rejects +`createBidirectionalStream` and `createUnidirectionalStream` at once when +`waitUntilAvailable` is false and the peer has granted no stream credit, as +Chrome's WebTransport does, instead of queueing the create. Released from +moq-dev/web-transport and bumped in `js/net/package.json`. + +## Plan + +Decided 2026-10-07 with [JS requests](/quest/m1/js-request-deadline.md), +which pass the flag and otherwise rely on their 10 s timer to bound the queue. +Match the rejection Chrome raises so `@moq/net` handles both the same way. diff --git a/quest/m1/rs-request-credit.md b/quest/m1/rs-request-credit.md new file mode 100644 index 0000000000..4f3f7ab1b4 --- /dev/null +++ b/quest/m1/rs-request-credit.md @@ -0,0 +1,30 @@ +# [S] Rust requests fail fast without stream credit + +## Goal + +A moq-net request (subscribe, track info, fetch, announce interest; lite and +moq-transport) whose stream open finds no stream credit fails at once with a +local error, instead of waiting for the peer to grant more. A relay resetting a +downstream stream because of it sends `Internal`. Publisher group streams are +out of scope. + +## Plan + +Decided 2026-10-07 with [JS requests](/quest/m1/js-request-deadline.md): a +request without credit is refused, which sheds load rather than parking work +the caller can't see. Accepted: a relay asking a peer that grants few streams +gets hard failures in bursts where it used to wait, and nothing retries them. +Rust gets no answer timer: a request lives until it is answered, its demand +leaves, or the session closes, and a relay shouldn't kill a subscribe on a +slow upstream hop. + +A single `Pending` poll of `open_bi` is not "no credit": on WebTransport, +`web-transport-quinn` opens the QUIC stream and then writes the session header, +which can be pending under send-window pressure. Check credit at the QUIC +level instead, likely a non-waiting open on the transport trait. Also note that +`moq-tokio`'s `poll_open_bi` parks an abandoned open in the session's slot +rather than dropping it, so the next request inherits it; the non-waiting open +should avoid that. + +Propose the error variant's name in the PR. Public API: one new `Error` +variant, and possibly a transport-trait method. Wire: none.