Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions quest/m0/broadcast-epoch/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
71 changes: 71 additions & 0 deletions quest/m0/broadcast-epoch/unepoched-takeover.md
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions quest/m1/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
65 changes: 65 additions & 0 deletions quest/m1/js-request-deadline.md
Original file line number Diff line number Diff line change
@@ -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
15 changes: 15 additions & 0 deletions quest/m1/qmux-no-wait.md
Original file line number Diff line number Diff line change
@@ -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.
30 changes: 30 additions & 0 deletions quest/m1/rs-request-credit.md
Original file line number Diff line number Diff line change
@@ -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.
Loading