From 90387ce11fc8eda704353a31ac86e978bd8413ad Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 7 Oct 2026 10:34:40 -0700 Subject: [PATCH] quest: re-plan #4999 and #5001 as stream-credit and route-takeover quests Co-Authored-By: Claude Opus 5.5 --- quest/m0/broadcast-epoch/README.md | 13 ++-- .../m0/broadcast-epoch/unepoched-takeover.md | 52 ++++++++++++++++ quest/m1/README.md | 3 + quest/m1/js-request-deadline.md | 61 +++++++++++++++++++ quest/m1/qmux-no-wait.md | 15 +++++ quest/m1/rs-request-credit.md | 21 +++++++ 6 files changed, 160 insertions(+), 5 deletions(-) create mode 100644 quest/m0/broadcast-epoch/unepoched-takeover.md create mode 100644 quest/m1/js-request-deadline.md create mode 100644 quest/m1/qmux-no-wait.md create mode 100644 quest/m1/rs-request-credit.md 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..f62b22bb04 --- /dev/null +++ b/quest/m0/broadcast-epoch/unepoched-takeover.md @@ -0,0 +1,52 @@ +# [M] A better route without an epoch takes over + +## Goal + +When a path's routes carry no epoch, the route that ranks best (cost, then +newest) always serves it. A better route 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 on lite-06 and moq-transport), 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 in moq-net and in +js/net's origin. + +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". +- A change of winning route always reaches downstream as an announcement, even + when the metadata is identical. Today the announce cursor sends nothing in + that case, which leaves a client holding the old handle. +- Consequence, accepted: a cost change between live routes without an epoch + (a GOAWAY drain re-price, a cluster cost shift) restarts lite-06 playback. + Routes with an epoch are unchanged. +- 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; and waiting for lite-07 to be the default. + +Flip `better_route_keeps_the_incumbent` and `route_dies_without_an_epoch` in +`rs/moq-net/tests/route_change.rs` to expect the takeover, and cover the same +in js/net. Check the lite draft for any text describing a sticky route. Wire: +no new messages. + +## 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 d0d5d36876..e15ee95e5a 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -158,6 +158,9 @@ blocks. The quests that gated m0 lines moved under them. - [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 abandonment](/quest/m1/js-subscribe-abandonment.md) - a viewer returning during IETF subscribe setup keeps its track across microtasks +- [JS request deadline](/quest/m1/js-request-deadline.md) - each JS request 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..2f51be6f87 --- /dev/null +++ b/quest/m1/js-request-deadline.md @@ -0,0 +1,61 @@ +# [M] JS requests: one 10 s deadline from create to answer + +## Goal + +Every `@moq/net` request that waits for an answer (subscribe, standalone track +info, and fetch, on lite and moq-transport) runs under one fatal 10 s timer, +from creating its stream to the peer's first answer. 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: waiting for stream credit is back-pressure, +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. Per-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. The per-request opens drop `OPEN_TIMEOUT_MS` + (`openWithin` in `js/net/src/stream.ts`), and the setup timers + (`SUBSCRIBE_SETUP_TIMEOUT_MS` in lite, `SUBSCRIBE_OK_TIMEOUT_MS` in IETF) + cover the create as well as the answer. SETUP, probe, and group streams keep + their own bounds. +- 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 silently blocks" and "Chrome ~100" in both + subscribers (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 fail fast](/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 abandonment](/quest/m1/js-subscribe-abandonment.md) - edits the IETF setup path; land after its PRs to avoid conflicts +- [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..271ee69ff8 --- /dev/null +++ b/quest/m1/rs-request-credit.md @@ -0,0 +1,21 @@ +# [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): +waiting for credit queues work the caller can't see, so a request is refused +rather than parked. Poll the open once; `Pending` means no credit, and +dropping the open cancels it cleanly. 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. + +Propose the error variant's name in the PR. Public API: one new `Error` +variant. Wire: none.