diff --git a/quest/README.md b/quest/README.md index 4e1f8ab493..e8dd793607 100644 --- a/quest/README.md +++ b/quest/README.md @@ -8,8 +8,8 @@ grouped into milestones ordered by priority. ## Plan m0 is everything in flight now: relay hardening and IETF interop ahead of -Seattle, wildcard routing, and audio playout (jitter target and quality -harness). m1 is the next wave across reliability, features, performance, and +Seattle, wildcard routing, broadcast epochs, and audio playout (jitter target +and quality harness). m1 is the next wave across reliability, features, performance, and planning. m2 holds later features, design studies, and experiments. m3 is gated on the outside world: hardware, a partner, a consumer, or a provider's offer. m4 waits on an upstream release. @@ -21,7 +21,7 @@ move the blocked quest to the milestone its priority belongs in. ## Required -- [m0: immediate priorities](/quest/m0/README.md) - everything in flight now: relay hardening and IETF interop for Seattle, wildcard routing, and audio playout +- [m0: immediate priorities](/quest/m0/README.md) - everything in flight now: relay hardening and IETF interop for Seattle, wildcard routing, broadcast epochs, and audio playout - [m1: next wave](/quest/m1/README.md) - reliability, capabilities, performance, and the planning that settles their contracts - [m2: later work](/quest/m2/README.md) - deferred features, design studies, and experiments - [m3: deferred](/quest/m3/README.md) - gated on the outside world: hardware, a partner, a consumer, or a provider's offer diff --git a/quest/m0/README.md b/quest/m0/README.md index f407402f09..3602dba4c9 100644 --- a/quest/m0/README.md +++ b/quest/m0/README.md @@ -2,20 +2,23 @@ ## Goal -The work in flight now, in three independent tracks. Relay hardening: legal +The work in flight now, in four independent tracks. Relay hardening: legal moq-transport input never fails a session ahead of Seattle interop on 2026-10-12, every resource a peer can make the relay hold is bounded by what it sent or by a budget, and no peer input panics the process. Routing: a service claims the prefix it could serve instead of enumerating broadcasts. +Identity: nothing treats who published a route as what it carries; a path, +with its `@epoch`, is the only content identity, and every first-party +publisher that can restart mints a fresh epoch, so #4741 stalls nobody. Audio playout: the target is a measured estimate of arrival timing in both languages, and a browser regression fails a nightly run. ## Plan The release API gates (#3829..#3878) and the release that followed them are -done. moq.pro tracks this repository as a submodule rather than a release, so -no release quest gates this milestone. The Pronto GPU integration lives in -moq.pro. +done. moq.pro pins this repository's `release` line, so the release gate below +also keeps #4741 from reaching it early. +The Pronto GPU integration lives in moq.pro. The branch flip ranks first: it is a short cutover that every later PR targets. Relay hardening: IETF interop leads the rest, since only those @@ -29,6 +32,12 @@ work is done on the line branch and waits to land. Serving the relay's ingested-only view (`origin::Consumer::local()`) to localhost workers belongs to moq.pro's edge, which embeds moq-relay; it moved there on 2026-09-28. +Identity: the [broadcast epoch](/quest/m0/broadcast-epoch/README.md) line +ranks right after Wildcard and gates the next release (decided 2026-10-03: +#4741 resumes an un-epoched republish into the old broadcast and stalls its +viewers). #4741 can merge to main, but no release ships until first-party +publishers mint epochs. + Audio playout: the jitter target replaces the round-trip guess. The browser audio quality harness in `test/audio-quality/` has landed; it grades playout nightly and records the traces it replays. Its native lane is a standalone m1 @@ -47,6 +56,7 @@ a published `@moq/watch` break. - [qmux credit](/quest/m0/qmux-credit.md) - qmux returns connection credit for dropped and stopped streams and delivers its close frame, on both lines - [Shared fronts](/quest/m0/shared-fronts.md) - viewer sessions share a front, so fronts scale with peers, not viewers - [Wildcard](/quest/m0/wildcard/README.md) - a relay resolves subscriptions against advertised prefixes, a service claims the prefix it could serve and refuses the rest instead of enumerating broadcasts, and the browser player treats a covering claim as availability +- [Broadcast epochs](/quest/m0/broadcast-epoch/README.md) - every first-party publisher that can restart mints a fresh `@` epoch, viewers follow the newest live one, and bare names still resolve on every version - [Audio jitter target](/quest/m0/audio-jitter-target/README.md) - the audio playout target is a measured estimate of arrival timing in both languages, not a round-trip guess ## Related diff --git a/quest/m1/broadcast-epoch/README.md b/quest/m0/broadcast-epoch/README.md similarity index 55% rename from quest/m1/broadcast-epoch/README.md rename to quest/m0/broadcast-epoch/README.md index 0d4392c8f3..c89e64e11e 100644 --- a/quest/m1/broadcast-epoch/README.md +++ b/quest/m0/broadcast-epoch/README.md @@ -2,28 +2,37 @@ ## Goal -Reusing a broadcast name is never ambiguous. By default, each publish of -`demo/BBB.hang` goes out as `demo/BBB.hang/@`, so the relay and viewers -can tell a new broadcast from a continuation. A viewer of the name follows -the newest live epoch as soon as it is announced, instead of waiting ~30s for -the old route's QUIC idle timeout. Moving to a new epoch is a clean boundary -(a fresh catalog and tracks, never spliced), and each run stays addressable -by its full path. +A path, with its `@epoch`, is the only content identity, and no first-party +publisher reuses one for different content. With #4741, any route covering a +path resumes its subscriptions from the first frame the subscriber lacks, +whoever serves it. So epochs are a correctness requirement: a publisher that +restarts its group numbering at 0 under an un-epoched name, while its old +route lingers, is resumed into the old broadcast and stalls viewers until its +sequence catches up. + +By default, each publish of `demo/BBB.hang` goes out as +`demo/BBB.hang/@`, so a restart is a new broadcast. A viewer of the +bare name follows the newest live epoch as soon as it is announced. Moving to +a new epoch is a clean boundary (a fresh catalog and tracks, never resumed +across), and each run stays addressable by its full path. The epoch rides in the path, so it survives any moq-transport relay, and no wire message changes. At an epoch-aware relay, a request for a bare name resolves to its newest live epoch on every protocol version. -Non-goals: pooling redundant publishers that share one epoch, which -[Cluster routing](/quest/m1/cluster-routing/README.md)'s selection and -`--hop` removal own; this line only keeps an epoch a caller supplies. Also out -of scope: trusting the publisher's clock (a far-future epoch -wins until its route goes away). +Non-goals: pooling, which needs nothing here, since every publisher of one +path is already one source; a redundant pair shares an explicit epoch through +[`--hop` removal](/quest/m0/broadcast-epoch/hop-removal.md). Also out of +scope: trusting the publisher's clock (a far-future epoch wins until its +route goes away). ## Plan Decided: +- This line gates the next release (decided 2026-10-03: #4741 can merge to + main, but without epochs every restarting first-party publisher stalls its + viewers). - The marker is a child segment `@`, parsed into `Option` by [the shared primitive](/doc/concept/moq-lite.md#publisher-epochs). Not a lite-07 flag: the path is the only carrier. @@ -41,6 +50,11 @@ Decided: bare `foo`, since a route covers its descendants, not its parent. A bare-name viewer behind one needs a publisher that opts out with the raw prefix route. Document this rather than promise it works. +- Publishers on the default publish path, such as moq-boy and moq-room, + inherit the epoch from Origin. moq-stats mints its own through + [Stats epochs](/quest/m1/stats-epoch.md), which is outside the release + gate: until it lands, a restarted stats node under a reused name can stall + its viewers the same way. - Derived output mirrors the epoch it came from (`.pro/transcode//foo.hang/@e`, per the [wildcard](/quest/m0/wildcard/README.md) line's derived-output layout), so @@ -58,9 +72,10 @@ This README owns: ## Required -- [Origin](/quest/m1/broadcast-epoch/origin.md) - moq-net publish mints an epoch, consumers follow the newest live one, and bare requests resolve to it on every version -- [Apps](/quest/m1/broadcast-epoch/apps.md) - moq-cli, the browser publish and watch components, and demo/web publish under epochs and play bare names -- [Gateways](/quest/m1/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/m1/broadcast-epoch/ts-restart.md) - a signalled backward TS discontinuity finishes the broadcast and continues the same input under a fresh epoch -- [Bindings](/quest/m1/broadcast-epoch/bindings.md) - moq-ffi and every wrapper expose the epoch and inherit the default -- [GStreamer and OBS](/quest/m1/broadcast-epoch/gst-obs.md) - moqsink and the OBS plugin publish each run under a fresh epoch +- [Origin](/quest/m0/broadcast-epoch/origin.md) - moq-net publish mints an epoch, consumers follow the newest live one, and bare requests resolve to it on every version +- [Apps](/quest/m0/broadcast-epoch/apps.md) - moq-cli, the browser publish and watch components, and demo/web publish under epochs and play bare names +- [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 inherit the default +- [GStreamer and OBS](/quest/m0/broadcast-epoch/gst-obs.md) - moqsink and the OBS plugin publish each run under a fresh epoch +- [Remove `--hop`](/quest/m0/broadcast-epoch/hop-removal.md) - `moq` takes an optional `--epoch` instead of `--hop`, a plain publisher declares a random Hop ID, and the per-session hop stamp and NO_CAPACITY are gone diff --git a/quest/m1/broadcast-epoch/apps.md b/quest/m0/broadcast-epoch/apps.md similarity index 56% rename from quest/m1/broadcast-epoch/apps.md rename to quest/m0/broadcast-epoch/apps.md index 93e4bc1e4b..cfbbdf28c2 100644 --- a/quest/m1/broadcast-epoch/apps.md +++ b/quest/m0/broadcast-epoch/apps.md @@ -3,9 +3,11 @@ ## Goal `moq-cli` publish and play, `@moq/publish`, `@moq/watch`, and `demo/web` use -the origin default. Each publish run is a new epoch, and watching a bare name -switches to a republish within an RTT rather than after the idle timeout. -The UI and logs show the full epoch path, and a watch link can pin one. +the origin default. Each publish run is a new epoch, so a restart while the +old route lingers is a new broadcast rather than a resume into the old one, +which stalls viewers until the new run's group sequence catches up. Watching +a bare name switches to a republish within an RTT. The UI and logs show the +full epoch path, and a watch link can pin one. ## Plan @@ -18,4 +20,4 @@ The UI and logs show the full epoch path, and a watch link can pin one. ## Required -- [Origin](/quest/m1/broadcast-epoch/origin.md) - the publish default and follow logic +- [Origin](/quest/m0/broadcast-epoch/origin.md) - the publish default and follow logic diff --git a/quest/m1/broadcast-epoch/bindings.md b/quest/m0/broadcast-epoch/bindings.md similarity index 93% rename from quest/m1/broadcast-epoch/bindings.md rename to quest/m0/broadcast-epoch/bindings.md index 3bc0239146..5aecc9045c 100644 --- a/quest/m1/broadcast-epoch/bindings.md +++ b/quest/m0/broadcast-epoch/bindings.md @@ -21,4 +21,4 @@ Decided in the 2026-09-30 audit: libmoq is frozen (renamed `rs/moq-c`), so C and ## Required -- [Origin](/quest/m1/broadcast-epoch/origin.md) - the behavior the bindings surface +- [Origin](/quest/m0/broadcast-epoch/origin.md) - the behavior the bindings surface diff --git a/quest/m1/broadcast-epoch/gateways.md b/quest/m0/broadcast-epoch/gateways.md similarity index 68% rename from quest/m1/broadcast-epoch/gateways.md rename to quest/m0/broadcast-epoch/gateways.md index 0b0f059aa5..2124764d1f 100644 --- a/quest/m1/broadcast-epoch/gateways.md +++ b/quest/m0/broadcast-epoch/gateways.md @@ -4,8 +4,9 @@ RTMP, SRT, and WHIP ingest publish each incoming connection under a fresh epoch. An encoder that reconnects to the same stream key becomes a clean -takeover at once, instead of a second publisher racing the stale one until it -times out. +takeover at once. Without it, the reconnect is resumed into the stale +connection's broadcast and stalls viewers until its group sequence catches +up. ## Plan @@ -16,8 +17,8 @@ describes ingest paths. ## Required -- [Origin](/quest/m1/broadcast-epoch/origin.md) - the publish default +- [Origin](/quest/m0/broadcast-epoch/origin.md) - the publish default ## Related -- [TS restart](/quest/m1/broadcast-epoch/ts-restart.md) - a signalled restart inside one connection, which this quest's per-connection epoch does not cover +- [TS restart](/quest/m0/broadcast-epoch/ts-restart.md) - a signalled restart inside one connection, which this quest's per-connection epoch does not cover diff --git a/quest/m1/broadcast-epoch/gst-obs.md b/quest/m0/broadcast-epoch/gst-obs.md similarity index 91% rename from quest/m1/broadcast-epoch/gst-obs.md rename to quest/m0/broadcast-epoch/gst-obs.md index 35604ee5e4..de56261456 100644 --- a/quest/m1/broadcast-epoch/gst-obs.md +++ b/quest/m0/broadcast-epoch/gst-obs.md @@ -16,5 +16,5 @@ audit: libmoq gets no new API). Update `doc/bin/gstreamer.md` and ## Required -- [Bindings](/quest/m1/broadcast-epoch/bindings.md) - moq-ffi exposes epochs +- [Bindings](/quest/m0/broadcast-epoch/bindings.md) - moq-ffi exposes epochs - [C++ through moq-ffi](/quest/m1/cpp/README.md) - OBS publishes through the generated C++ diff --git a/quest/m0/broadcast-epoch/hop-removal.md b/quest/m0/broadcast-epoch/hop-removal.md new file mode 100644 index 0000000000..2f776c535a --- /dev/null +++ b/quest/m0/broadcast-epoch/hop-removal.md @@ -0,0 +1,54 @@ +# [M] Redundant publishers share an epoch, not a hop + +## Goal + +Nothing names a publisher's identity outside its path. `moq` loses `--hop`, +`MOQ_HOP`, and the hidden `--origin` alias; an optional `--epoch` takes their +place for publishing. Omitted, each run gets a fresh epoch (the origin +default); a redundant pair passes the same value. A plain publisher declares +a random Hop ID per process with no flag, as the bindings and js already do, +so a multi-homed publisher still catches its own loops. `--cluster-id` names +a node and no longer falls back to `--hop`. Relays stop stamping an unnamed +publisher's hop chain, and NO_CAPACITY is gone: every refusal is terminal. + +## Plan + +- Drop the per-session hop stamp for unnamed publishers (decided 2026-10-03: + split horizon rests on `via` and own-hop checks, and the stamp never + matched any relay's id, so it only existed for publisher identity). Delete + `Hops::stamp` (`rs/moq-net/src/model/origin.rs`), the `stamp` fields in + `rs/moq-net/src/{lite,ietf}/subscriber.rs`, `stampHops` in + `js/net/src/hop.ts` and its callers in `js/net/src/{lite,ietf}/subscriber.ts`, + and their tests, including `rs/moq-net/tests/legacy_reconnect.rs`. Keep the + leading 0, so an unnamed chain still ranks anonymous. +- Remove NO_CAPACITY entirely (decided 2026-10-03: the maintainer does not + want a retryable refusal). Every refusal is terminal, and an advertiser + sheds load by withdrawing or re-pricing its route. That covers + `StreamCode.NoCapacity` and its uses in `js/net/src/{error,origin}.ts` and + the reserved-code comments in `rs/moq-net/src/error.rs`. +- Drafts: lite-07 and cluster-02 are in progress, so edit their text and + changelogs in place. In `drafts/draft-lcurley-moq-lite.md`: the stamping + rule, NO_CAPACITY in Error Codes and Resolution, "the identity it stamps", + and "the first entry identifies the endpoint that originated the route". In + `drafts/draft-lcurley-moq-cluster.md`: the unknown-publisher stamping text, + the relay behavior that stamps, NO_CAPACITY and its IANA row, and the + changelog bullets. +- CLI: `rs/moq-cli/src/{args,complete,fetch,ls}.rs`. Relays keep `cluster.id` + as their node id. +- Docs: `doc/bin/cli.md` ("Redundant publishers" uses `--epoch`), the + migration row in `doc/setup/upgrade.md`, the stamping paragraph in + `doc/bin/relay/cluster.md`, and the stamping sentence in + `doc/concept/moq-lite.md`. Search the repo for `--hop`, `MOQ_HOP`, and + `--cluster-id` examples, including demo recipes. + +Open: whether anything declares an incumbent dead faster than the QUIC +idle timeout (a failover service that retracts it, or active-active delivery +to the relay). Neither is required to land this. + +Public API: removes `--hop`, `MOQ_HOP`, and `--origin`, adds `--epoch`, and +`--cluster-id` stops reading `--hop`; js/net loses `StreamCode.NoCapacity`. +Wire: relays stop stamping, and lite-07 and cluster-02 lose NO_CAPACITY. + +## Required + +- [Origin](/quest/m0/broadcast-epoch/origin.md) - the default mint `--epoch` falls back to diff --git a/quest/m1/broadcast-epoch/origin.md b/quest/m0/broadcast-epoch/origin.md similarity index 95% rename from quest/m1/broadcast-epoch/origin.md rename to quest/m0/broadcast-epoch/origin.md index c37879cccc..0c9bc4f0e2 100644 --- a/quest/m1/broadcast-epoch/origin.md +++ b/quest/m0/broadcast-epoch/origin.md @@ -22,8 +22,8 @@ any wire change. one `@` segment below it. They pick the greatest live epoch, and on a table change, re-select: move up at once, or fall back when the current one is retracted. A path that names an epoch pins it and never moves. Each move is - a new broadcast to the caller, never a splice. The #3312 first-hop resume - rule still applies within one epoch path. + a new broadcast to the caller, never a splice. Within one epoch path, any + route resumes a subscription, whoever serves it. - Bare resolution: a bare request with no route of its own resolves to that epoch. A takeover ends the bare subscription with a typed reset, never a silent switch. Choose the code so existing clients resubscribe rather than diff --git a/quest/m1/broadcast-epoch/ts-restart.md b/quest/m0/broadcast-epoch/ts-restart.md similarity index 92% rename from quest/m1/broadcast-epoch/ts-restart.md rename to quest/m0/broadcast-epoch/ts-restart.md index baa7fa3d13..f302c717bf 100644 --- a/quest/m1/broadcast-epoch/ts-restart.md +++ b/quest/m0/broadcast-epoch/ts-restart.md @@ -8,7 +8,7 @@ When a TS feed rewinds its time base and signals it the rest of the same input as a new broadcast under a fresh epoch, in the same process and connection. This covers an encoder restart or source switch behind a gateway that keeps its connection up, and a looping playout server, none of -which makes a new connection for [Gateways](/quest/m1/broadcast-epoch/gateways.md) +which makes a new connection for [Gateways](/quest/m0/broadcast-epoch/gateways.md) to turn into an epoch. An unsignalled rewind stays fatal, as #4543 decided. ## Plan @@ -21,7 +21,7 @@ Decided (maintainer, 2026-09-30): - A rewind is new content, so it is always a new broadcast at a new epoch path, never a continuation of the old name. Viewers of the bare name follow - it through [Origin](/quest/m1/broadcast-epoch/origin.md). + it through [Origin](/quest/m0/broadcast-epoch/origin.md). - `decode` stops at the flagged rewind and reports it. The caller finishes the old broadcast (a clean end, not an abort, so its viewers read to its end), publishes a new broadcast at a fresh epoch path, and calls @@ -45,7 +45,7 @@ and `restart` is new. Wire: none. ## Required -- [Origin](/quest/m1/broadcast-epoch/origin.md) - the fresh epoch path the rest of the feed publishes under +- [Origin](/quest/m0/broadcast-epoch/origin.md) - the fresh epoch path the rest of the feed publishes under ## Closes diff --git a/quest/m0/wildcard/README.md b/quest/m0/wildcard/README.md index 78e88a1dd5..30556219d2 100644 --- a/quest/m0/wildcard/README.md +++ b/quest/m0/wildcard/README.md @@ -40,9 +40,9 @@ still hold: the transcoder claims its service prefix ([Where derived output lives](#where-derived-output-lives)), and the archive claims the root and refuses what it does not have. Resolve and Demand are additive and land on main. Both are done on the line -branch (#4050 re-resolves on a refusal; 9d059b1b9 lists and demands covered -renditions in the browser player), so main no longer lists them; the line -branch moves to `quest/m0/wildcard/README` to match this path. +branch (9d059b1b9 lists and demands covered renditions in the browser +player), so main no longer lists them; the line branch moves to +`quest/m0/wildcard/README` to match this path. ### What already exists, and what does not @@ -75,14 +75,11 @@ itself exists: `moq_net::{Pattern, Patterns, Segment}` and `Path.Pattern` / `Path.Patterns` in `js/net/src/path.ts` own the shared matching, containment, specificity, and rebasing tokens and filters reuse. -What is genuinely missing, beyond patterns themselves, is content identity. -Announcement `Epoch` was specified into lite-06 by -[#2611](https://github.com/moq-dev/moq/pull/2611), never implemented, and -removed from the draft by #3225, which retired `draft-lcurley-moq-broadcast` -with it. [moq#3312](https://github.com/moq-dev/moq/pull/3312) restored per-path identity -from the route's first hop, reversing #3225's no-splice rule, and this -questline builds its collision handling on that rather than on a generation -field. +Content identity is the path: it names one broadcast whoever serves it, and +any covering route resumes a subscription from the first frame the subscriber +lacks (#4741). The `@` segment (#4706) is what makes a restart a new +path. So this questline settles collisions with interchangeable output at one +path, not with a route identity or a generation field. ### Decisions @@ -98,8 +95,7 @@ field. pool that cost and the request hash order. A terminal refusal from the winning tier IS the answer and never falls through to a shorter prefix, so a transcoder refusing a path does not leak the request to the archive's - catch-all, and one unserved path still costs one round trip. The capacity - re-resolution below stays within the tier, refuser excluded. The accepted + catch-all, and one unserved path still costs one round trip. The accepted consequence: an offline derivative is not reachable through the catch-all while a longer prefix covers it. - **A claim is a POOL, not a competitor.** Several advertisers of one @@ -152,39 +148,35 @@ field. `#isPathAnnounced` hides a catalog rendition with no exact-path announcement); demand 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 - refusal it is (`Error::to_code` already puts a typed code on the wire; the - capacity code is NO_CAPACITY, 0x30, in moq-lite's own 48-63 range). - - A capacity refusal is unavoidable: an advertiser's capacity and a relay's view - of it are separated by at least half a round trip, so a retraction and a - request for the slot it just gave away WILL cross, at a rate of request rate - times retraction rate times RTT. Only that code permits ONE re-resolution, - with the refusing advertiser excluded from it. The exclusion is what makes the - retry safe, NOT the retraction arriving first: the reset and the retraction - travel independently, so re-resolution may pick another advertiser, and may - equally find none and return unroutable. Both are correct outcomes. - - Every other refusal is terminal and propagates: a path no rule covers, an - unauthorized one, one that does not exist. Scanning unserved paths therefore - still costs one round trip per path, and this is not a fallback list: there - is no walk down the candidates, only one re-resolution. Classification is - EXPLICIT, per the repository's retry policy: an unrecognized or bare reset is - permanent, so a new refusal mode surfaces instead of quietly joining a retry - loop. No negative cache either way; rate limiting stays with the advertiser - and the per-project auth gate. -- **A double claim is settled by route identity, not by a lease.** Two relays - can hash one path to different workers before either concrete announcement - propagates, and both land at the SAME literal path. Whichever route wins - selection serves it, and a consumer moves between them only when the winner's - identity is preserved, per the first-hop resume rule ([moq#3312](https://github.com/moq-dev/moq/pull/3312)); two distinct workers are two identities, - so the loser's subscribers end and resubscribe rather than being spliced onto - another worker's frames mid-group. Wildcard routing invents neither a lease - nor a generation. This is weaker than the retired `Epoch` design, which could - declare two workers' output interchangeable and splice between them; a service - that needs that guarantee has to carry it in its own media contract, not in - routing. +- **Every refusal is a terminal typed stream reset, with no negative cache.** + An advertiser resets a subscribe it will not serve, and the reset carries + which KIND of refusal it is (`Error::to_code` already puts a typed code on + the wire). Every refusal propagates: a path no rule covers, an unauthorized + one, one that does not exist, or one the advertiser has no capacity for. + There is no re-resolution (decided 2026-10-03: NO_CAPACITY is removed, so + no refusal is retryable). An advertiser sheds load by withdrawing or + re-pricing its claim; a request that crosses the retraction is refused, and + the client's ordinary resubscribe resolves again. Scanning unserved paths + costs one round trip per path. No negative cache; rate limiting stays with + the advertiser and the per-project auth gate. +- **A double claim is settled by interchangeable output, not by route + identity or a lease.** Two relays can hash one path to different workers + before either concrete announcement propagates, and both land at the SAME + literal path. A path is one broadcast whoever serves it, so claim workers + mirror the source's epoch and group numbers, publish a deterministic + catalog, and start at group boundaries + ([Transcoders start at group boundaries](/quest/m1/transcode-group-start.md)). + Two workers at one path are then one broadcast, and a relay moving between + them does so at a group boundary (decided 2026-10-03: #4741 drops + first-hop identity, so routing can no longer tell two workers apart). + Wildcard routing invents neither a lease nor a generation. Ship-order + risk: until that m1 quest lands, two workers at one path can still be + spliced mid-group. +- **No reply Origin.** The lite-07 `Origin` field in SUBSCRIBE_OK and FETCH_OK, + and the rule that a relay MUST NOT splice across differing Origins, are + dropped (decided 2026-10-03: nothing needs them for correctness). The line + branch carries code for Origin, `Identity`, and `Pin`, and #4050's + NO_CAPACITY re-resolution; remove it when the branch next merges main. - **Patterns are independent of clustering.** The `moq-pattern` crate owns the matching semantics tokens and filters share, with no draft of its own; no announce message carries a pattern on either protocol (AUTH grants on @@ -222,7 +214,9 @@ distinguish recording generations reads the catalog's archive entry - [archive](/quest/m1/archive/README.md) - an archive claims the root, and its catalog names the generations a claim cannot -- [Broadcast epochs](/quest/m1/broadcast-epoch/README.md) - derived output +- [Broadcast epochs](/quest/m0/broadcast-epoch/README.md) - derived output mirrors the source path, `@` segment included +- [Transcoders start at group boundaries](/quest/m1/transcode-group-start.md) - + what makes two claim workers at one path interchangeable - [Announcement shapes](/quest/m2/announce-shapes.md) - moq-lite-only exact, suffix, and prefix+suffix claims that survive relay hops 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 index 7ab04e037c..6e60a2d752 100644 --- 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 @@ -19,22 +19,20 @@ subscription creates a fresh `track::Request` (`rs/moq-net/src/model/track.rs`), and `Request::new` creates a fresh `TrackState`. Because its `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` (`rs/moq-net/src/model/resume.rs`) 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`, -`takeover_splices_mid_group`, -`takeover_splices_a_replacement_that_resends_the_head`, -`takeover_rolls_past_a_finished_group`, -`takeover_after_empty_segment_keeps_live_edge`) 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. +That conflicts with the relay's logical track. The pump +(`rs/moq-net/src/model/pump.rs`) resumes a replacement from the first frame +the subscriber lacks, so groups from a restarted producer are skipped until +its counter catches up, causing the same playback stall fixed for JavaScript +in #2953. With #4741 this is the general hazard of restarting at 0 under one +name, which [broadcast epochs](/quest/m0/broadcast-epoch/README.md) fix for a +restarted publisher. This quest covers what an epoch does not: one dynamic +track replaced inside a live broadcast. + +The pump's route-change tests (`rs/moq-net/tests/route_change.rs`) are the +ones to extend: none replaces a producer through `append_group()`, which 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 @@ -78,15 +76,11 @@ the closed producer's cache or terminal state. - 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. +- Rust relay model: extend the route-change tests above so a replacement + produced with `append_group()` is delivered immediately rather than skipped + 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 - -## Related - -- [#4491](https://github.com/moq-dev/moq/pull/4491) - edits `resume.rs` and its takeover tests diff --git a/quest/m1/3021-moq-gst-anchor-generated-media-timelines-to-wall-clock.md b/quest/m1/3021-moq-gst-anchor-generated-media-timelines-to-wall-clock.md index b12adcc540..14f002dd89 100644 --- a/quest/m1/3021-moq-gst-anchor-generated-media-timelines-to-wall-clock.md +++ b/quest/m1/3021-moq-gst-anchor-generated-media-timelines-to-wall-clock.md @@ -20,7 +20,7 @@ its own. Do not define a GStreamer-specific catalog shape. Decided in the 2026-09-30 audit: a restart is a new broadcast epoch, not a forward re-anchor on the old clock (per remove-live and -[GStreamer and OBS](/quest/m1/broadcast-epoch/gst-obs.md)), and +[GStreamer and OBS](/quest/m0/broadcast-epoch/gst-obs.md)), and [#3115](/quest/m2/3115-moqsink-the-publication-has-no-generation-so-a-flush.md) handles the sink side. @@ -33,4 +33,4 @@ delayed first buffers, and multiple pads sharing one epoch. ## Related -- [GStreamer and OBS](/quest/m1/broadcast-epoch/gst-obs.md) - a restarted pipeline publishes a new epoch +- [GStreamer and OBS](/quest/m0/broadcast-epoch/gst-obs.md) - a restarted pipeline publishes a new epoch diff --git a/quest/m1/README.md b/quest/m1/README.md index e87d02526e..bacac369a2 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -22,12 +22,11 @@ QUIC studies there on that rule. ## Required -- [moq-transport peers are plain clients](/quest/m1/ietf-cluster-off.md) - no moq-transport session negotiates the cluster extension, so none can splice a pool's members on a Hop ID label -- [Broadcast epochs](/quest/m1/broadcast-epoch/README.md) - each publish of a name gets a fresh `@` epoch, viewers follow the newest live one at once, and bare names still resolve on every version +- [moq-transport peers are plain clients](/quest/m1/ietf-cluster-off.md) - no moq-transport session negotiates the cluster extension, since cluster links are moq-lite only - [Bounded stats aggregate](/quest/m1/stats-aggregate-bound.md) - the stats aggregator folds departed nodes into a retired total after a grace window, so its memory stops growing with node churn - [Stats epochs](/quest/m1/stats-epoch.md) - each stats producer publishes under `.../node//@`, so a restarted node never reuses a name - [Cluster routing](/quest/m1/cluster-routing/README.md) - any node routes toward a broadcast's origin over CDN and P2P links alike, with per-origin routes and path-less announces -- [Remove `--hop`](/quest/m1/hop-removal.md) - redundant publishers share an explicit `@`, and `--hop` and the publisher's Hop ID are gone +- [Transcoders start at group boundaries](/quest/m1/transcode-group-start.md) - moq-transcode serves from a group boundary and mirrors its source's sequences, so two instances are interchangeable - [One route cost](/quest/m1/route-cost.md) - Warm and Cold collapse to one static route cost - [Delete removed cluster flags](/quest/m1/cluster-shims.md) - once a release has carried their refusals, `mesh` and `linger` leave `cluster::Config` - [Track tail interop](/quest/m1/track-tail-interop.md) - a Rust publisher ending a track with a group in flight is read to its end by the JS subscriber, and the reverse, in `just test interop` @@ -57,7 +56,7 @@ QUIC studies there on that rule. - [CI runner stalls](/quest/m1/ci-runner-stalls.md) - the 0.4 to 0.8 s freezes of both interop tracks on CI are attributed from a week of nightlies and fixed or told apart from playback bugs - [Subscriber max-delay](/quest/m1/subscriber-max-delay.md) - subscriber staleness becomes `max_delay`; publisher retention stays `max_age` - [Wire compatibility](/quest/m1/wire-compat.md) - a nightly run tests this checkout against the last published release for tokens, session wire, and catalog/container -- [Same-hop importers](/quest/m1/hop-aligned-import.md) - importers fed one stream publish identical groups and timestamps, so failover between a redundant pair survives +- [Same-epoch importers](/quest/m1/hop-aligned-import.md) - importers sharing one `--epoch` and fed one stream publish identical groups and timestamps, so failover between a redundant pair survives - [Auth client CA](/quest/m1/relay-auth-client-ca.md) - `auth::Config::validate` and `init` take the client-CA flag, so no caller can skip the check - [TS stats module](/quest/m1/ts-stats-module.md) - the TS stats types move under `ts::stats` as `Snapshot` and `Stream`, with an owned `track` - [Audio capture without ALSA link](/quest/m1/capture-alsa-link.md) - moq-audio capture and playback build on Linux without linking libasound @@ -117,11 +116,11 @@ QUIC studies there on that rule. - [QoS](/quest/m1/qos/README.md) - broadcast health: relay starvation and timeliness histograms - [Catalog track alias](/quest/m1/catalog-track-alias.md) - catalog rendition keys become aliases with an optional `track` name, so one catalog lists renditions from several broadcasts - [Media stats](/quest/m1/stats/README.md) - publishers announce a stats track in the catalog, viewers answer a soliciting catalog through a per-catalog `.echo` broadcast, and a Rust encoder adapts to them -- [JS group-boundary handover](/quest/m1/js-group-handover.md) - a JS track subscription carries across a route swap at a group boundary, so `test/drain` passes at zero latency budget +- [JS track handover](/quest/m1/js-group-handover.md) - a JS track subscription resumes across a route swap from the first frame it lacks, so `test/drain` passes at zero latency budget - [JS GOAWAY requests](/quest/m1/js-goaway-requests.md) - after GOAWAY the JS client opens no new request on the old session, like Rust - [Drain handshakes](/quest/m1/drain-handshakes.md) - a drain GOAWAYs and waits for sessions still in their handshake instead of exiting under them - [Strict Redirect::resolve](/quest/m1/redirect-resolve.md) - `Redirect::resolve` can no longer quietly turn a refused redirect into a redial -- [Transport upgrade](/quest/m1/transport-upgrade/README.md) - a session that came up over WebSocket moves to QUIC once the QUIC dial lands, handing over at a group boundary +- [Transport upgrade](/quest/m1/transport-upgrade/README.md) - a session that came up over WebSocket moves to QUIC once the QUIC dial lands, handing over without dropping a group - [Scope track priority](/quest/m1/track-priority-scope.md) - priority orders one owner's streams, and a shared cluster session is fair across tenants - [IETF on the ring](/quest/m1/uring-ietf.md) - the io_uring workers serve moq-transport sessions too, so a uring relay drops no client protocol - [BBR ACK cleanup](/quest/m1/bbr-ack-cleanup.md) - packet bookkeeping scales with completed entries instead of scanning the flight on every ACK diff --git a/quest/m1/cluster-routing/README.md b/quest/m1/cluster-routing/README.md index 84dbc08cdd..d6873ebd0e 100644 --- a/quest/m1/cluster-routing/README.md +++ b/quest/m1/cluster-routing/README.md @@ -46,8 +46,7 @@ vector the 2026-09-30 cache-tiers audit kept: subscription aggregation and has no good answer when stale. Nearest-origin selection over consistent metrics is still a shortest-path tree, so it is loop-free once converged, and a transient cycle collapses into one - aggregated subscription until re-selection moves it. The reply names the - serving node for the splice rule. + aggregated subscription until re-selection moves it. - **Policy per link, not roles.** A link marked upstream never receives routes learned on another upstream link, and a route learned upstream keeps a down-only mark across other links so a mesh with two uplinks cannot leak @@ -82,10 +81,11 @@ vector the 2026-09-30 cache-tiers audit kept: Kept from 2026-09-30: core links are configured and may skip PoPs; Warm and Cold collapse to one cost ([One route cost](/quest/m1/route-cost.md)); -epoch-qualified paths are a source's identity -([Selection](/quest/m1/cluster-routing/selection.md)); cluster links are -moq-lite only ([moq-transport peers are plain clients](/quest/m1/ietf-cluster-off.md)); -`--hop` removal and One route cost land on their own. +cluster links are moq-lite only +([moq-transport peers are plain clients](/quest/m1/ietf-cluster-off.md)); +`--hop` removal and One route cost land on their own. Decided 2026-10-03: a +concrete path is a source's identity, whoever serves it +([Selection](/quest/m1/cluster-routing/selection.md)). Anything specific to moq.pro's deployment is planned in moq.pro. ### Simulator findings @@ -129,7 +129,7 @@ Once every child has landed: ## Required - [Upstream links](/quest/m1/cluster-routing/transit.md) - a link marked upstream never receives routes learned on another upstream link, which builds edge tiers and drone uplinks from one rule -- [Selection](/quest/m1/cluster-routing/selection.md) - a broadcast under overlapping prefixes routes to one origin deterministically, and same-epoch origins are one source +- [Selection](/quest/m1/cluster-routing/selection.md) - a broadcast under overlapping prefixes routes to one origin deterministically, and same-path origins are one source - [Multi-CDN endpoints](/quest/m1/cluster-routing/multi-cdn.md) - an endpoint holds sessions to several CDNs, uses its preferred one, and fails over to the next - [Simulate the split](/quest/m1/cluster-routing/sim.md) - moq.pro's simulator compares the route layer with path vector before the wire is written - [Routes and announces](/quest/m1/cluster-routing/routes.md) - ROUTE per origin node and path-less ANNOUNCE on one stream, loop-free by Babel feasibility @@ -138,11 +138,11 @@ Once every child has landed: ## Related -- [Wildcard](/quest/m0/wildcard/README.md) - the longest-prefix rule, pool spread, and reply identity Selection builds on +- [Wildcard](/quest/m0/wildcard/README.md) - the longest-prefix rule and pool spread Selection builds on - [P2P](/quest/m2/p2p/README.md) - browser and native peers that become routing nodes over this layer -- [Remove `--hop`](/quest/m1/hop-removal.md) - redundant publishers share an explicit `@` +- [Remove `--hop`](/quest/m0/broadcast-epoch/hop-removal.md) - redundant publishers share an explicit `@` - [One route cost](/quest/m1/route-cost.md) - Warm and Cold collapse to one static cost -- [Same-hop importers](/quest/m1/hop-aligned-import.md) - the importer half of a redundant pair; `--hop` removal re-keys it to a shared epoch -- [Broadcast epochs](/quest/m1/broadcast-epoch/README.md) - a redundant pair shares one epoch +- [Same-epoch importers](/quest/m1/hop-aligned-import.md) - the importer half of a redundant pair under one explicit epoch +- [Broadcast epochs](/quest/m0/broadcast-epoch/README.md) - a redundant pair shares one epoch - [Cross-relay delivery under bursts](/quest/m1/cross-relay-bursts.md) - its #4349 report shows closed broadcasts announced for up to 229 s - [Routing cost domains](/quest/m3/routing-cost-domains.md) - policy and aggregation at boundaries between operators diff --git a/quest/m1/cluster-routing/multi-cdn.md b/quest/m1/cluster-routing/multi-cdn.md index f073b061ec..498f96a843 100644 --- a/quest/m1/cluster-routing/multi-cdn.md +++ b/quest/m1/cluster-routing/multi-cdn.md @@ -4,9 +4,9 @@ A client or publisher holds sessions to several CDNs at once, ranked by its own preference: moq.pro as primary, Cloudflare as secondary. A subscription -goes over the most preferred link that has a route and moves to the next one -when that route goes away; a publisher pushes the same epoch to every CDN so -the move splices instead of restarting. The secondary needs nothing from us: +goes over the most preferred link that has a route and resumes on the next +one when that route goes away; a publisher pushes the same path to every CDN, +so the move resumes instead of restarting. The secondary needs nothing from us: a moq-transport relay that speaks no ROUTE is just a link. ## Plan @@ -23,20 +23,19 @@ Decided 2026-10-01 (moq-dev/moq#4694): preference input and the endpoint side. - A session that speaks no ROUTE (moq-transport, older lite) is a link whose announces resolve to a session-scoped origin at that link's cost. -- Identity: two CDNs never share node ids, so failover across them splices - only for an epoch-qualified path; a bare path is a discontinuity. The - publisher side is the redundant pair of - [Selection](/quest/m1/cluster-routing/selection.md), from one process. +- Identity: two CDNs never share node ids, but any same-path route resumes, + so failover across them needs only the same path. The publisher side is + the redundant pair of [Selection](/quest/m1/cluster-routing/selection.md), + from one process. - API: the Rust client config and `js/net` accept several upstream URLs, each with its preference (and cost), feeding one shared origin. Mirror the shape in the bindings that expose connect config. Pick the smallest shape that covers both publish and subscribe. Test with mocked time: a subscriber linked to a lite relay (preferred) and a -moq-transport relay (secondary), with a publisher pushing one epoch to both; -losing the preferred relay moves the subscription at a group boundary with -no timestamp rewind, and its return moves it back. A bare path ends and -resubscribes instead. +moq-transport relay (secondary), with a publisher pushing one path to both; +losing the preferred relay resumes the subscription on the secondary with no +timestamp rewind, and its return moves it back. Docs: a multi-CDN section in the routing concept page and the client connect docs. diff --git a/quest/m1/cluster-routing/route-trust.md b/quest/m1/cluster-routing/route-trust.md index 592e6bd8fe..07fbdfba3c 100644 --- a/quest/m1/cluster-routing/route-trust.md +++ b/quest/m1/cluster-routing/route-trust.md @@ -27,8 +27,7 @@ Decided 2026-10-01: Test: a client advertising a cheap route to a cluster node does not move any other session's subscription; two uplinks of one mesh holding grants for the -same drone node fail over between each other at a group boundary instead of -ending the subscription. +same drone node advertise one origin node rather than two unrelated ones. Public API: the grant's node scope (`moq-token`, `js/token`, and the token CLI docs). Wire: none beyond the grant. diff --git a/quest/m1/cluster-routing/routes.md b/quest/m1/cluster-routing/routes.md index 511beded23..6b5d57fe86 100644 --- a/quest/m1/cluster-routing/routes.md +++ b/quest/m1/cluster-routing/routes.md @@ -53,8 +53,8 @@ Decided 2026-10-01 (see the [line's decisions](/quest/m1/cluster-routing/README. - The node id is global and opaque: a relay's `cluster.id` or a random id, and an app's handshake id. It is needed so routes from two neighbours to one origin are recognized as one, which carries loop freedom, - deduplication, the reply's serving node, and P2P dialing (an app maps an - announce's node to a roster peer). + deduplication, and P2P dialing (an app maps an announce's node to a roster + peer). - Loop freedom is Babel's feasibility condition (RFC 8966) keyed by node: accept a route if its seqno is newer, or equal with a metric below the feasibility distance. Only the origin advances its seqno. Retraction is an @@ -73,8 +73,8 @@ Decided 2026-10-01 (see the [line's decisions](/quest/m1/cluster-routing/README. identity across sessions is [Route trust](/quest/m1/cluster-routing/route-trust.md)). A relay advertising routes to a client sends them as usual; node ids reveal nothing about the backbone. -- Every hop re-selects; SUBSCRIBE names no origin. The reply names the - serving node, which is the identity Selection's splice rule uses. +- Every hop re-selects; SUBSCRIBE names no origin, and the reply names none + either: any route announcing a path resumes it. - Mixed versions: a lite-06 peer keeps today's path vector, translated at the relay that speaks both, for the rollout window only. diff --git a/quest/m1/cluster-routing/selection.md b/quest/m1/cluster-routing/selection.md index 2e85ca6635..42d5cc4497 100644 --- a/quest/m1/cluster-routing/selection.md +++ b/quest/m1/cluster-routing/selection.md @@ -6,9 +6,8 @@ A broadcast under overlapping announcements routes to one origin chosen deterministically, and a relay spreads paths over equally ranked next hops (an edge over its region's cores) so every relay sends a given path the same way. -Origins that announce the same epoch-qualified concrete path are one source, -so a subscriber moves between them at a group boundary when the incumbent -ends or becomes unreachable. +Origins that announce the same concrete path are one source, so a subscriber +resumes on another when the incumbent ends or becomes unreachable. ## Plan @@ -17,16 +16,15 @@ Decided: - The longest covering prefix ranks first, per [Wildcard](/quest/m0/wildcard/README.md), and origin choice is per broadcast, not per announced prefix. -- An epoch-qualified concrete path (`foo/@`) is a source's identity: - every origin announcing it is interchangeable, which is how a redundant - pair works without `--hop`. The first relay fails over between them at a - group boundary. A path with no epoch, or one a claim produces, keeps - Wildcard's per-origin identity (the origin SUBSCRIBE_OK names), since two - workers' groups differ. That includes a claim worker's derived output once - it is announced concretely: it mirrors the input's epoch - (`.pro/transcode//foo.hang/@e`), so after a double claim two workers - announce the same epoch-qualified path and must not pool. This extends - Wildcard's resume rule to concrete same-epoch origins. +- A concrete path, with or without an epoch, is a source's identity: every + origin announcing it is interchangeable, which is how a redundant pair + works under one explicit epoch. There is no per-origin identity, for claim + output or for paths without an epoch, and the reply names no origin + (decided 2026-10-03: #4741 makes a path one broadcast whoever serves it). + Failover resumes from the first frame the subscriber lacks, mid-group, + except where a publisher starts at a group boundary, as claim workers do + per [Wildcard](/quest/m0/wildcard/README.md)'s double-claim rule. So a + relay never needs to tell claim output from a redundant pair. Candidate mechanics: @@ -41,27 +39,17 @@ Candidate mechanics: candidates are the origin nodes announcing the path, ranked by longest prefix, then the link's preference ([Multi-CDN endpoints](/quest/m1/cluster-routing/multi-cdn.md)), then route - metric to the node, then HRW, and the reply's Origin is - the serving node id. Write the ranking so that change swaps its inputs, not - its shape. + metric to the node, then HRW. Write the ranking so that change swaps its + inputs, not its shape. - A refusal follows Wildcard's refusal rule: every refusal is terminal, and an origin sheds load by withdrawing or re-pricing its route instead. -- The serving origin's identity rides the Origin field of the reply, per - Wildcard's resume rule. - -Open: - -- How a relay tells claim output from a redundant pair. One candidate: a - concrete path an origin announces under its own claim keeps per-origin - identity, since a redundant publisher claims nothing. Wire: none expected; if one is needed it goes in the current wip lite version with the draft. Tests cover an HRW split across an equal-cost -pool, refusal and reselection, a same-epoch pair failing over mid-track -with no timestamp rewind, and a concrete double claim whose loser's -subscribers end and resubscribe rather than splice. +pool, a terminal refusal, and a same-path pair failing over mid-group with +no timestamp rewind. ## Required - [Upstream links](/quest/m1/cluster-routing/transit.md) - the edges and cores this spreads over -- [Wildcard](/quest/m0/wildcard/README.md) - the longest-prefix rule, pool spread, and reply identity this builds on +- [Wildcard](/quest/m0/wildcard/README.md) - the longest-prefix rule and pool spread this builds on diff --git a/quest/m1/e2ee/README.md b/quest/m1/e2ee/README.md index 4119db3e4c..69c11db08a 100644 --- a/quest/m1/e2ee/README.md +++ b/quest/m1/e2ee/README.md @@ -20,7 +20,7 @@ The contract is [draft-lcurley-moq-e2ee](/drafts/draft-lcurley-moq-e2ee.md), pro ### Epoch and identity - Every publisher instance mints an epoch, a UUIDv7 in lowercase text, and publishes at `/@`, where `` derives from the credential and the semantic broadcast name without the epoch. The epoch is an input to every HKDF derivation, so a restart, takeover, or explicit group sequence cannot repeat a nonce under a key: nothing is persisted across instances and no generation counter is redistributed. The epoch is the shared [`Epoch`](/doc/concept/moq-lite.md#publisher-epochs) primitive. Subscribers discover instances under the `/` prefix and take the greatest epoch, which sorts newest; a known full path carries its epoch in the last segment. -- The epoch is untrusted and unauthenticated. A wrong epoch fails authentication and a withheld one denies service; neither can make a nonce repeat, because only the publisher instance chooses what it encrypts under. This is the same trust a cache needs to serve the right instance. Plaintext broadcasts adopt the same epoch by default through [broadcast epochs](/quest/m1/broadcast-epoch/README.md). +- The epoch is untrusted and unauthenticated. A wrong epoch fails authentication and a withheld one denies service; neither can make a nonce repeat, because only the publisher instance chooses what it encrypts under. This is the same trust a cache needs to serve the right instance. Plaintext broadcasts adopt the same epoch by default through [broadcast epochs](/quest/m0/broadcast-epoch/README.md). - Nothing in the path says the bytes are encrypted. The format after decryption (`meeting.hang`) is inside the opaque name; a plaintext player, exporter, or matcher that opens a protected broadcast finds no catalog it can read and fails with its usual typed refusal, the same as for any format it does not support. - One 32-byte secret authorizes the whole broadcast; HKDF-SHA-256 derives separate AES-128-GCM keys for each physical track and for grouped-frame versus datagram domains. A grouped frame uses the 96-bit nonce `uint64_be(group) || uint32_be(frame)`; a datagram uses its sequence with frame zero under the datagram domain. Empty AAD: every immutable end-to-end field is in the HKDF info or the nonce. - Within an instance, group and datagram sequences are allocated monotonically and frames are numbered in write order, which is the whole reuse rule. There is no ciphertext retention or retransmission API: relays forward bytes unchanged, and an application that needs to resend encrypts nothing twice because it never gets the same identity twice. diff --git a/quest/m1/hop-aligned-import.md b/quest/m1/hop-aligned-import.md index 045f9a12c2..29fb464e17 100644 --- a/quest/m1/hop-aligned-import.md +++ b/quest/m1/hop-aligned-import.md @@ -1,25 +1,23 @@ -# [L] Same-hop importers publish identical tracks +# [L] Same-epoch importers publish identical tracks ## Goal -Two importers sharing a `--hop` and fed the same encoded stream publish -identical tracks, so a relay can fail over between them. Today two -`moq import ts` fed one forked stream break that contract twice: a standby -refuses every track until it has parsed its PMT, which aborts every -subscriber the moment it connects (#4352), and each numbers its groups from -its own counter, so after a failover `export ts` sees a timestamp rewind and -exits (#4354). +Two importers publishing one path, such as a redundant pair sharing an +explicit `--epoch`, and fed the same encoded stream publish identical tracks, +so a relay can fail over between them. Today two `moq import ts` fed one +forked stream break that contract twice: a standby refuses every track until +it has parsed its PMT, which aborts every subscriber the moment it connects +(#4352), and each numbers its groups from its own counter, so after a +failover `export ts` sees a timestamp rewind and exits (#4354). ## Plan -Decided: same-hop publishers MUST publish the same broadcasts and tracks. -Keep `--hop` for now: the users' bugs are on one relay today. Cluster -routing replaces it with a shared explicit epoch (decided -2026-09-30), and its [`--hop` removal](/quest/m1/hop-removal.md) -re-keys this quest's docs and tests. The importer work holds under either -key. Make every container importer (ts, fmp4, flv, mkv, and the -SRT, RTMP, and HLS gateways that reuse them) meet the contract when fed one -encoded stream. Capture is out: two encoders never align. +Decided: every publisher of one path MUST publish the same broadcasts and +tracks, since any route covering a path resumes its subscriptions. A redundant +pair is keyed by a shared explicit epoch (decided 2026-10-03: `--hop` no +longer exists to key it). Make every container importer (ts, fmp4, flv, mkv, +and the SRT, RTMP, and HLS gateways that reuse them) meet the contract when +fed one encoded stream. Capture is out: two encoders never align. - A group's sequence derives from its keyframe's media timestamp (PTS in TS), not a per-process counter. Decide how both processes agree across a @@ -29,13 +27,17 @@ encoded stream. Capture is out: two encoders never align. stream's own timestamps and refuse a rewind, so nothing shifts them. - An importer announces only once it knows its tracks, so it never refuses a track the incumbent serves. -- Docs (`doc/bin/cli.md` "Redundant publishers", the `--hop` doc comment) +- Docs (`doc/bin/cli.md` "Redundant publishers", the `--epoch` doc comment) narrow the contract to one encoded stream fed to each publisher. Tests: per importer, two instances started at different offsets into the same input produce the same group sequences and timestamps. End to end: the -issues' 1+1 setup (one relay, two same-hop `import ts`, two `export ts`) -survives the standby joining and the incumbent stopping. +issues' 1+1 setup (one relay, two `import ts` sharing one `--epoch`, two +`export ts`) survives the standby joining and the incumbent stopping. + +## Required + +- [Remove `--hop`](/quest/m0/broadcast-epoch/hop-removal.md) - the shared `--epoch` this keys a redundant pair on ## Closes @@ -44,5 +46,4 @@ survives the standby joining and the incumbent stopping. ## Related -- [Remove `--hop`](/quest/m1/hop-removal.md) - a redundant pair shares an explicit epoch instead, and this quest's docs and tests are re-keyed to it -- [Broadcast epochs](/quest/m1/broadcast-epoch/README.md) - a redundant pair shares one epoch +- [Broadcast epochs](/quest/m0/broadcast-epoch/README.md) - a redundant pair shares one epoch diff --git a/quest/m1/hop-removal.md b/quest/m1/hop-removal.md deleted file mode 100644 index 0159d26f06..0000000000 --- a/quest/m1/hop-removal.md +++ /dev/null @@ -1,43 +0,0 @@ -# [M] Redundant publishers share an epoch, not a hop - -## Goal - -Redundant publishers share an explicit `@` instead of a `--hop`, and -the publisher-facing Hop ID is gone: `moq`'s `--hop` / `MOQ_HOP` and the -deprecated `--origin` spelling, and the Hop setup parameter a publisher -sends. An epoch is strictly better, since a Hop ID is per session: one -connection cannot publish several broadcasts with different identities. - -## Plan - -- The pool semantics already live in - [Selection](/quest/m1/cluster-routing/selection.md): origins announcing the - same epoch-qualified concrete path are one source. This quest deletes what - that makes dead, including `Pin::Publisher(Hop)` in - `rs/moq-net/src/model/front.rs` and `RouteEntry::qualifies`. -- A redundant pair passes one explicit epoch; the broadcast-publish path keeps - an epoch a caller supplies ([Broadcast epochs](/quest/m1/broadcast-epoch/README.md)). - Give `moq` a flag for it if Broadcast epochs has not. -- Relays keep `cluster.id` as their identity; `--hop` fills it today - (`rs/moq-cli/src/args.rs`), so split that coupling. Relays and P2P peers - keep the Hop setup parameter: it becomes the node id of - [Routes and announces](/quest/m1/cluster-routing/routes.md). Only the - publisher-facing use goes. -- Re-key [Same-hop importers](/quest/m1/hop-aligned-import.md)'s docs and 1+1 - tests to a shared epoch. -- Docs: rewrite "Redundant publishers" in `doc/bin/cli.md` and - `doc/concept/use-case/contribution.md`, then search the repo for `--hop`, - `MOQ_HOP`, and `--cluster-id` examples, including demo recipes. - -Open: whether anything declares an incumbent dead faster than the QUIC -idle timeout (a failover service that retracts it, or active-active delivery -to the relay). Neither is required to land this. - -Public API and wire: removes a CLI flag and the publisher's Hop setup -parameter; lands outside the cluster routing line. - -## Required - -- [Selection](/quest/m1/cluster-routing/selection.md) - owns the same-epoch pool this relies on -- [Broadcast epochs](/quest/m1/broadcast-epoch/README.md) - a restart is a new path, not a splice -- [Same-hop importers](/quest/m1/hop-aligned-import.md) - the importer fixes this re-keys diff --git a/quest/m1/ietf-cluster-off.md b/quest/m1/ietf-cluster-off.md index 3aeda66a88..3f6cb65e25 100644 --- a/quest/m1/ietf-cluster-off.md +++ b/quest/m1/ietf-cluster-off.md @@ -10,21 +10,17 @@ fully supported. ## Plan -Why now: the Wildcard line spreads a pool's paths behind one route, and names -the serving member only in lite-07's reply Origin. The cluster extension has -no such field, so a moq-transport downstream relay pins failover to the -advertisement's first Hop ID, which labels the pool rather than the member, -and can splice one member's objects onto another's. A peer that never learns -Hop IDs cannot make that mistake. Decided in the 2026-09-30 wildcard audit, -matching the topology decision that cluster links are lite-only. +Why: cluster links are moq-lite only (the 2026-09-30 topology decision), so +a moq-transport peer stays a plain client until +[moq-transport cluster peers](/quest/m2/ietf-cluster-peers.md) extends the +draft. - Stop offering and accepting the cluster Setup Options in Rust (`rs/moq-net/src/ietf/cluster.rs`, `parameters.rs`, `publish_namespace.rs`) and JS (`js/net/src/ietf/cluster.ts` and its callers). A peer that offers them is served as if it had not. -- Delete what becomes dead, in both languages. The draft stays: - [moq-transport cluster peers](/quest/m2/ietf-cluster-peers.md) extends it - and brings the code back. +- Delete what becomes dead, in both languages. The draft stays, for + moq-transport cluster peers to extend. - Update `doc/bin/relay/cluster.md` and `doc/concept` wherever they say moq-transport relays can join a cluster. - Test: a moq-transport 17+ peer that offers the extension gets a session @@ -37,4 +33,3 @@ negotiating an optional extension. ## Related - [Upstream links](/quest/m1/cluster-routing/transit.md) - cluster links are lite-only -- [Wildcard](/quest/m0/wildcard/README.md) - the pool spread that makes a Hop ID label unsafe to splice on diff --git a/quest/m1/js-group-handover.md b/quest/m1/js-group-handover.md index 42cc600ba1..8787785850 100644 --- a/quest/m1/js-group-handover.md +++ b/quest/m1/js-group-handover.md @@ -1,10 +1,10 @@ -# [L] JS group-boundary handover +# [L] JS track handover ## Goal A `js/net` track subscription survives its broadcast's route swapping to another provider, such as a relay migration after a GOAWAY, and resumes from -the new provider at the first group it has not delivered. A viewer at the live +the new provider at the first frame it has not delivered. A viewer at the live edge with no latency budget never loses a group across the swap, and never has to notice the swap to keep reading. @@ -21,20 +21,22 @@ old relay drops its upstream pull, the new relay subscribes upstream from scratch at the live edge. A group boundary that lands inside that window loses the group in flight: about 1 run in 5 at 100 ms groups. -Rust already solves this. `moq-net`'s origin fronts are spliced broadcasts, and -`model/resume.rs` resumes each track from the replacement at the first missing -group, capping the old segment so it ends at the boundary on its own. Mirror -that shape and naming in JS rather than inventing a second model. Things to +Rust solves this with #4741's single-writer pump (`model/pump.rs`): each track a +front serves is one producer, a route change subscribes the new route from +the first frame the logical track lacks (mid-group), an open group is +continued in place, duplicates are dropped by frame index, and the old route +is cancelled once the new one feeds the track. Mirror that shape and naming +in JS rather than inventing a second model. Things to settle along the way: - Whether the request's `active` broadcast stays the same object across a swap, with its tracks re-sourced underneath. That is the Rust behavior and the simplest for players. It is also a behavior change for code that watches `active` to resubscribe. -- The resumed subscription names where it left off (the `groups` floor) so the - new provider serves the missing group from its cache or upstream instead of - starting at its own live edge. -- Failover compatibility: Rust refuses to splice a source whose track +- The resumed subscription names where it left off (group and frame) so the + new provider serves the rest from its cache or upstream instead of starting + at its own live edge. +- Failover compatibility: Rust refuses to resume onto a source whose track properties differ (timescale, retention, priority, order). Match it. - `js/watch` and `js/hang` consumers that re-subscribe on `active` changes. Check whether they still need to. diff --git a/quest/m1/route-wakes.md b/quest/m1/route-wakes.md index e90a09936d..48f04bedb8 100644 --- a/quest/m1/route-wakes.md +++ b/quest/m1/route-wakes.md @@ -4,13 +4,13 @@ A route change under a prefix (join, withdraw, re-price, restale) makes the origin driver act only on the fronts that could change their selection: -fronts still waiting for a source, fronts served by the changed route, and -publisher-pinned fronts on its first hop. Today it wakes every front below -the prefix, so one equal-cost pool member joining or leaving a prefix that -serves 10k paths costs the driver 100 to 600 ms of single-threaded work -while no front switches (`origin/pool_churn`, #4607). A root claim, such as -an archive's catch-all, makes every front on the relay pay for every change -to it. Done when `origin/pool_churn` is flat in served paths. +fronts still waiting for a source, and fronts the changed route serves or is +requesting through. Today it wakes every front below the prefix, so one +equal-cost pool member joining or leaving a prefix that serves 10k paths +costs the driver 100 to 600 ms of single-threaded work while no front +switches (`origin/pool_churn`, #4607). A root claim, such as an archive's +catch-all, makes every front on the relay pay for every change to it. Done +when `origin/pool_churn` is flat in served paths. ## Plan @@ -20,23 +20,11 @@ Facts from the wildcard line (`rs/moq-net/src/model/origin.rs` there): `routes.poke_below(prefix)`. That bumps a counter-only `Watch` on every path below the prefix, so a woken front cannot tell what changed. Each re-runs `select` under the table read lock (`retain_routes`, then `best_route`, - which hashes every qualifying pool member unless a Stay front's route - still serves), then the driver polls every track and rescans deadlines. + which hashes every pool member unless a serving front's route still + serves), then the driver polls every track and rescans deadlines. - `route_order` is rendezvous-style: FNV over the path and hop ids, lowest wins. A join moves only paths the newcomer wins, a leave only paths the leaver was winning. -- A front's `Pin` limits which routes it can take. `Stay` (an origin-named - front while serving) keeps its route while it serves; `Route` qualifies - only its own route; `Publisher` only routes whose first hop matches; - `Local` only local routes; `Any` (identity not yet known, or origin-named - and not serving) every route. Pool fronts in `origin/pool_churn` never - get an origin-naming reply, so they are `Publisher`, each on a different - member's hop. So on a join the fronts that can act are the `Any` ones, - `Publisher` ones on the new route's first hop, and `Local` ones for a - local route. On a leave, the fronts served by or requesting through that - route, plus those same pin sets: a deeper prefix appearing shadows - shallower routes for them, and one leaving can unshadow a broader route - for a waiter it never served. Decisions: @@ -44,41 +32,33 @@ Decisions: the benchmark found it, but the wake is per prefix regardless of cost. (2026-09-30) - An index, not a payload filter on the watch: each route records the - fronts it serves, and each prefix node records only its non-Stay watches - (waiting fronts, publisher- and local-pinned fronts, `routed_broadcast` - waiters). A change wakes the ones it qualifies for (next decision) plus - the changed route's own fronts. A - payload filter would still walk every watch under the prefix, which stays - linear in paths. (2026-09-30) -- The prefix node keys its watches by what they qualify for: `Any` and - `routed_broadcast` waiters in one set, `Publisher` by hop, `Local` in its - own set. `Route` fronts hang off their route only. A front moves its - entries when it selects, and drops them when it closes. Any change to a - route wakes the `Any` set, its first hop's `Publisher` set, the `Local` - set if local, and the fronts it serves or is requesting for, so an - unrelated publisher joining or leaving wakes no serving pool front. The - pin sets wake on a loss too: withdrawing a deeper claim can unshadow a - broader route for a parked waiter it never served. A withdrawal takes - the served set before dropping the record. Fronts sit at paths below the changed prefix, so the lookup - walks descendants but skips subtrees whose matching set is empty, keeping - cost in woken fronts rather than served paths. (2026-09-30) -- A same-publisher join still wakes that hop's whole `Publisher` set, even - where the new route cannot win. Narrowing it to the paths the route wins - is left out: the `Publisher` pin goes with `--hop` removal in the cluster - routing line. (2026-09-30) + fronts it serves or is requesting through, and each prefix node records + only its waiting fronts and `routed_broadcast` waiters. A payload filter + would still walk every watch under the prefix, which stays linear in paths. + (2026-09-30) +- No pin sets (decided 2026-10-03: #4741 deletes `Pin`, so any covering route + qualifies for a waiting front). Any change to a route wakes the waiting set + below it and the fronts it serves or is requesting through. Waiters wake on + a loss too: withdrawing a deeper claim can unshadow a broader route for a + parked waiter it never served. A withdrawal takes the served set before + dropping the record. A front moves its entries when it selects and drops + them when it closes. The lookup walks descendants but skips subtrees whose + waiting set is empty, keeping cost in woken fronts rather than served + paths. +- A serving front stays on its route while it serves, per the wildcard line's + Stay decision, so a join or re-price never wakes it; it moves only on + failover. (2026-09-30) - Builds on shared-fronts' keying, so the index hangs off the final front identity. `origin-front-parks.md` replaces the `routed_broadcast` retry loop, one of the watch consumers here; whichever lands second adapts it. (2026-09-30) -- Stay fronts never need a join or re-price: the edge/core tiers decision - (2026-09-30) dropped route upgrades, so a serving front moves only on - failover. Verification: `origin/pool_churn` flat in served paths at every pool width it already sweeps, and a unit test that counts `select` calls per route -change and asserts only affected fronts re-select, with zero for an -unrelated publisher's join and leave, and a parked waiter retrying when a -deeper advertise-only claim over a served root is withdrawn. Keep `pool_resolve` unchanged. +change and asserts only affected fronts re-select, with zero serving fronts +for an unrelated route's join and leave, and a parked waiter retrying when a +deeper advertise-only claim over a served root is withdrawn. Keep +`pool_resolve` unchanged. Public API: none. Wire: none. diff --git a/quest/m1/transcode-group-start.md b/quest/m1/transcode-group-start.md new file mode 100644 index 0000000000..021248806d --- /dev/null +++ b/quest/m1/transcode-group-start.md @@ -0,0 +1,27 @@ +# [S] Transcoders start at group boundaries + +## Goal + +Two moq-transcode instances serving one derived path are interchangeable at +group boundaries, so a relay moving a subscription between them never splices +two encoders' output mid-group. moq-transcode (`rs/moq-transcode`, and +`moq transcode` in moq-cli) resolves a subscription that starts at (N, M>0) +to (N+1, 0), and refuses a FETCH that starts mid-group. Output groups mirror +the source's sequence numbers, and the catalog derives only from the input and +config. + +## Plan + +- The lite draft already lets a publisher skip a group it cannot serve from + the requested frame and resolve to a later one, so no wire change. +- Output groups already mirror the source's sequences (`rung.rs`). Keep that, + and check that nothing per-process (start time, random ids, encoder + defaults) leaks into the catalog. +- Tests: a subscription and a FETCH that start mid-group, and two instances + fed the same source that publish the same catalog and group sequences. + +Public API: none. Wire: none. + +## Related + +- [Wildcard](/quest/m0/wildcard/README.md) - a double claim's two workers at one path are settled by interchangeable output diff --git a/quest/m1/transport-upgrade/README.md b/quest/m1/transport-upgrade/README.md index 88ccc2afb1..3f5e03c10a 100644 --- a/quest/m1/transport-upgrade/README.md +++ b/quest/m1/transport-upgrade/README.md @@ -11,8 +11,7 @@ life on a reliable transport that cannot shed load under congestion, and the "WebSocket won" memo removes QUIC's head start from every later connect in that process or page. The end state: when WebSocket wins, the QUIC dial keeps going; if it lands, the reconnect loop attaches the QUIC session, hands the -routes over at a group boundary, sends a GOAWAY on the WebSocket session, and -drains it. When QUIC wins, WebSocket is closed immediately, as today. +routes over, sends a GOAWAY on the WebSocket session, and drains it. When QUIC wins, WebSocket is closed immediately, as today. Non-goals: migrating between IPv6 and IPv4. That race is inside the QUIC dial, before any MoQ session exists, the loser has no claim to being the better path @@ -31,11 +30,12 @@ keeps the old session serving until it closes or overstays the handover cap, reports `Status::Migrating`, and `moq_net::Session::drain()` sends a GOAWAY on every version (a client may send one with an empty URI; only a redirect URI is forbidden to a moq-transport client). The origin's multi-route front prefers -the newest of two equal routes and `resume` splices each track at a group -boundary, capping the old segment so the old session's subscription ends at the -boundary on its own. The JavaScript GOAWAY handover landed with the drain line, but it does not -splice tracks yet; the JS half requires -[JS group-boundary handover](/quest/m1/js-group-handover.md) for that. +the newest of two equal routes, and #4741's pump (`model/pump.rs`) resumes each +track from the new route at the first frame the subscriber lacks, cancelling +the old session's subscription once the new one feeds it. The JavaScript +GOAWAY handover landed with the drain line, but it does not resume tracks yet; +the JS half requires [JS track handover](/quest/m1/js-group-handover.md) for +that. Shared decisions: @@ -47,7 +47,7 @@ Shared decisions: narrowly beats QUIC would open two connections on every reconnect. - The old session gets `Goaway::new()` with the configured handover cap before it enters draining. The relay refuses new requests on it from then on; the - splice ends its subscriptions at the boundary. + pump cancels its subscriptions once the new session feeds them. - One-shot `connect()` returns one session and never upgrades; every `Connection` upgrades, reconnecting or not. - Publishing over the old session is announced again over the new one; the diff --git a/quest/m1/transport-upgrade/js.md b/quest/m1/transport-upgrade/js.md index eefefe9002..16c399515f 100644 --- a/quest/m1/transport-upgrade/js.md +++ b/quest/m1/transport-upgrade/js.md @@ -12,7 +12,7 @@ nothing changes. ## Plan Lands in `js/net`, reusing the GOAWAY handover the drain line shipped once -[JS group-boundary handover](/quest/m1/js-group-handover.md) splices tracks +[JS track handover](/quest/m1/js-group-handover.md) resumes tracks across it: dial the replacement while the old session keeps serving, swap the origin wiring once it is established, leave the old session to close on its own or at the handover cap. See the [questline](/quest/m1/transport-upgrade/README.md) for the shared decisions. @@ -46,5 +46,5 @@ session to close on its own or at the handover cap. See the ## Required -- [JS group-boundary handover](/quest/m1/js-group-handover.md) - tracks carry across the handover this upgrade reuses without a dropped group +- [JS track handover](/quest/m1/js-group-handover.md) - tracks carry across the handover this upgrade reuses without a dropped group - [JS GOAWAY requests](/quest/m1/js-goaway-requests.md) - no new request opens on a session that is going away diff --git a/quest/m1/transport-upgrade/rust.md b/quest/m1/transport-upgrade/rust.md index e0d05e29b5..d920c9b90f 100644 --- a/quest/m1/transport-upgrade/rust.md +++ b/quest/m1/transport-upgrade/rust.md @@ -3,10 +3,10 @@ ## Goal A `moq_tokio::Connection` that came up over the WebSocket fallback migrates to -QUIC once the QUIC dial completes: live tracks hand over at a group boundary, -the WebSocket session receives a GOAWAY and drains within the handover cap, -`Status::Migrating` is observable across the swap, and the "WebSocket won" -memo forgets the URL. When QUIC wins the race nothing changes. +QUIC once the QUIC dial completes: live tracks resume on QUIC from the first +frame they lack, the WebSocket session receives a GOAWAY and drains within the +handover cap, `Status::Migrating` is observable across the swap, and the +"WebSocket won" memo forgets the URL. When QUIC wins the race nothing changes. ## Plan diff --git a/quest/m1/untimed-model.md b/quest/m1/untimed-model.md index 49eb30db46..bb043c6831 100644 --- a/quest/m1/untimed-model.md +++ b/quest/m1/untimed-model.md @@ -21,7 +21,7 @@ timestamp](/quest/m1/lite-untimed.md)), and JS Decided (2026-10-01, maintainer): faithful absence on both the publish and the subscribe side. Today every receiver stamps local arrival time, as the timestamp draft mandates, and on lite-05 and later a relay forwards those -stamps as if they were real. A failover to another first hop then changes +stamps as if they were real. Any route failover then changes them, so the timeline jumps, and the "one clock per broadcast" property breaks. Rejected: first-hop arrival (the failover jump stays), 0 as a sentinel (collides with a real pts of 0), and `max_age` on max(wall, pts) @@ -59,7 +59,7 @@ Things to look out for: `Option` for media time only, so an open group and a group of untimed frames don't share one `None`. - The live edge skips unstamped groups. Reach and successor search - (`track.rs`, `resume.rs`) deliberately stop at the immediate successor and + (`track.rs`) deliberately stop at the immediate successor and leave the bound unknown while it is unstamped, because skipping ahead could expire content that is still valid. An untimed successor keeps that bound unknown for good, so the timed group before it is kept. Preserve diff --git a/quest/m2/README.md b/quest/m2/README.md index 04276c2562..5c01b22d71 100644 --- a/quest/m2/README.md +++ b/quest/m2/README.md @@ -115,7 +115,7 @@ waiting on an upstream release waits in [m4](/quest/m4/README.md). - [SEI separation study](/quest/m2/sei.md) - measure whether separating SEI saves enough, or has a metadata-only consumer, to justify a split - [Compressed tracks](/quest/m2/flate.md) - moq-ffi and every wrapper expose flate tracks through a `flate` namespace like `json` - [Announcement shapes](/quest/m2/announce-shapes.md) - moq-lite announcements and interests carry prefix, exact, suffix, or prefix+suffix shapes that survive relay hops, benchmarked over the announce table -- [moq-transport cluster peers](/quest/m2/ietf-cluster-peers.md) - an extended cluster draft lets moq-transport relays peer again, stitching failover on the reply's origin +- [moq-transport cluster peers](/quest/m2/ietf-cluster-peers.md) - an extended cluster draft lets moq-transport relays peer again, carrying the cluster route layer - [MSFTS convergence](/quest/m2/msfts-convergence.md) - the demultiplexed TS lane converges on MSFTS where the two still differ: program tables and the ES payload unit - [VAAPI encode and decode](/quest/m2/video-vaapi.md) - H.265 encode and decode, pre-generated bindings, and pooled resize surfaces, including the moq-dev/vaapi release that carries them - [Dead worklet config](/quest/m2/worklet-dead-config.md) - delete moq-boy's unused worklet plugin and hang's unused `?worker&url` typings diff --git a/quest/m2/catalog-tracks.md b/quest/m2/catalog-tracks.md index e3c039ea0e..f55df5fcb6 100644 --- a/quest/m2/catalog-tracks.md +++ b/quest/m2/catalog-tracks.md @@ -19,7 +19,7 @@ Audit publishers in Rust and JS for the properties that change mid-track today (codec/config bytes, resolution, audio layout, rendition metadata) and make each one either refuse the change or mint a new identity: a new track name through a [catalog alias](/quest/m1/catalog-track-alias.md) when the -catalog can keep both, or a new [broadcast epoch](/quest/m1/broadcast-epoch/README.md) +catalog can keep both, or a new [broadcast epoch](/quest/m0/broadcast-epoch/README.md) when the whole broadcast restarts. The catalog may still add and remove tracks; a removed name is never reused for different content. @@ -29,5 +29,5 @@ JS, and HLS/watch tests. ## Related - [Catalog track alias](/quest/m1/catalog-track-alias.md) - lets a catalog list a new track name for a changed rendition -- [Broadcast epochs](/quest/m1/broadcast-epoch/README.md) - a restart is a new epoch rather than a changed track +- [Broadcast epochs](/quest/m0/broadcast-epoch/README.md) - a restart is a new epoch rather than a changed track - [Archive](/quest/m1/archive/README.md) - storage and replay consume the identity contract diff --git a/quest/m2/ietf-cluster-peers.md b/quest/m2/ietf-cluster-peers.md index a0803b2505..8a378917be 100644 --- a/quest/m2/ietf-cluster-peers.md +++ b/quest/m2/ietf-cluster-peers.md @@ -3,28 +3,22 @@ ## Goal A moq-transport relay can join a cluster as a peer again, through an -extended `draft-lcurley-moq-cluster`, and never splices one pool member's -content onto another's. +extended `draft-lcurley-moq-cluster`, with the loop safety a lite cluster +link has. ## Plan -Replaces the wildcard line's "Cluster origin reply" quest. Until this lands, -moq-transport peers are plain clients +Until this lands, moq-transport peers are plain clients ([moq-transport peers are plain clients](/quest/m1/ietf-cluster-off.md)). -The extension must carry what a lite cluster link carries by then: +The extension must carry what a lite cluster link carries by then: the route +layer of the [cluster routing line](/quest/m1/cluster-routing/README.md) +(per-node ROUTEs with seqno and metric, path-less announces, the down-only +bit) and its selection, since cluster links are lite-only there. -- The serving origin in the reply, mirroring lite-07's SUBSCRIBE_OK and - FETCH_OK Origin, so a downstream stitches failover on the member serving the - path rather than the advertisement's first Hop ID. The draft's "Several - Publishers of One Namespace" section changes with it. -- The route layer of the [cluster routing line](/quest/m1/cluster-routing/README.md) - (per-node ROUTEs with seqno and metric, path-less announces, the down-only - bit) and its selection, since cluster links are lite-only there. - -Test: two workers behind a pool relay behind a moq-transport downstream relay; -killing the serving worker ends the downstream subscription rather than -splicing the survivor's objects. +Test: a moq-transport relay peering through the extension discards a route +that loops back to it, and resumes a subscription on another route when the +serving one dies. Wire: extends `draft-lcurley-moq-cluster`, updated in the same PR. diff --git a/quest/m2/p2p/cost-scopes.md b/quest/m2/p2p/cost-scopes.md index c6223259a3..26765a8967 100644 --- a/quest/m2/p2p/cost-scopes.md +++ b/quest/m2/p2p/cost-scopes.md @@ -27,8 +27,8 @@ Decided 2026-10-01 (moq-dev/moq#4694): [Routes and announces](/quest/m1/cluster-routing/routes.md): a broadcast's ANNOUNCE names its origin node, and the roster maps that node to a peer the application may dial, so an app learns whom to dial from the announce. -- Switching between the peer and the relay splices for an epoch-qualified - path (same source), and is a discontinuity otherwise, per +- Switching between the peer and the relay resumes the subscription, since + every route announcing a path is one source, per [Selection](/quest/m1/cluster-routing/selection.md). Deliverables: the `Peers` and `moq-cli` cost knobs with their defaults, the diff --git a/quest/m2/quic-careful-resume.md b/quest/m2/quic-careful-resume.md index b009be4dcb..07bb8e5242 100644 --- a/quest/m2/quic-careful-resume.md +++ b/quest/m2/quic-careful-resume.md @@ -38,6 +38,6 @@ only when the jump never makes the first second worse than slow start. - [Transport upgrade](/quest/m1/transport-upgrade/README.md) - one of the reconnects this speeds up -- [JS group-boundary handover](/quest/m1/js-group-handover.md) - GOAWAY +- [JS track handover](/quest/m1/js-group-handover.md) - GOAWAY redials are the other - [noq#815](https://github.com/n0-computer/noq/issues/815) - the careful-resume proposal to n0 diff --git a/quest/m3/routing-cost-domains.md b/quest/m3/routing-cost-domains.md index 23c909b5bc..3fb3978141 100644 --- a/quest/m3/routing-cost-domains.md +++ b/quest/m3/routing-cost-domains.md @@ -57,7 +57,7 @@ Use these references to evaluate the design: The design must work through examples of two operators using different scales, multiple entrances to one domain, asymmetric charges, mixed-provider nodes in one domain, unknown or untrusted peers, and a route leaving and re-entering a -domain. Preserve publisher identity and loop safety across any metric rewrite. +domain. Preserve loop safety across any metric rewrite. State tradeoffs, migration and mixed-version behavior, and the limits of any convergence claim. diff --git a/quest/m3/rtsp-import.md b/quest/m3/rtsp-import.md index 90d151545d..9386d6494a 100644 --- a/quest/m3/rtsp-import.md +++ b/quest/m3/rtsp-import.md @@ -31,7 +31,7 @@ Decided 2026-10-01: than exiting for a supervisor to restart. This deliberately differs from Pronto's truck, which exits so a supervisor surfaces half-dead sessions. - Each camera session publishes a new broadcast under a fresh epoch, per - [Broadcast epochs](/quest/m1/broadcast-epoch/README.md), never spliced onto + [Broadcast epochs](/quest/m0/broadcast-epoch/README.md), never spliced onto the last one. A new session's RTP time restarts, so a reconnect finishes the old broadcast cleanly and builds a fresh catalog, tracks, and importers, starting on a keyframe. A timestamp jump inside one session (retina #64) @@ -59,5 +59,5 @@ Wire: none. ## Related -- [Broadcast epochs](/quest/m1/broadcast-epoch/README.md) - the epoch each camera session's broadcast publishes under +- [Broadcast epochs](/quest/m0/broadcast-epoch/README.md) - the epoch each camera session's broadcast publishes under - [moq.pro's Pronto truck](https://github.com/moq-dev/moq.pro/blob/main/pronto/truck/src/camera.rs) - the prior art this generalizes, and the first consumer