From a9e3240932653c3125a8c654365eb45070caa819 Mon Sep 17 00:00:00 2001 From: OneTooMany Date: Wed, 7 Oct 2026 18:35:47 +0200 Subject: [PATCH 1/8] quest: plan idle fronts and TRACK stream demand Adds two m0 quests from a transcode-pool report: a relay front nobody reads ends after the linger, so a drained claim stops serving paths it once served, and a lite-05+ subscribe no longer flaps the publisher's demand between TRACK and SUBSCRIBE. Narrows route-wakes' "a serving front follows the best route" to the routes #4942 lets a front resume across. Co-Authored-By: Claude Opus 5.5 --- quest/m0/README.md | 5 +++ quest/m0/idle-fronts.md | 72 +++++++++++++++++++++++++++++++++ quest/m0/track-stream-demand.md | 59 +++++++++++++++++++++++++++ quest/m1/route-wakes.md | 13 +++--- 4 files changed, 144 insertions(+), 5 deletions(-) create mode 100644 quest/m0/idle-fronts.md create mode 100644 quest/m0/track-stream-demand.md diff --git a/quest/m0/README.md b/quest/m0/README.md index 847b9ef556..3a6bbaffc3 100644 --- a/quest/m0/README.md +++ b/quest/m0/README.md @@ -25,6 +25,9 @@ Routing: Wildcard landed in #4403, so a service claims the prefix it could serve instead of enumerating broadcasts. 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. +Pools of claim workers (transcoders) also need the relay to forget a front +nobody reads, and demand that doesn't flap on a viewer's first subscribe +(both found 2026-10-07). Identity: the [broadcast epoch](/quest/m0/broadcast-epoch/README.md) line gates the next release (decided 2026-10-03: @@ -45,6 +48,8 @@ a published `@moq/watch` break. - [Draft-22 LOCATION_FILTER](/quest/m0/ietf-location-filter-22.md) - moqt-22 LOCATION_FILTER carries its type instead of a Length in Rust and JS, so a draft-22 peer reads our Next Object correctly - [Request caps](/quest/m0/request-caps.md) - lite message sizes, IETF request IDs, and per-session announces and subscriptions are bounded - [Prefix route fronts](/quest/m0/prefix-route-fronts.md) - a prefix route cannot be made to mint one front per requested path +- [Idle fronts](/quest/m0/idle-fronts.md) - a front nobody has read for the linger ends, so a drained claim stops serving paths it once served +- [TRACK stream demand](/quest/m0/track-stream-demand.md) - a lite-05+ subscribe shows the publisher one `used` edge, not a flap between TRACK and SUBSCRIBE - [qmux credit](/quest/m0/qmux-credit.md) - qmux returns connection credit for dropped and stopped streams and delivers its close frame, on both lines - [Broadcast epochs](/quest/m0/broadcast-epoch/README.md) - every first-party publisher that can restart mints a fresh route epoch, the newest wins a path, and only routes with the same epoch resume a subscription - [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 diff --git a/quest/m0/idle-fronts.md b/quest/m0/idle-fronts.md new file mode 100644 index 0000000000..0e9f0bd2ec --- /dev/null +++ b/quest/m0/idle-fronts.md @@ -0,0 +1,72 @@ +# [M] An unread front ends after its linger + +## Goal + +A relay ends an origin front once nothing has read it for `track::IDLE_LINGER`, +so the next request for that path resolves by current route cost. Today a +front lives until its route leaves. Two workers claim a prefix with +`origin::Producer::dynamic`, a viewer reads path P through a relay and leaves, +the serving worker closes its output and re-prices its claim to +`Cost::DRAIN`. Minutes later a new viewer on a new session still gets P from +the drained worker, while a fresh path under the same claim goes to the cheaper +one. Done when, after the linger, that viewer gets P from the cheapest claim, +and the session no longer holds per-path state for P. + +Non-goal: a worker that closes an output and serves the same path again +restarting at group 0. That reuses a name for different content, which is the +publisher's bug (see Plan). + +## Plan + +Facts from `main` (`rs/moq-net/src/model/origin.rs`, `front.rs`, +`lite/subscriber.rs`): + +- A front resolved without an epoch stays on its first route (`pick`, #4942), + and `request()` joins any live front whose epoch matches the best route's, + `None == None` here, without asking whether its route is still the best. +- Under a claim, the session answers a request with a placeholder source it + creates on the spot (`poll_serve`), kept in the announced route's sources + until the claim is withdrawn or the session closes. moq-lite has no message + for "the broadcast served under this prefix closed", so the relay never sees + `SourceClosed` when the worker closes its output. In-process (no relay) the + front's source is the worker's broadcast, `SourceClosed` fires, and the next + request re-resolves; that's why the bug needs a relay. +- Nothing ends a front for having no readers. Its unread tracks park and are + forgotten after `IDLE_LINGER`, but the front and its driver task stay. +- Each distinct path requested under a claim therefore leaves a placeholder + source and serve state on the session, and a front on the relay, for as + long as the claim stands. + +Decisions (2026-10-07): + +- End every front, epoch or not, once all its tracks are forgotten and no + consumer holds its broadcast. Ending only after the linger keeps the cache + window for a returning viewer, needs no wire change, and doesn't conflict + with the draft's Resume text, which pins subscriptions, not idle fronts. +- A front still waiting for coverage + ([Front parking](/quest/m1/origin-front-parks.md)) is not idle and stays. +- When a front ends, the session drops the placeholder source it served, so + per-path state under a claim goes with the front instead of piling up until + the claim leaves. +- A claim worker that re-serves a path after closing it is a new instance. + Document that in `doc/concept/moq-lite.md` Resume: such a worker keeps its + group sequence going or announces the exact path with a fresh epoch, and + "a transcoder claim stays on the worker that first served it" holds only + while something reads it. Without that, a viewer returning within the + linger gets the old instance's cached latest group and then nothing until + the new instance's sequence passes it. + +Verification: a relay integration test with two `dynamic` claims on their own +sessions (mocked time): read P, leave, close the output, drain the serving +claim, advance past the linger, and check a new session's P comes from the +other claim and that the first session holds no source for P. A `front.rs` +unit test for the end condition, including a parked front that must stay. + +Public API: none expected. Wire: none. + +## Related + +- [Prefix route fronts](/quest/m0/prefix-route-fronts.md) - bounds how many fronts a prefix route can mint at once; this reclaims idle ones +- [Front parking](/quest/m1/origin-front-parks.md) - a front waiting for coverage must survive this +- [Route wakes](/quest/m1/route-wakes.md) - indexes fronts per route, so an ended front must drop its entries +- [Broadcast epochs](/quest/m0/broadcast-epoch/README.md) - restarted publishers mint a fresh epoch, the documented fix for a re-served path diff --git a/quest/m0/track-stream-demand.md b/quest/m0/track-stream-demand.md new file mode 100644 index 0000000000..3b5ab38461 --- /dev/null +++ b/quest/m0/track-stream-demand.md @@ -0,0 +1,59 @@ +# [M] Demand holds across a subscription's TRACK and SUBSCRIBE + +## Goal + +On moq-lite 05 and later, a publisher sees one `used` edge when a viewer +subscribes, not `used`, `unused`, `used`. Today demand drops for one round +trip per hop between the TRACK request and the SUBSCRIBE that follows it. A +publisher that stops work on `broadcast::Demand::unused`, which is what +`Demand` documents itself for, closes its output on every first viewer and is +asked to create it again a few milliseconds later; a transcoder tears down its +encoders and waits for the next keyframe. It happens under a prefix claim and +for an exact announce alike. lite-04 and moq-transport don't do this. Rust, +JS, and the lite draft. + +## Plan + +Facts from `main`: + +- The publisher answers TRACK with a track `query()`, which counts as demand + on the track and its broadcast, and drops it once TRACK_INFO is written + (`lite/publisher.rs`, `TrackInfoServe`). At the publisher's front it + arrives as an `Asked` with no subscription to hold; the `Asked::sub` + comment covers moq-transport's case, not this one. +- The subscriber sends SUBSCRIBE only after reading TRACK_INFO. At a relay, + the upstream SUBSCRIBE waits for the downstream reader's own SUBSCRIBE, so + the gap grows by a round trip per hop. JS subscribers also await TRACK_INFO + before SUBSCRIBE. +- A relay's own copy flaps too, but `track::IDLE_LINGER` hides that from + everything downstream. Only an end publisher sees the raw edge. + +Decisions (2026-10-07): + +- An open TRACK stream counts as interest. The publisher keeps its query + until the requester FINs or resets the TRACK stream. The subscriber FINs it + once its first SUBSCRIBE is sent, or once its own demand goes (an info-only + query). The hold chains across hops, so demand stays continuous end to end. + It is backward compatible: an older subscriber FINs at once, as today. +- Accept the residual: QUIC doesn't order the two streams, so losing the + SUBSCRIBE while the TRACK FIN arrives can still flap for one retransmit + timeout. Closing it would mean holding TRACK until the first response, and + lite-07 has no SUBSCRIBE_OK. +- Not pipelining TRACK and SUBSCRIBE, which the draft already allows: it + still races, and every hop would have to buffer frames until TRACK_INFO. +- Consumers never debounce demand; the docs promise clean edges. +- Scope: Rust and JS, plus one sentence in the draft's Track Stream section. + Run `just test interop --all`. + +Verification: a `moq-net` integration test on the simulated network (10 ms +latency) asserting exactly one `used` edge and no `unused` while the reader +stays subscribed, on lite-05, 06, and 07, direct and through one relay. It +fails on `main` today. A JS counterpart for the JS side. + +Public API: none. Wire: semantics only (holding the TRACK stream open), no new +fields. + +## Related + +- [Idle fronts](/quest/m0/idle-fronts.md) - found in the same transcode-pool report +- [#4225](https://github.com/moq-dev/moq/pull/4225) - holds a lite subscription's demand in the same publisher code; expect a conflict diff --git a/quest/m1/route-wakes.md b/quest/m1/route-wakes.md index b573b29c11..849a5bc5ae 100644 --- a/quest/m1/route-wakes.md +++ b/quest/m1/route-wakes.md @@ -49,11 +49,14 @@ Decisions: 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 follows the best route, per the wildcard line (decided - 2026-10-04, replacing Stay). A join or re-price must rehash every front - below the prefix, since rendezvous moves exactly the paths the changed - route now wins; only those fronts re-select. A leave wakes only the fronts - the leaver served or was requesting through. +- A serving front follows the best route among those it may resume across + (decided 2026-10-04, replacing Stay; narrowed 2026-10-07 after #4942): + routes with its epoch, or none for a front resolved without one, which stays + on its route. An unread front ends instead of moving + ([Idle fronts](/quest/m0/idle-fronts.md)). A join or re-price must rehash + every epoch front below the prefix, since rendezvous moves exactly the paths + the changed route now wins; only those fronts re-select. A leave wakes only + the fronts the leaver served or was requesting through. - Builds on fronts keyed by effective exclusion, 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. From 469f5283f7f4270f0450eef70c9f98e02f1e84b3 Mon Sep 17 00:00:00 2001 From: OneTooMany Date: Wed, 7 Oct 2026 18:44:47 +0200 Subject: [PATCH 2/8] quest: hold TRACK until the first response, bound it, own the filtered-front leak Co-Authored-By: Claude Opus 5.5 --- quest/m0/idle-fronts.md | 27 ++++++++++++++++++++++----- quest/m0/track-stream-demand.md | 23 ++++++++++++++++------- quest/m1/origin-front-parks.md | 13 +++---------- quest/m1/route-wakes.md | 4 +++- 4 files changed, 44 insertions(+), 23 deletions(-) diff --git a/quest/m0/idle-fronts.md b/quest/m0/idle-fronts.md index 0e9f0bd2ec..ff25493a13 100644 --- a/quest/m0/idle-fronts.md +++ b/quest/m0/idle-fronts.md @@ -43,11 +43,25 @@ Decisions (2026-10-07): consumer holds its broadcast. Ending only after the linger keeps the cache window for a returning viewer, needs no wire change, and doesn't conflict with the draft's Resume text, which pins subscriptions, not idle fronts. + A front serving a local source forgets an unread track outright, so it ends + as soon as it goes unread; reaching a local source again is free. +- A request must never join a front that is ending: the front leaves the + origin's front table under the lock before it ends (or `request()` treats an + ending front as gone), so a newcomer gets a fresh front rather than a + broadcast that closes at once. - A front still waiting for coverage ([Front parking](/quest/m1/origin-front-parks.md)) is not idle and stays. -- When a front ends, the session drops the placeholder source it served, so - per-path state under a claim goes with the front instead of piling up until - the claim leaves. +- The session drops a placeholder source once it has no consumers left, so + per-path state under a claim goes with its fronts instead of piling up until + the claim leaves. The route's served cache hands one source to every front + for the path (a plain front and a peer's filtered front can share it), so + the trigger is the source losing its last consumer, not one front ending. + The session keeps that state in more than one place; all of it goes. +- This also reclaims the filtered front a peer session leaves behind (moved + here from [Front parking](/quest/m1/origin-front-parks.md)): a hop in a + covering route chain gets its own filtered front, which today lives as long + as the route, one per peer session that both publishes and subscribes or + reconnects with a fresh hop. - A claim worker that re-serves a path after closing it is a new instance. Document that in `doc/concept/moq-lite.md` Resume: such a worker keeps its group sequence going or announces the exact path with a fresh epoch, and @@ -60,13 +74,16 @@ Verification: a relay integration test with two `dynamic` claims on their own sessions (mocked time): read P, leave, close the output, drain the serving claim, advance past the linger, and check a new session's P comes from the other claim and that the first session holds no source for P. A `front.rs` -unit test for the end condition, including a parked front that must stay. +unit test for the end condition, including a parked front that must stay, +and an origin test of a request racing a front's end (like `front.rs`'s +`a_reader_racing_the_forget_keeps_the_track`, one level up). The front count +returns to the plain front after a peer session closes. Public API: none expected. Wire: none. ## Related - [Prefix route fronts](/quest/m0/prefix-route-fronts.md) - bounds how many fronts a prefix route can mint at once; this reclaims idle ones -- [Front parking](/quest/m1/origin-front-parks.md) - a front waiting for coverage must survive this +- [Front parking](/quest/m1/origin-front-parks.md) - a front waiting for coverage must survive this; the filtered-front leak moved here from it - [Route wakes](/quest/m1/route-wakes.md) - indexes fronts per route, so an ended front must drop its entries - [Broadcast epochs](/quest/m0/broadcast-epoch/README.md) - restarted publishers mint a fresh epoch, the documented fix for a re-served path diff --git a/quest/m0/track-stream-demand.md b/quest/m0/track-stream-demand.md index 3b5ab38461..ac4643fb7a 100644 --- a/quest/m0/track-stream-demand.md +++ b/quest/m0/track-stream-demand.md @@ -32,13 +32,18 @@ Decisions (2026-10-07): - An open TRACK stream counts as interest. The publisher keeps its query until the requester FINs or resets the TRACK stream. The subscriber FINs it - once its first SUBSCRIBE is sent, or once its own demand goes (an info-only - query). The hold chains across hops, so demand stays continuous end to end. - It is backward compatible: an older subscriber FINs at once, as today. -- Accept the residual: QUIC doesn't order the two streams, so losing the - SUBSCRIBE while the TRACK FIN arrives can still flap for one retransmit - timeout. Closing it would mean holding TRACK until the first response, and - lite-07 has no SUBSCRIBE_OK. + once its SUBSCRIBE stream has its first response (the start, a group, an + end, or a reset), or once its own demand goes (an info-only query). The hold + chains across hops, so demand stays continuous end to end. It is backward + compatible: an older subscriber FINs at once, as today. +- Holding only until SUBSCRIBE is sent isn't enough: QUIC doesn't order the + two streams, and a relay that handles the TRACK FIN before the SUBSCRIBE + parks the track and drops its copy at once, letting go upstream. Waiting for + the first response costs one stream held for about a round trip. +- A held TRACK stream is demand without a subscription, so it counts against + the per-session subscription cap from + [Request caps](/quest/m0/request-caps.md); a peer can't hold more interest + than it could by subscribing. - Not pipelining TRACK and SUBSCRIBE, which the draft already allows: it still races, and every hop would have to buffer frames until TRACK_INFO. - Consumers never debounce demand; the docs promise clean edges. @@ -53,6 +58,10 @@ fails on `main` today. A JS counterpart for the JS side. Public API: none. Wire: semantics only (holding the TRACK stream open), no new fields. +## Required + +- [Request caps](/quest/m0/request-caps.md) - the per-session subscription cap a held TRACK stream counts against + ## Related - [Idle fronts](/quest/m0/idle-fronts.md) - found in the same transcode-pool report diff --git a/quest/m1/origin-front-parks.md b/quest/m1/origin-front-parks.md index 57161e3e31..f7266e7747 100644 --- a/quest/m1/origin-front-parks.md +++ b/quest/m1/origin-front-parks.md @@ -25,16 +25,9 @@ benchmark swept over requesters and route-table churn would show whether the re-mint is a real slope or noise, and it is the same benchmark that would show the replacement is cheaper. -Also reclaim the filtered front a peer session leaves behind (folded in -2026-10-05). #4922 keyed fronts by `Horizon::effective`, so viewers share the -plain front, but a hop that appears in a covering route chain still gets its -own filtered front, and that front lives as long as the route. A peer that -both publishes and subscribes, or reconnects with a fresh hop, leaves one -behind per peer session. #4922 scoped it out because it scales with peer -sessions, not viewers. Test that the front count returns to the plain front -after the peer session closes. If this quest moves to m2, split this leftover -into its own m1 quest: it is a leak, not a question of whether the retry loop -costs anything. +The filtered front a peer session leaves behind, folded in here on +2026-10-05, moved to [Idle fronts](/quest/m0/idle-fronts.md) on 2026-10-07: +it ends once unread like any other front. Public API: no signature change expected. `routed_broadcast` and `request_broadcast` keep their contracts; only where the waiting happens diff --git a/quest/m1/route-wakes.md b/quest/m1/route-wakes.md index 849a5bc5ae..9d543b1f9b 100644 --- a/quest/m1/route-wakes.md +++ b/quest/m1/route-wakes.md @@ -52,7 +52,9 @@ Decisions: - A serving front follows the best route among those it may resume across (decided 2026-10-04, replacing Stay; narrowed 2026-10-07 after #4942): routes with its epoch, or none for a front resolved without one, which stays - on its route. An unread front ends instead of moving + on its route until a route with an epoch wins the path and supersedes it. + An idle front (every track forgotten, no consumer holding its broadcast, + not waiting for coverage) ends instead of moving ([Idle fronts](/quest/m0/idle-fronts.md)). A join or re-price must rehash every epoch front below the prefix, since rendezvous moves exactly the paths the changed route now wins; only those fronts re-select. A leave wakes only From 07549fda83f7fa7fc3b10449156bb39f48720a4c Mon Sep 17 00:00:00 2001 From: OneTooMany Date: Wed, 7 Oct 2026 19:04:55 +0200 Subject: [PATCH 3/8] quest: plan claim-served epochs and a fail-loud position regression A claim's answer names the instance that served it (lite-07 TRACK_INFO), so an unread path re-resolves at once, a restarted output is never spliced, and a per-output epoch costs no first-view cut. A relay copy that sees upstream's largest group go backwards ends instead of serving the old instance's cache. TRACK stream demand ranks ahead of idle fronts. Co-Authored-By: Claude Opus 5.5 --- quest/m0/README.md | 3 +- quest/m0/broadcast-epoch/README.md | 6 +- quest/m0/broadcast-epoch/claim-epochs.md | 79 ++++++++++++++++++++++++ quest/m0/idle-fronts.md | 6 +- quest/m0/largest-regression.md | 35 +++++++++++ quest/m1/lite07-finalize.md | 1 + 6 files changed, 127 insertions(+), 3 deletions(-) create mode 100644 quest/m0/broadcast-epoch/claim-epochs.md create mode 100644 quest/m0/largest-regression.md diff --git a/quest/m0/README.md b/quest/m0/README.md index 3a6bbaffc3..bd62b1cfc0 100644 --- a/quest/m0/README.md +++ b/quest/m0/README.md @@ -48,8 +48,9 @@ a published `@moq/watch` break. - [Draft-22 LOCATION_FILTER](/quest/m0/ietf-location-filter-22.md) - moqt-22 LOCATION_FILTER carries its type instead of a Length in Rust and JS, so a draft-22 peer reads our Next Object correctly - [Request caps](/quest/m0/request-caps.md) - lite message sizes, IETF request IDs, and per-session announces and subscriptions are bounded - [Prefix route fronts](/quest/m0/prefix-route-fronts.md) - a prefix route cannot be made to mint one front per requested path -- [Idle fronts](/quest/m0/idle-fronts.md) - a front nobody has read for the linger ends, so a drained claim stops serving paths it once served - [TRACK stream demand](/quest/m0/track-stream-demand.md) - a lite-05+ subscribe shows the publisher one `used` edge, not a flap between TRACK and SUBSCRIBE +- [Idle fronts](/quest/m0/idle-fronts.md) - a front nobody has read for the linger ends, so a drained claim stops serving paths it once served +- [Upstream position regression](/quest/m0/largest-regression.md) - a relay copy that sees upstream's largest group go backwards ends instead of serving the old instance's cache - [qmux credit](/quest/m0/qmux-credit.md) - qmux returns connection credit for dropped and stopped streams and delivers its close frame, on both lines - [Broadcast epochs](/quest/m0/broadcast-epoch/README.md) - every first-party publisher that can restart mints a fresh route epoch, the newest wins a path, and only routes with the same epoch resume a subscription - [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 diff --git a/quest/m0/broadcast-epoch/README.md b/quest/m0/broadcast-epoch/README.md index b6bc106787..d995e453fc 100644 --- a/quest/m0/broadcast-epoch/README.md +++ b/quest/m0/broadcast-epoch/README.md @@ -34,7 +34,10 @@ 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. + subscription and is never stitched to another worker's output. On lite-07 + a claim's answer will carry the served broadcast's own epoch + ([Claim-served epochs](/quest/m0/broadcast-epoch/claim-epochs.md), planned + 2026-10-07), which adds it to TRACK_INFO. - 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 @@ -74,4 +77,5 @@ timeout, and killing the newest epoch falls back to a still-live older one. - [Bindings](/quest/m0/broadcast-epoch/bindings.md) - moq-ffi and every wrapper expose the epoch and let a publisher announce one - [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 is gone - [Stats epochs](/quest/m0/broadcast-epoch/stats-epoch.md) - moq-stats publishes each group announcement under its own epoch, so neither a restarted node nor a returning idle group stalls its viewers +- [Claim-served epochs](/quest/m0/broadcast-epoch/claim-epochs.md) - a claim's answer names the instance that served it, so an unread path re-resolves at once, a restarted output is never spliced, and a per-output epoch costs no cut - [Stats totals and prefix tracks](/quest/m0/broadcast-epoch/stats-split.md) - the same release retires the per-path stats maps for totals and on-demand prefix tracks (decided 2026-10-05) diff --git a/quest/m0/broadcast-epoch/claim-epochs.md b/quest/m0/broadcast-epoch/claim-epochs.md new file mode 100644 index 0000000000..02fbe29872 --- /dev/null +++ b/quest/m0/broadcast-epoch/claim-epochs.md @@ -0,0 +1,79 @@ +# [L] A claim's answer carries its broadcast's epoch + +## Goal + +A broadcast a worker serves in answer to a prefix claim carries its own +epoch, and a relay learns it from the answer. Three things follow for a pool +of claim workers (transcoders) behind relays: + +- A front nobody reads re-resolves by current cost on the next request, + instead of being joined while it still sits on a drained worker. +- A worker that closes an output and serves the path again is a new instance, + so a relay never splices the closed instance's cached groups into it. +- A per-output epoch costs no cut on the first view. + +Today a claim names no instance, a relay's session answers a request under a +claim with a placeholder source before anything goes upstream, and no answer +on the wire names an instance, so the relay's front always resolves without +an epoch. A worker that announces its output's exact path with a fresh epoch +to get one cuts the first viewer: the front resolved through the claim, and +the epoch route supersedes it with `Unroutable`. lite-07 only; older versions +and moq-transport keep today's behavior. + +## Plan + +Decisions (2026-10-07, proposed for the maintainer): + +- TRACK_INFO gains an `Epoch`: the instance that answered, or empty. Each hop + answers with the epoch of the front it served from, so it carries through + relay chains. SUBSCRIBE_OK doesn't need it: the relay fills the learned + epoch into later TRACK, SUBSCRIBE and FETCH, and the publisher's existing + refusal of another epoch catches a restart. +- The epoch lives on the broadcast, since a broadcast is an instance, and + `Request::accept` reads it from the accepted broadcast rather than taking + it as an argument. +- A front adopts the epoch it learns. A request joins an unread front only + while the front's route is still the best; otherwise it gets a fresh front. + That join rule is unsafe without epochs, because a downstream relay's + lingering copy would land on another worker under its cached track; with + them, the copy names its epoch and is refused on a mismatch. +- A front someone reads stays on its instance even when a cheaper route + appears: moving it would start a second instance at the path. A worker that + wants its viewers off closes its outputs, and they re-request. +- No dedicated "broadcast closed" message: with the epoch, the next request + re-resolves anyway, and an exact announcement already gets ANNOUNCE_END. +- The draft's "the Epoch of the route it resolved" and "the route it would + serve from has another Epoch" become broadcast-scoped; as written, a + request naming an epoch is refused while the best route is the claim, even + though that instance is serving. Update the concept doc's epoch section and + this line's README to match. +- A worker using this should not also announce its output's exact path with + the epoch: that announcement and TRACK_INFO race on separate streams, and + the announcement still supersedes the claim front. +- A worker drains feed by feed by closing the outputs it wants off: their + readers re-request and land on the cheapest worker as a new instance, one + short cut per feed instead of withdrawing the claim and cutting everything. +- Every link from the worker to the relays that serve viewers must speak + lite-07, since the epoch is learned and named hop by hop. A viewer's own + version should not matter, because the join rule and the refusal live at + relays and workers; confirm it, including that `@moq/net`'s track cache + never hands a returning viewer the old instance's group. + +This is a lite-07 wire change, so it lands before the version is cut. + +Verification: a relay integration test with two claim workers (mocked time). +After a drain, an unread path re-resolves to the cheaper worker at once. A +worker restarting an output within the linger delivers the new instance from +its first group, with nothing stale. A worker answering with a per-output +epoch serves its first viewer without a cut. A worker closing a path while +it is read sends the re-request to the cheaper worker as a new instance. +`just test interop --all`. + +Public API: Rust and JS, the broadcast's epoch and what `accept` does with +it. Wire: lite-07 TRACK_INFO gains `Epoch`. + +## Related + +- [Idle fronts](/quest/m0/idle-fronts.md) - ends an unread front after the linger on every version; this re-resolves it at once on lite-07 +- [Upstream position regression](/quest/m0/largest-regression.md) - catches a restart on versions without this field +- [Finalize moq-lite-07](/quest/m1/lite07-finalize.md) - waits on this wire change diff --git a/quest/m0/idle-fronts.md b/quest/m0/idle-fronts.md index ff25493a13..97a3fe3b94 100644 --- a/quest/m0/idle-fronts.md +++ b/quest/m0/idle-fronts.md @@ -64,7 +64,9 @@ Decisions (2026-10-07): reconnects with a fresh hop. - A claim worker that re-serves a path after closing it is a new instance. Document that in `doc/concept/moq-lite.md` Resume: such a worker keeps its - group sequence going or announces the exact path with a fresh epoch, and + group sequence going, across its own source restarting too (mirroring an + upstream sequence that goes back to 0 isn't enough), or gives each output its own epoch once + [Claim-served epochs](/quest/m0/broadcast-epoch/claim-epochs.md) lands, and "a transcoder claim stays on the worker that first served it" holds only while something reads it. Without that, a viewer returning within the linger gets the old instance's cached latest group and then nothing until @@ -83,6 +85,8 @@ Public API: none expected. Wire: none. ## Related +- [Claim-served epochs](/quest/m0/broadcast-epoch/claim-epochs.md) - on lite-07, an unread front re-resolves at once instead of after the linger, and a restarted output is never spliced +- [Upstream position regression](/quest/m0/largest-regression.md) - fails loud on the stale splice where the answer shows it - [Prefix route fronts](/quest/m0/prefix-route-fronts.md) - bounds how many fronts a prefix route can mint at once; this reclaims idle ones - [Front parking](/quest/m1/origin-front-parks.md) - a front waiting for coverage must survive this; the filtered-front leak moved here from it - [Route wakes](/quest/m1/route-wakes.md) - indexes fronts per route, so an ended front must drop its entries diff --git a/quest/m0/largest-regression.md b/quest/m0/largest-regression.md new file mode 100644 index 0000000000..8cf05a35ae --- /dev/null +++ b/quest/m0/largest-regression.md @@ -0,0 +1,35 @@ +# [S] A relay copy fails loud when upstream's position goes backwards + +## Goal + +A relay copy that subscribes upstream again and hears that upstream's largest +group is below the newest group it has cached treats it as a reused name: the +copy ends with an error, its source closes so the front ends, and readers +re-request. Today the copy keeps its stale live floor (`set_live` in +`rs/moq-net/src/model/track.rs`), so a viewer returning within the linger to a +publisher that restarted at group 0 gets the old instance's cached group and +then nothing until the new sequence passes it. + +Covers lite-07 and moq-transport, whose answers carry the largest position. +lite-05 and 06 can't tell, and keep waiting on the floor. + +## Plan + +Found while scoping [Idle fronts](/quest/m0/idle-fronts.md) (2026-10-07): +under a prefix claim the relay never sees the worker close a broadcast, so a +restart at the same path reaches a lingering copy unannounced. A publisher +that restarts its group sequence under the same name is buggy, and this makes +that bug visible instead of a silent stall. Check first that no legitimate +answer reports a largest group below what a copy already holds from the same +instance, notably a same-epoch resume onto a replica that has fallen behind. + +Verification: mocked time, a publisher restarting a path at group 0 within +the linger. The returning reader gets an error and then the new instance from +group 0, never the old group. + +Public API: none. Wire: none. + +## Related + +- [Claim-served epochs](/quest/m0/broadcast-epoch/claim-epochs.md) - prevents the splice on lite-07 by naming the instance +- [Coalesce dynamic tracks](/quest/m1/2991-net-coalesce-dynamic-tracks-and-preserve-sequences-across.md) - keeps sequences going when a dynamic track's producer is replaced within one broadcast diff --git a/quest/m1/lite07-finalize.md b/quest/m1/lite07-finalize.md index c783cabfb8..023fa1ef30 100644 --- a/quest/m1/lite07-finalize.md +++ b/quest/m1/lite07-finalize.md @@ -74,6 +74,7 @@ is published; older versions are unchanged. - [Live media time](/quest/m1/subscribe-live-time.md) - SUBSCRIBE_OK carries the publisher's live media time - [Untimed lite-07](/quest/m1/lite-untimed.md) - an untimed track crosses the wire untimed - [One route cost](/quest/m1/route-cost.md) - ANNOUNCE carries one cost +- [Claim-served epochs](/quest/m0/broadcast-epoch/claim-epochs.md) - TRACK_INFO carries the epoch of the instance that answered ## Related From 98fded0b659d5149938ec1d638a4ee6fc2c70ea1 Mon Sep 17 00:00:00 2001 From: OneTooMany Date: Wed, 7 Oct 2026 19:18:14 +0200 Subject: [PATCH 4/8] quest: re-resolve on a refused epoch, keep same-epoch lagging resumes Co-Authored-By: Claude Opus 5.5 --- quest/m0/broadcast-epoch/claim-epochs.md | 8 +++++++- quest/m0/largest-regression.md | 14 ++++++++++---- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/quest/m0/broadcast-epoch/claim-epochs.md b/quest/m0/broadcast-epoch/claim-epochs.md index 02fbe29872..0de5f9e763 100644 --- a/quest/m0/broadcast-epoch/claim-epochs.md +++ b/quest/m0/broadcast-epoch/claim-epochs.md @@ -37,6 +37,10 @@ Decisions (2026-10-07, proposed for the maintainer): That join rule is unsafe without epochs, because a downstream relay's lingering copy would land on another worker under its cached track; with them, the copy names its epoch and is refused on a mismatch. +- A refusal of a front's learned epoch means that instance is gone: the front + ends, and a request that joined it re-resolves onto a fresh front (a new + TRACK_INFO exchange) instead of failing. Only readers the old instance was + serving see a cut. - A front someone reads stays on its instance even when a cheaper route appears: moving it would start a second instance at the path. A worker that wants its viewers off closes its outputs, and they re-request. @@ -66,7 +70,9 @@ After a drain, an unread path re-resolves to the cheaper worker at once. A worker restarting an output within the linger delivers the new instance from its first group, with nothing stale. A worker answering with a per-output epoch serves its first viewer without a cut. A worker closing a path while -it is read sends the re-request to the cheaper worker as a new instance. +it is read sends the re-request to the cheaper worker as a new instance. A +request joining an unread front whose worker restarted its output reaches the +new instance without an error. `just test interop --all`. Public API: Rust and JS, the broadcast's epoch and what `accept` does with diff --git a/quest/m0/largest-regression.md b/quest/m0/largest-regression.md index 8cf05a35ae..83a15d6bf6 100644 --- a/quest/m0/largest-regression.md +++ b/quest/m0/largest-regression.md @@ -19,13 +19,19 @@ Found while scoping [Idle fronts](/quest/m0/idle-fronts.md) (2026-10-07): under a prefix claim the relay never sees the worker close a broadcast, so a restart at the same path reaches a lingering copy unannounced. A publisher that restarts its group sequence under the same name is buggy, and this makes -that bug visible instead of a silent stall. Check first that no legitimate -answer reports a largest group below what a copy already holds from the same -instance, notably a same-epoch resume onto a replica that has fallen behind. +that bug visible instead of a silent stall. + +A lower largest group alone doesn't prove a restart: a same-epoch resume onto +a replica that has fallen behind reports one legitimately, and must keep +waiting as today. The copy ends only when the answer comes from the route it +was last served from without an epoch, where no cross-route resume happens, +or names a different epoch than the copy holds. Check for any other +legitimate case before relying on that split. Verification: mocked time, a publisher restarting a path at group 0 within the linger. The returning reader gets an error and then the new instance from -group 0, never the old group. +group 0, never the old group. A same-epoch standby behind the cached copy +keeps the copy and resumes it. Public API: none. Wire: none. From 9bfa0dee61dddf00d7ea7f53d4dcfecafaf54012 Mon Sep 17 00:00:00 2001 From: OneTooMany Date: Wed, 7 Oct 2026 19:30:56 +0200 Subject: [PATCH 5/8] quest: scope the regression error, keep withdrawal immediate, test ordering and the cap Co-Authored-By: Claude Opus 5.5 --- quest/m0/idle-fronts.md | 4 +++- quest/m0/largest-regression.md | 5 +++-- quest/m0/track-stream-demand.md | 5 ++++- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/quest/m0/idle-fronts.md b/quest/m0/idle-fronts.md index 97a3fe3b94..cf16780331 100644 --- a/quest/m0/idle-fronts.md +++ b/quest/m0/idle-fronts.md @@ -56,7 +56,9 @@ Decisions (2026-10-07): the claim leaves. The route's served cache hands one source to every front for the path (a plain front and a peer's filtered front can share it), so the trigger is the source losing its last consumer, not one front ending. - The session keeps that state in more than one place; all of it goes. + Withdrawing the claim or closing the session still closes the source at + once, as today, and in-flight consumers drain on their own. The session + keeps that state in more than one place; all of it goes. - This also reclaims the filtered front a peer session leaves behind (moved here from [Front parking](/quest/m1/origin-front-parks.md)): a hop in a covering route chain gets its own filtered front, which today lives as long diff --git a/quest/m0/largest-regression.md b/quest/m0/largest-regression.md index 83a15d6bf6..ae1fe59849 100644 --- a/quest/m0/largest-regression.md +++ b/quest/m0/largest-regression.md @@ -4,8 +4,9 @@ A relay copy that subscribes upstream again and hears that upstream's largest group is below the newest group it has cached treats it as a reused name: the -copy ends with an error, its source closes so the front ends, and readers -re-request. Today the copy keeps its stale live floor (`set_live` in +copy ends with an error, its source closes so the front ends, and the readers +it was serving re-request. A new request arriving as the front ends gets a +fresh front without an error. Today the copy keeps its stale live floor (`set_live` in `rs/moq-net/src/model/track.rs`), so a viewer returning within the linger to a publisher that restarted at group 0 gets the old instance's cached group and then nothing until the new sequence passes it. diff --git a/quest/m0/track-stream-demand.md b/quest/m0/track-stream-demand.md index ac4643fb7a..31af3a71bf 100644 --- a/quest/m0/track-stream-demand.md +++ b/quest/m0/track-stream-demand.md @@ -53,7 +53,10 @@ Decisions (2026-10-07): Verification: a `moq-net` integration test on the simulated network (10 ms latency) asserting exactly one `used` edge and no `unused` while the reader stays subscribed, on lite-05, 06, and 07, direct and through one relay. It -fails on `main` today. A JS counterpart for the JS side. +fails on `main` today. The relay case controls the ordering so the +downstream TRACK FIN is handled before its SUBSCRIBE. A boundary test fills +the per-session subscription cap with held TRACK streams and checks the next +one is refused. JS counterparts for the JS side. Public API: none. Wire: semantics only (holding the TRACK stream open), no new fields. From 6ee3e0dd757713beaa8fa890aa390ecab807f089 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 7 Oct 2026 19:09:19 -0700 Subject: [PATCH 6/8] quest: routes without an epoch never splice; drop the relay-chain caveat Maintainer decision 2026-10-08: a Restart (or END then START) drops every downstream copy, relays included, and only an identical epoch resumes. Co-Authored-By: Claude Opus 5.5 --- quest/m0/broadcast-epoch/claim-epochs.md | 12 +++++++----- quest/m0/largest-regression.md | 10 +++++----- quest/m1/route-wakes.md | 5 +++-- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/quest/m0/broadcast-epoch/claim-epochs.md b/quest/m0/broadcast-epoch/claim-epochs.md index 0a391beaba..f3f509c0e3 100644 --- a/quest/m0/broadcast-epoch/claim-epochs.md +++ b/quest/m0/broadcast-epoch/claim-epochs.md @@ -33,11 +33,13 @@ Decisions (2026-10-07, proposed for the maintainer): - The epoch lives on the broadcast, since a broadcast is an instance, and `Request::accept` reads it from the accepted broadcast rather than taking it as an argument. -- A front adopts the epoch it learns. Restart's join rule (a request joins a - front only while its route still wins) can land a downstream relay's - lingering copy on another worker under its cached track, since the - upstream relay's per-path winner moves without the downstream claim route - changing. With the epoch, the copy names it and is refused on a mismatch. +- A front adopts the epoch it learns. Routes without an epoch never splice + (maintainer, 2026-10-08): a route change reaches every downstream + subscriber, relays included, as a `Restart` (or END then START), which + drops the old copy for a fresh subscription, and only an identical epoch + resumes ([Restart](/quest/m0/broadcast-epoch/restart.md)). What that can't + see is a worker restarting an output under the same claim route; there the + learned epoch is what a downstream copy names, and a mismatch is refused. - A refusal of a front's learned epoch means that instance is gone: the front ends, and a request that joined it re-resolves onto a fresh front (a new TRACK_INFO exchange) instead of failing. Only readers the old instance was diff --git a/quest/m0/largest-regression.md b/quest/m0/largest-regression.md index c2d5bc3db5..5afcfbd7d7 100644 --- a/quest/m0/largest-regression.md +++ b/quest/m0/largest-regression.md @@ -20,11 +20,11 @@ Found while scoping [Idle fronts](/quest/m0/idle-fronts.md) (2026-10-07): under a prefix claim the relay never sees the worker close a broadcast, so a restart at the same path reaches a lingering copy unannounced. A publisher that restarts its group sequence under the same name is buggy, and this makes -that bug visible instead of a silent stall. Restart's join rule -([Restart](/quest/m0/broadcast-epoch/restart.md)) can add a path to the same -splice through a relay chain: an upstream relay's per-path winner moves to -another worker while a downstream relay's lingering copy re-subscribes -through it. +that bug visible instead of a silent stall. A change of route can't cause +it: routes without an epoch never splice, and a `Restart` (or END then +START) drops every downstream copy, relays included (maintainer, 2026-10-08, +enforced in [Restart](/quest/m0/broadcast-epoch/restart.md)). Only a restart +behind an unchanged route reaches a copy unannounced. A lower largest group alone doesn't prove a restart: a same-epoch resume onto a replica that has fallen behind reports one legitimately, and must keep diff --git a/quest/m1/route-wakes.md b/quest/m1/route-wakes.md index 6f69e58f3b..1a2ac40d3d 100644 --- a/quest/m1/route-wakes.md +++ b/quest/m1/route-wakes.md @@ -53,8 +53,9 @@ Decisions: (decided 2026-10-04, replacing Stay; narrowed 2026-10-07 after #4942 and [Restart](/quest/m0/broadcast-epoch/restart.md)): routes with its epoch. A front resolved without one, or replaced by a newer epoch, stays on its route - for its subscribers until that route goes, and new requests take a fresh - front on the winner instead of waking it. An idle front (every track + for its subscribers until that route goes and never splices onto another + (maintainer, 2026-10-08); new requests take a fresh front on the winner + instead of waking it. An idle front (every track forgotten, no consumer holding its broadcast, not waiting for coverage) ends instead of moving ([Idle fronts](/quest/m0/idle-fronts.md)). A join or re-price must rehash From 9b12e21870e832efcef598be2ea2fc7c8ad805a0 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 7 Oct 2026 20:22:30 -0700 Subject: [PATCH 7/8] quest: share a cap slot between TRACK and SUBSCRIBE; test a mixed-version claim restart From the OpenAI review of 9bfa0dee and CodeRabbit's review of 6ee3e0dd. Co-Authored-By: Claude Opus 5.5 --- quest/m0/broadcast-epoch/claim-epochs.md | 5 ++++- quest/m0/track-stream-demand.md | 11 ++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/quest/m0/broadcast-epoch/claim-epochs.md b/quest/m0/broadcast-epoch/claim-epochs.md index f3f509c0e3..729cf79dad 100644 --- a/quest/m0/broadcast-epoch/claim-epochs.md +++ b/quest/m0/broadcast-epoch/claim-epochs.md @@ -76,7 +76,10 @@ its first group, with nothing stale. A worker answering with a per-output epoch serves its first viewer without a cut. A worker closing a path while it is read sends the re-request to the cheaper worker as a new instance. A request joining an unread front whose worker restarted its output reaches the -new instance without an error. +new instance without an error. A lite-06 or IETF viewer behind a lite-07 +worker-to-relay path, with the old instance's groups still cached, receives +only the new instance's groups after the worker restarts the output with a +new epoch. `just test interop --all`. Public API: Rust and JS, the broadcast's epoch and what `accept` does with diff --git a/quest/m0/track-stream-demand.md b/quest/m0/track-stream-demand.md index 31af3a71bf..cc51eb3558 100644 --- a/quest/m0/track-stream-demand.md +++ b/quest/m0/track-stream-demand.md @@ -43,7 +43,11 @@ Decisions (2026-10-07): - A held TRACK stream is demand without a subscription, so it counts against the per-session subscription cap from [Request caps](/quest/m0/request-caps.md); a peer can't hold more interest - than it could by subscribing. + than it could by subscribing. A held TRACK and the SUBSCRIBE for the same + track on the same session share one slot: the SUBSCRIBE takes over the + TRACK's reservation instead of needing a second, so a session at the cap + can still turn its held TRACKs into subscriptions (decided 2026-10-08 from + review). - Not pipelining TRACK and SUBSCRIBE, which the draft already allows: it still races, and every hop would have to buffer frames until TRACK_INFO. - Consumers never debounce demand; the docs promise clean edges. @@ -55,8 +59,9 @@ latency) asserting exactly one `used` edge and no `unused` while the reader stays subscribed, on lite-05, 06, and 07, direct and through one relay. It fails on `main` today. The relay case controls the ordering so the downstream TRACK FIN is handled before its SUBSCRIBE. A boundary test fills -the per-session subscription cap with held TRACK streams and checks the next -one is refused. JS counterparts for the JS side. +the per-session subscription cap with held TRACK streams, turns each into a +live SUBSCRIBE with no `unused` edge, and checks that one more TRACK is +refused. JS counterparts for the JS side. Public API: none. Wire: semantics only (holding the TRACK stream open), no new fields. From b75e64c51b135302ab6c371f35f3dc97a9a9cdde Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 7 Oct 2026 20:41:59 -0700 Subject: [PATCH 8/8] quest: Restart is route-level; nested broadcasts under the route restart Maintainer decision 2026-10-08: a Restart names a route on every version, and a subscriber unsets the cached copy of every broadcast whose longest matching route it is. A per-path winner change under a prefix pool goes out as a Restart of the prefix. Co-Authored-By: Claude Opus 5.5 --- quest/m0/broadcast-epoch/claim-epochs.md | 3 ++- quest/m0/broadcast-epoch/restart.md | 34 +++++++++++++++--------- quest/m0/largest-regression.md | 3 ++- 3 files changed, 25 insertions(+), 15 deletions(-) diff --git a/quest/m0/broadcast-epoch/claim-epochs.md b/quest/m0/broadcast-epoch/claim-epochs.md index 80d1a3803a..ffe32fdf1a 100644 --- a/quest/m0/broadcast-epoch/claim-epochs.md +++ b/quest/m0/broadcast-epoch/claim-epochs.md @@ -36,7 +36,8 @@ Decisions (2026-10-07, proposed for the maintainer): - A front adopts the epoch it learns. Routes without an epoch never splice (maintainer, 2026-10-08, in [Restart](/quest/m0/broadcast-epoch/restart.md)): a route change, including a per-path winner change under a prefix pool, - reaches downstream as a `Restart` that unsets the cached copy. What that + reaches downstream as a `Restart` of the route, which unsets the cached + copy of every broadcast nested under it. What that can't see is a worker restarting an output under the same claim route; there the learned epoch is what a downstream copy names on re-subscribe, and a mismatch is refused. diff --git a/quest/m0/broadcast-epoch/restart.md b/quest/m0/broadcast-epoch/restart.md index b2df67dec7..bf7ae6bc45 100644 --- a/quest/m0/broadcast-epoch/restart.md +++ b/quest/m0/broadcast-epoch/restart.md @@ -29,13 +29,19 @@ Relays keep caching between subscriptions: a `Restart` is the cache invalidation. It unsets the relay's copy for that path, so the next subscription goes upstream to the new route (maintainer, 2026-10-08: "a Restart unsets the cache; the next subscription goes upstream to the new -route"). Under a prefix pool the advertised winner is ranked by the prefix -while requests are ranked by the requested path, so a path can move to -another worker while the prefix's winner stays. That per-path winner change -is a source change for the path: the relay emits a `Restart` for it even -though the prefix winner is unchanged. The restart message must be able to -name a path below an announced prefix; check how older wires carry that, and -raise it in the PR if they can't. +route"). + +`Restart` is route-level only, on every version, lite-07 included +(maintainer, 2026-10-08: "Restart is route-level only; nested broadcasts +under the longest-matching route restart"). A `Restart` names a route, the +announced path or prefix, as ANNOUNCE does. A subscriber applies it to every +broadcast whose longest matching route is that one: it unsets each cached +copy, and the next subscribe re-resolves upstream. Under a prefix pool the +advertised winner is ranked by the prefix while requests are ranked by the +requested path, so a path can move to another worker while the prefix's +winner stays. The relay sends that as a `Restart` of the prefix route (such +as `pool`), and every nested broadcast under it re-resolves; a viewer whose +path kept its worker just resubscribes to the same source. ## Plan @@ -100,7 +106,8 @@ Decided in planning (2026-10-07, from #4970's review): relay retires that copy for new requests and opens a fresh one, never joining or resuming into the old copy. - **Wire.** lite-07 (`moq-lite-07-wip`) gains an explicit restart message on - the announce stream. Older lite versions and moq-transport send + the announce stream, naming a route like ANNOUNCE does, never a nested + path. Older lite versions and moq-transport send `ANNOUNCE_END` then `ANNOUNCE_START` for a source change, a new rule for a relay's winner moving between entries (the draft only says so for a publisher replacing its own epoch), and a receiver delivers an end and @@ -152,11 +159,12 @@ old route: no `Restart` and new requests stay on the old route until it is withdrawn, then `Restart` and a re-request lands on the replacement. With mocked time, a lite-06 relay chain where an epochless publisher restarts: the downstream relay's cached groups from the old instance never reach a new -subscriber mixed with the new instance's groups. With mocked time, a prefix -pool of epoch-less workers behind two relays where one path's winner changes -under an unchanged prefix winner: the downstream relay gets a `Restart` for -that path, drops its cached copy, and its next subscribe reaches the new -route. Run `just drafts check` and `just test interop --all`. +subscriber mixed with the new instance's groups. With mocked time, on lite-06 +and lite-07, a prefix pool of epoch-less workers announced as `pool` behind +two relays, where one path's winner changes under an unchanged prefix winner: +the downstream relay gets a `Restart` of `pool`, drops its cached copies of +the nested paths, and the next subscribes reach the right workers, the moved +path on its new worker and the rest on theirs. Run `just drafts check` and `just test interop --all`. Docs: update `doc/concept/moq-lite.md` (publisher epochs) and `doc/lib/{rs,js}` announce sections inline, plus `doc/bin/rtmp.md`, diff --git a/quest/m0/largest-regression.md b/quest/m0/largest-regression.md index 4a7d655672..73aa85d5d1 100644 --- a/quest/m0/largest-regression.md +++ b/quest/m0/largest-regression.md @@ -22,7 +22,8 @@ restart at the same path reaches a lingering copy unannounced. A publisher that restarts its group sequence under the same name is buggy, and this makes that bug visible instead of a silent stall. A change of route can't cause it: every route change, including a per-path winner change under a prefix -pool, reaches downstream as a `Restart` that unsets the cached copy +pool, reaches downstream as a `Restart` of the route, which unsets the cached +copy of every broadcast nested under it (maintainer, 2026-10-08, in [Restart](/quest/m0/broadcast-epoch/restart.md)). Only a restart behind an unchanged route reaches a copy unannounced.