From 573af08ceeb7660d483a827b60e5780330e07ec9 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 7 Oct 2026 10:34:21 -0700 Subject: [PATCH 01/10] quest: plan Restart, Rust untimed default, and three follow-ups Restart (m0) reverses the epoch hard switch: a replaced broadcast is an explicit announce Restart and subscriptions stay sticky. Also plans the Rust untimed default, the browser interop timeouts, cross-relay FETCH over moq-transport refusing held groups as old, and the JS query lifetime. Co-Authored-By: Claude Opus 5.5 --- quest/m0/broadcast-epoch/README.md | 11 +++-- quest/m0/broadcast-epoch/restart.md | 62 ++++++++++++++++++++++++++++ quest/m1/README.md | 4 ++ quest/m1/ietf-peer-fetch-old.md | 32 ++++++++++++++ quest/m1/interop-browser-timeouts.md | 29 +++++++++++++ quest/m1/js-probe-lifetime.md | 28 +++++++++++++ quest/m1/rust-untimed-default.md | 37 +++++++++++++++++ 7 files changed, 199 insertions(+), 4 deletions(-) create mode 100644 quest/m0/broadcast-epoch/restart.md create mode 100644 quest/m1/ietf-peer-fetch-old.md create mode 100644 quest/m1/interop-browser-timeouts.md create mode 100644 quest/m1/js-probe-lifetime.md create mode 100644 quest/m1/rust-untimed-default.md diff --git a/quest/m0/broadcast-epoch/README.md b/quest/m0/broadcast-epoch/README.md index b6bc106787..b85c6b6797 100644 --- a/quest/m0/broadcast-epoch/README.md +++ b/quest/m0/broadcast-epoch/README.md @@ -35,10 +35,12 @@ Decided: 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. -- 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 - wins again as a new broadcast. +- The newest epoch wins a prefix ahead of cost (decided 2026-10-06). The + 2026-10-06 hard switch (subscriptions in flight end with `Unroutable`) is + reversed (2026-10-07): subscriptions stay sticky on their route and an + explicit `Restart` announce event tells players to follow, through + [Restart](/quest/m0/broadcast-epoch/restart.md). When the newest goes and + an older one is still live, the older one wins again as a new broadcast. - A catalog `broadcast` reference by name follows the newest epoch, since a path cannot name one. - Every first-party publisher that can restart mints its own: the apps, @@ -69,6 +71,7 @@ timeout, and killing the newest epoch falls back to a still-live older one. ## Required - [Apps](/quest/m0/broadcast-epoch/apps.md) - moq-cli, the browser publish and watch components, and demo/web restart into a new epoch and reset on the switch +- [Restart](/quest/m0/broadcast-epoch/restart.md) - a replaced broadcast reaches announce consumers as an explicit Restart, subscriptions stay sticky, and new requests never join a replaced route's front - [Gateways](/quest/m0/broadcast-epoch/gateways.md) - RTMP, SRT, and WHIP ingest mint an epoch per incoming connection, so an encoder reconnect is a clean takeover - [TS restart](/quest/m0/broadcast-epoch/ts-restart.md) - a signalled backward TS discontinuity finishes the broadcast and continues the same input under a fresh epoch - [Bindings](/quest/m0/broadcast-epoch/bindings.md) - moq-ffi and every wrapper expose the epoch and let a publisher announce one diff --git a/quest/m0/broadcast-epoch/restart.md b/quest/m0/broadcast-epoch/restart.md new file mode 100644 index 0000000000..3eb795f3c4 --- /dev/null +++ b/quest/m0/broadcast-epoch/restart.md @@ -0,0 +1,62 @@ +# [L] A replaced broadcast restarts announce consumers, and subscriptions stay sticky + +## Goal + +When the source behind a path changes, every announce consumer sees an +explicit `Restart` event, in Rust (`AnnounceEvent::Restart`) and `@moq/net`. +The source changes on a newer epoch, or, on a route without an epoch, when a +different route entry wins. A re-price or a same-source failover stays an +`Update`. Players start on `Start`, restart on `Restart`, and stop on `End`. + +Subscriptions are sticky: a replacement no longer ends them. A subscription +stays on the route it resolved through until the application drops it or that +route goes, so the application decides whether to stay on the old broadcast +or follow the new one. New requests resolve the current winner and never join +a front pinned to a replaced route. + +## Plan + +Decided in planning (2026-10-07, from #4970's review): + +- **Source identity.** On a route without an epoch, a different route entry + is a different source: any other announcing session, a peer reconnect + included. Routes without an epoch can't resume across routes anyway, so + the restart is honest. Rust's origin delivers an `Update` today when an + epochless winner moves to another entry (`CursorState::update` compares + `(hops, cost, source, epoch)`), while JS already ends and restarts it + (`#runAnnounced` diffs entry identity). Both deliver `Restart` after this. +- **Sticky subscriptions (reverses the 2026-10-06 hard switch).** A newer + epoch no longer ends subscriptions in flight with `Unroutable` + (`Pick::Superseded`, `front.rs` `supersede`). The front keeps serving its + subscribers until its route goes. A relay keeps its upstream subscription + on the old route while any downstream still holds it. Update the line + README's decision, the epoch tests from #4942, and #4962's gateway tests + that assert `Unroutable`. +- **No pinned joins.** `Consumer::request` joins an existing front only while + the route it resolved through still wins. Today a request joins an + epochless front even after the winner changed + (`origin.rs` `Consumer::request`, `pick`), so a player that re-requests + gets the dead broadcast again. JS already swaps per path. +- **Wire.** lite-07 (`moq-lite-07-wip`) gains an explicit restart message on + the announce stream, with the draft text and changelog. Older lite + versions and moq-transport send `ANNOUNCE_END` then `ANNOUNCE_START`, the + draft's existing replacement rule, and a receiver delivers an end and start + for the same prefix that are both pending before delivery as one `Restart` + (no timer). `ANNOUNCE_UPDATE` stays a metadata update with no content claim. +- **Players.** `moq play` (#4970) and `@moq/watch` restart on `Restart` + instead of treating every epochless `Update` as a restart, so a GOAWAY + drain or re-price no longer restarts playback. + `moqsrc` (planned in #4960) switches on `Restart` too. + +Tests: a newer epoch and an epochless source change each deliver `Restart` +in both languages; a re-price delivers `Update`; an old subscription keeps +receiving after a replacement until its route goes; a re-request after +`Restart` resolves the new route, over a relay on lite-06, lite-07, and +moq-transport. 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. + +Public API: breaking, a new `AnnounceEvent::Restart` variant (Rust) and +`"restart"` kind (JS). Wire: a new lite-07 announce message; older versions +send END then START where they sent UPDATE for a source change. diff --git a/quest/m1/README.md b/quest/m1/README.md index d0d5d36876..1395f2a195 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -30,6 +30,7 @@ blocks. The quests that gated m0 lines moved under them. - [Untimed model](/quest/m1/untimed-model.md) - a track is all timed or all untimed (`Info.timescale` is optional, frames carry an optional timestamp), and an untimed track reaches every subscriber untimed; no receiver fills in arrival time - [One max_age meaning](/quest/m1/cache-max-age.md) - a superseded group goes stale on wall clock since its successor arrived or on media time, whichever is first, in Rust and js/net; fixes the untimed failover stall - [JS untimed model](/quest/m1/js-untimed-model.md) - the untimed model in @moq/net +- [Untimed by default in Rust](/quest/m1/rust-untimed-default.md) - an undeclared Rust timescale means untimed, and shared-clock publishers declare milliseconds - [CMAF frame timestamp](/quest/m1/cmaf-frame-timestamp.md) - CMAF decoders time samples from the moq-lite frame timestamp, using `tfdt` only within the fragment - [Shared import clock](/quest/m1/shared-clock.md) - an importer joining a clock already in use offsets its PTS instead of moving it, so captures and imports share one timeline - [FFI shape](/quest/m1/ffi-shape/README.md) - the bindings mirror Rust's layers: net at the root, then media, json, flate, audio, and video namespaces built from the handle below @@ -55,18 +56,21 @@ blocks. The quests that gated m0 lines moved under them. - [Live media time](/quest/m1/subscribe-live-time.md) - re-scoped against `set_live`: a lite-07 SUBSCRIBE_OK carries the publisher's current media time only if a reader still needs it - [Cross-relay bursts re-run](/quest/m1/cross-relay-bursts.md) - condition: the #4349 reporter re-runs their A/B/C comparison against current cdn.moq.pro - [Flapping peer drill](/quest/m1/cross-relay-flap-drill.md) - the cluster burst drill survives a flapping peer link, the regression for 0.15.6 route-flap drops +- [Cross-relay FETCH over moq-transport](/quest/m1/ietf-peer-fetch-old.md) - a moq-transport peer link serves held groups instead of refusing them as old, and the drill covers it - [Untimed lite-07](/quest/m1/lite-untimed.md) - lite-07 carries an untimed track in both languages; lite-05/06 write send time - [Finalize moq-lite-07](/quest/m1/lite07-finalize.md) - when the maintainer cuts it, lite-07 negotiates as `moq-lite-07` and the next release ships it - [Late lower groups](/quest/m1/lite-late-lower-group.md) - a moq-lite subscriber with a floor receives a group created below the first served one, as moq-transport does - [JSON stream budget](/quest/m1/json-stream-budget.md) - an oversized JSON stream record is refused without ending the log, and a JS subscribe to a gone track answers NotFound - [Flate stream budget](/quest/m1/flate-stream-budget.md) - a flate stream refuses an oversized append without ending, sharing one DEFLATE bound with json - [JS track takeover](/quest/m1/js-track-takeover.md) - JS `createTrack` answers a queued request and continues its sequences, as Rust does +- [JS query lifetime](/quest/m1/js-probe-lifetime.md) - a pending JS track-info query ends with its last requester, so broadcast demand drops - [A watch and publish release ships assets()](/quest/m1/assets-release.md) - the release that lets the sites host the worklets - [Dogfood hosted worklets](/quest/m1/dogfood-assets.md) - moq.dev and the moq.pro dashboard host the worklets and call `assets()` after the release - [More tests under load](/quest/m1/test-flakes-2/README.md) - the second round of load-only failures, one quest per flake, fixed at the cause - [moq-uring tests under load](/quest/m1/uring-tests-under-load.md) - uring tests pass while parallel checks share locked memory - [Media audio-tone check](/quest/m1/media-audio-tone.md) - the media lane's audio-tone check passes under load, fixed at its cause - [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 +- [Browser interop cells](/quest/m1/interop-browser-timeouts.md) - `go -> js` and `python -> js` pass, fixed at the cause rather than by a longer timeout - [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-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 diff --git a/quest/m1/ietf-peer-fetch-old.md b/quest/m1/ietf-peer-fetch-old.md new file mode 100644 index 0000000000..cbeecf604c --- /dev/null +++ b/quest/m1/ietf-peer-fetch-old.md @@ -0,0 +1,32 @@ +# [M] Cross-relay FETCH over moq-transport serves held groups + +## Goal + +With the cluster peer link on moq-transport, a cross-relay FETCH for a group +the origin still holds is served, not refused as `old`. The burst drill then +gains a moq-transport peer link as a third case and passes it. + +## Plan + +Found by #4972: with the edge pinned to `moq-transport-19`, the existing +steady impaired drill (`bursts_cross_a_cluster`, `rs/moq-relay/tests/drills.rs`) +fails 5 of 5 runs. Seeds: 11977074354257116273, 6755536760138253279, +17984197590923068922, 10974424043511566981. + +Facts (2026-10-07): the IETF FETCH path (`run_fetch_stream`, +`rs/moq-net/src/ietf/publisher.rs`) has no explicit `Old` check. An `Old` can +come from reading a group the cache's wall-clock expiry aborts mid-read +(`expire_closed`, `evict_expired_scan` in `model/track.rs`), or from passing +through a lite peer's `StreamError::Old` reset. `Old` has no moq-transport +code, so it goes out as INTERNAL_ERROR and comes back as `Error::Remote`. + +- Reproduce with the seeds, find which path refuses, and fix it at the + cause. A held group must not be refused. +- Add the moq-transport peer link case to the drill. + +Public API: none expected. Wire: none expected. + +## Related + +- [Flapping peer drill](/quest/m1/cross-relay-flap-drill.md) - the drill this extends +- [Cross-relay bursts re-run](/quest/m1/cross-relay-bursts.md) - the field report of lost cross-relay groups diff --git a/quest/m1/interop-browser-timeouts.md b/quest/m1/interop-browser-timeouts.md new file mode 100644 index 0000000000..f0295febfd --- /dev/null +++ b/quest/m1/interop-browser-timeouts.md @@ -0,0 +1,29 @@ +# [M] Browser interop cells pass with FFI publishers + +## Goal + +The `go -> js` and `python -> js` cells of `just test interop --all` pass +reliably on `main` and in nightly CI, with the cause found and fixed. They +time out today at 32 to 33 s against a 30 s limit, on `main` too (run +37511624075), and on the auth line the browser's tone subscription is reset +with code 0. + +## Plan + +Decided 2026-10-07: investigate now rather than wait for nightly results +after #5005 (expiry-wakes), which may be the cause: an FFI publisher's +2 s audio max age parks hundreds of group serves. + +- Reproduce locally with `just test interop --all`, under load and alone. + The js cell (`test/interop/interop.sh` js subscriber, `driver.ts`) waits for + rendered video, then audio, pause, and resume, each within the timeout; + `harness.ts` `waitForWatch` has its own hard-coded 30 s. +- Find which step stalls and why, from the player trace and the publisher + side. Check whether #5005 alone fixes it before changing anything else. +- Never raise the timeout or add a retry. + +Public API: none expected. Wire: none. + +## Related + +- [CI runner stalls](/quest/m1/ci-runner-stalls.md) - shorter freezes of both interop tracks on CI runners diff --git a/quest/m1/js-probe-lifetime.md b/quest/m1/js-probe-lifetime.md new file mode 100644 index 0000000000..0e9b50dd9f --- /dev/null +++ b/quest/m1/js-probe-lifetime.md @@ -0,0 +1,28 @@ +# [S] A JS track-info query ends with its last requester + +## Goal + +In `@moq/net`, a pending track-info query counts as broadcast demand only +while someone still wants the answer. When the requester's TRACK stream is +reset or its session closes before the publisher answers, the query ends and +`Broadcast.Demand` drops, as in Rust. + +## Plan + +Found in the review of #4956. Decided 2026-10-07: mirror Rust, where the +query is a consumer of the track state that counts toward demand and drops +with the serve (`TrackInfoServe`, `rs/moq-net/src/lite/publisher.rs`). + +- `resolveTrackInfo` (`js/net/src/broadcast.ts`) takes an `AbortSignal` and + pins demand only while a requester holds the query. +- The lite publisher (`runTrackInfo` and `#resolveTrackInfo` in + `js/net/src/lite/publisher.ts`) watches the TRACK stream for a reset and + releases its hold; the shared per-front query ends with its last holder. +- Fold in the review nit: `removeTrack` on a name cached only by a + `consume()` subscription is outside its documented contract; document or + refuse it. + +Test: a requester that disconnects before the answer drops broadcast +demand, and a second requester keeps it pinned until it leaves too. + +Public API: `resolveTrackInfo` gains a signal argument. Wire: none. diff --git a/quest/m1/rust-untimed-default.md b/quest/m1/rust-untimed-default.md new file mode 100644 index 0000000000..b44d36dcc0 --- /dev/null +++ b/quest/m1/rust-untimed-default.md @@ -0,0 +1,37 @@ +# [M] An undeclared Rust timescale means untimed + +## Goal + +A Rust track that declares no timescale is untimed, as in `@moq/net`: +`track::Info::default()` and `track::Request::accept(None)` leave +`timescale` as `None`. Every publisher that stamps from the shared broadcast +clock declares milliseconds explicitly, so data tracks and media share one +timeline. + +## Plan + +Decided 2026-10-07 (with #4968, where `@moq/net` treats an omitted or `null` +timescale as untimed and `@moq/publish` declares milliseconds): + +- Remove the implicit millisecond default: `impl Default for Timescale` + (`rs/moq-net/src/model/time.rs`) and its use in `track::Info::default()` + and `accept(None)` (`rs/moq-net/src/model/track.rs`). +- Declare milliseconds, stamped from the shared broadcast clock, in every + Rust data track that relies on the default today: `hang` catalog + (`default_track_info`), moq-mux MSF and timeline tracks, moq-boy status, + moq-c `data_track`, and moq-ffi json and flate tracks. moq-ffi and moq-c + media tracks already pin microseconds. The shared timeline itself is + [Shared import clock](/quest/m1/shared-clock.md)'s; this quest only makes + each data track declare and use it. +- Update the bindings and `doc/lib` pages that describe the default. + +Public API: breaking (`Timescale` loses `Default`; an undeclared track is +untimed). Wire: none. + +## Required + +- [Untimed model](/quest/m1/untimed-model.md) - `track::Info.timescale` becomes an `Option` there + +## Related + +- [Publishing never invents a timestamp](/quest/m1/publish-timestamp.md) - the same publishers stop filling in a timestamp From dff5a9b12bcdc0997afbafdf072c886187b3f675 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 7 Oct 2026 10:56:27 -0700 Subject: [PATCH 02/10] quest: correct Restart facts and draft edits from review Co-Authored-By: Claude Opus 5.5 --- quest/m0/broadcast-epoch/README.md | 10 +++++---- quest/m0/broadcast-epoch/restart.md | 35 ++++++++++++++++++++--------- quest/m1/js-probe-lifetime.md | 14 +++++++----- quest/m1/rust-untimed-default.md | 3 +++ 4 files changed, 41 insertions(+), 21 deletions(-) diff --git a/quest/m0/broadcast-epoch/README.md b/quest/m0/broadcast-epoch/README.md index b85c6b6797..f2c7f1efc4 100644 --- a/quest/m0/broadcast-epoch/README.md +++ b/quest/m0/broadcast-epoch/README.md @@ -7,8 +7,9 @@ first-party publisher reuses a pair for different content. Only routes with the same epoch resume a subscription from the first frame it lacks; a route without one keeps its subscriptions until it goes. So epochs are what make failover seamless, and a restart is a new epoch at the same path: the newest -epoch wins and ends subscriptions to the old one, so viewers re-request -rather than stall on a replaced broadcast. +epoch wins new requests and announce consumers see a `Restart`, so viewers +re-request rather than stall on a replaced broadcast. Subscriptions already +on the old one stay until the application drops them or its route goes. The epoch rides moq-lite 07 announcements and requests as metadata, so the path never changes and every older version and moq-transport keeps working: @@ -65,8 +66,9 @@ Start a relay forwards upstream. This README owns an end-to-end relay test: republish a name while the old publisher's session stays open. A lite-07 viewer and a lite-06 or IETF viewer -both reach the new epoch within one RTT-scale bound rather than the idle -timeout, and killing the newest epoch falls back to a still-live older one. +that follow the announce `Restart` (or END then START) both reach the new +epoch within one RTT-scale bound rather than the idle timeout, and killing the +newest epoch falls back to a still-live older one. ## Required diff --git a/quest/m0/broadcast-epoch/restart.md b/quest/m0/broadcast-epoch/restart.md index 3eb795f3c4..77ce5ea6f1 100644 --- a/quest/m0/broadcast-epoch/restart.md +++ b/quest/m0/broadcast-epoch/restart.md @@ -21,10 +21,15 @@ Decided in planning (2026-10-07, from #4970's review): - **Source identity.** On a route without an epoch, a different route entry is a different source: any other announcing session, a peer reconnect included. Routes without an epoch can't resume across routes anyway, so - the restart is honest. Rust's origin delivers an `Update` today when an - epochless winner moves to another entry (`CursorState::update` compares - `(hops, cost, source, epoch)`), while JS already ends and restarts it - (`#runAnnounced` diffs entry identity). Both deliver `Restart` after this. + the restart is honest. Rust's origin (`TableCursor::update`, `origin.rs`) + compares `(hops, cost, source, epoch)`, servability, and captures: an + epochless winner moving to another entry is an `Update`, or nothing at all + when that metadata is identical (a reconnect under an identical route is + deliberately invisible today). Change detection must key on the entry id, + reversing that dedupe. A newer epoch already goes out as unannounce then + announce there (the `prev.3 != meta.3` branch), which becomes the + `Restart`. JS already ends and restarts on a new entry (`#runAnnounced` + diffs entry identity). Both deliver `Restart` after this. - **Sticky subscriptions (reverses the 2026-10-06 hard switch).** A newer epoch no longer ends subscriptions in flight with `Unroutable` (`Pick::Superseded`, `front.rs` `supersede`). The front keeps serving its @@ -38,21 +43,29 @@ Decided in planning (2026-10-07, from #4970's review): (`origin.rs` `Consumer::request`, `pick`), so a player that re-requests gets the dead broadcast again. JS already swaps per path. - **Wire.** lite-07 (`moq-lite-07-wip`) gains an explicit restart message on - the announce stream, with the draft text and changelog. Older lite - versions and moq-transport send `ANNOUNCE_END` then `ANNOUNCE_START`, the - draft's existing replacement rule, and a receiver delivers an end and start - for the same prefix that are both pending before delivery as one `Restart` - (no timer). `ANNOUNCE_UPDATE` stays a metadata update with no content claim. + the announce stream. 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 + start for the same prefix that are both pending before delivery as one + `Restart` (no timer). `ANNOUNCE_UPDATE` stays a metadata update with no + content claim. Draft edits in `drafts/draft-lcurley-moq-lite.md`: the new + message; the routing rule that a per-subscriber winner change travels as + ANNOUNCE_UPDATE (it does only without a source change); the SHOULD that a + newer Epoch ends older subscriptions with UNROUTABLE (removed); and the + lite-07 changelog's "ends subscriptions to the older one". - **Players.** `moq play` (#4970) and `@moq/watch` restart on `Restart` instead of treating every epochless `Update` as a restart, so a GOAWAY drain or re-price no longer restarts playback. `moqsrc` (planned in #4960) switches on `Restart` too. Tests: a newer epoch and an epochless source change each deliver `Restart` -in both languages; a re-price delivers `Update`; an old subscription keeps +in both languages, including a reconnect with identical route metadata; a +re-price delivers `Update`; an old subscription keeps receiving after a replacement until its route goes; a re-request after `Restart` resolves the new route, over a relay on lite-06, lite-07, and -moq-transport. Run `just drafts check` and `just test interop --all`. +moq-transport. On lite-06 and moq-transport, coalescing depends on read +timing, so those tests accept `Restart` or `End` then `Start`. 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. diff --git a/quest/m1/js-probe-lifetime.md b/quest/m1/js-probe-lifetime.md index 0e9b50dd9f..b0c5ff0bf7 100644 --- a/quest/m1/js-probe-lifetime.md +++ b/quest/m1/js-probe-lifetime.md @@ -15,14 +15,16 @@ with the serve (`TrackInfoServe`, `rs/moq-net/src/lite/publisher.rs`). - `resolveTrackInfo` (`js/net/src/broadcast.ts`) takes an `AbortSignal` and pins demand only while a requester holds the query. -- The lite publisher (`runTrackInfo` and `#resolveTrackInfo` in - `js/net/src/lite/publisher.ts`) watches the TRACK stream for a reset and - releases its hold; the shared per-front query ends with its last holder. +- The lite publisher (`runTrackInfo`, `runFetch`, and `#resolveTrackInfo` in + `js/net/src/lite/publisher.ts`) watches the TRACK or FETCH stream for a + reset and releases its hold; the shared per-front query ends with its last + holder. - Fold in the review nit: `removeTrack` on a name cached only by a `consume()` subscription is outside its documented contract; document or refuse it. -Test: a requester that disconnects before the answer drops broadcast -demand, and a second requester keeps it pinned until it leaves too. +Test: a requester (TRACK or FETCH) that disconnects before the answer drops +broadcast demand, and a second requester keeps it pinned until it leaves too. -Public API: `resolveTrackInfo` gains a signal argument. Wire: none. +Public API: none (`resolveTrackInfo` is internal, reached through `Wire`). +Wire: none. diff --git a/quest/m1/rust-untimed-default.md b/quest/m1/rust-untimed-default.md index b44d36dcc0..5dc2bff2c5 100644 --- a/quest/m1/rust-untimed-default.md +++ b/quest/m1/rust-untimed-default.md @@ -23,6 +23,9 @@ timescale as untimed and `@moq/publish` declares milliseconds): media tracks already pin microseconds. The shared timeline itself is [Shared import clock](/quest/m1/shared-clock.md)'s; this quest only makes each data track declare and use it. +- The untimed model (#4822) keeps `Default` as the timescale a lite encoder + declares for an untimed track's send times; that path names + `Timescale::MILLI` explicitly instead. - Update the bindings and `doc/lib` pages that describe the default. Public API: breaking (`Timescale` loses `Default`; an undeclared track is From 0bedda245af2a536bcd7b961cf7845da64d902d6 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 7 Oct 2026 11:07:28 -0700 Subject: [PATCH 03/10] quest: note uncoalesced restarts on older wire Co-Authored-By: Claude Opus 5.5 --- quest/m0/broadcast-epoch/restart.md | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/quest/m0/broadcast-epoch/restart.md b/quest/m0/broadcast-epoch/restart.md index 77ce5ea6f1..440a2bdfc1 100644 --- a/quest/m0/broadcast-epoch/restart.md +++ b/quest/m0/broadcast-epoch/restart.md @@ -57,15 +57,18 @@ Decided in planning (2026-10-07, from #4970's review): - **Players.** `moq play` (#4970) and `@moq/watch` restart on `Restart` instead of treating every epochless `Update` as a restart, so a GOAWAY drain or re-price no longer restarts playback. - `moqsrc` (planned in #4960) switches on `Restart` too. + `moqsrc` (planned in #4960) switches on `Restart` too. On lite-06 and + moq-transport, a pair not coalesced reaches players as `End` then + `Start`: a stop, then a fresh play. Tests: a newer epoch and an epochless source change each deliver `Restart` in both languages, including a reconnect with identical route metadata; a -re-price delivers `Update`; an old subscription keeps -receiving after a replacement until its route goes; a re-request after -`Restart` resolves the new route, over a relay on lite-06, lite-07, and -moq-transport. On lite-06 and moq-transport, coalescing depends on read -timing, so those tests accept `Restart` or `End` then `Start`. Run `just drafts check` and `just test interop --all`. +re-price delivers `Update`; an old subscription keeps receiving after a +replacement until its route goes; a re-request after `Restart` resolves the +new route, over a relay on lite-06, lite-07, and moq-transport. On lite-06 +and moq-transport, coalescing depends on read timing, so those tests accept +`Restart` or `End` then `Start`. 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. From b1cbbeb02a84440d0de0b84fb561d4f3dcbd3f3f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 7 Oct 2026 11:18:38 -0700 Subject: [PATCH 04/10] quest: qualify Restart on older wire and the FETCH Old source Co-Authored-By: Claude Opus 5.5 --- quest/m0/broadcast-epoch/README.md | 7 ++++--- quest/m0/broadcast-epoch/restart.md | 6 ++++-- quest/m1/ietf-peer-fetch-old.md | 5 +++-- quest/m1/rust-untimed-default.md | 6 ++++-- 4 files changed, 15 insertions(+), 9 deletions(-) diff --git a/quest/m0/broadcast-epoch/README.md b/quest/m0/broadcast-epoch/README.md index f2c7f1efc4..9ba41122c7 100644 --- a/quest/m0/broadcast-epoch/README.md +++ b/quest/m0/broadcast-epoch/README.md @@ -7,9 +7,10 @@ first-party publisher reuses a pair for different content. Only routes with the same epoch resume a subscription from the first frame it lacks; a route without one keeps its subscriptions until it goes. So epochs are what make failover seamless, and a restart is a new epoch at the same path: the newest -epoch wins new requests and announce consumers see a `Restart`, so viewers -re-request rather than stall on a replaced broadcast. Subscriptions already -on the old one stay until the application drops them or its route goes. +epoch wins new requests and announce consumers see a `Restart` (or an end +and start on older versions), so viewers re-request rather than stall on a +replaced broadcast. Subscriptions already on the old one stay until the +application drops them or its route goes. The epoch rides moq-lite 07 announcements and requests as metadata, so the path never changes and every older version and moq-transport keeps working: diff --git a/quest/m0/broadcast-epoch/restart.md b/quest/m0/broadcast-epoch/restart.md index 440a2bdfc1..76a0fe97f9 100644 --- a/quest/m0/broadcast-epoch/restart.md +++ b/quest/m0/broadcast-epoch/restart.md @@ -2,8 +2,10 @@ ## Goal -When the source behind a path changes, every announce consumer sees an -explicit `Restart` event, in Rust (`AnnounceEvent::Restart`) and `@moq/net`. +When the source behind a path changes, an announce consumer sees an +explicit `Restart` event, in Rust (`AnnounceEvent::Restart`) and `@moq/net`: +always on lite-07, and on older versions and moq-transport when the END and +START arrive together (otherwise an `End` then a `Start`). The source changes on a newer epoch, or, on a route without an epoch, when a different route entry wins. A re-price or a same-source failover stays an `Update`. Players start on `Start`, restart on `Restart`, and stop on `End`. diff --git a/quest/m1/ietf-peer-fetch-old.md b/quest/m1/ietf-peer-fetch-old.md index cbeecf604c..ebc8d7fd01 100644 --- a/quest/m1/ietf-peer-fetch-old.md +++ b/quest/m1/ietf-peer-fetch-old.md @@ -16,8 +16,9 @@ fails 5 of 5 runs. Seeds: 11977074354257116273, 6755536760138253279, Facts (2026-10-07): the IETF FETCH path (`run_fetch_stream`, `rs/moq-net/src/ietf/publisher.rs`) has no explicit `Old` check. An `Old` can come from reading a group the cache's wall-clock expiry aborts mid-read -(`expire_closed`, `evict_expired_scan` in `model/track.rs`), or from passing -through a lite peer's `StreamError::Old` reset. `Old` has no moq-transport +(`expire_closed`, `evict_expired_scan` in `model/track.rs`), and possibly +from a lite peer's `StreamError::Old` reset passing through (unconfirmed: no +caller chain into this FETCH path was traced). `Old` has no moq-transport code, so it goes out as INTERNAL_ERROR and comes back as `Error::Remote`. - Reproduce with the seeds, find which path refuses, and fix it at the diff --git a/quest/m1/rust-untimed-default.md b/quest/m1/rust-untimed-default.md index 5dc2bff2c5..462cdfc729 100644 --- a/quest/m1/rust-untimed-default.md +++ b/quest/m1/rust-untimed-default.md @@ -24,8 +24,10 @@ timescale as untimed and `@moq/publish` declares milliseconds): [Shared import clock](/quest/m1/shared-clock.md)'s; this quest only makes each data track declare and use it. - The untimed model (#4822) keeps `Default` as the timescale a lite encoder - declares for an untimed track's send times; that path names - `Timescale::MILLI` explicitly instead. + declares for an untimed track's send times, and the lite datagram decoder + falls back with `entry.timescale.unwrap_or_default()` + (`rs/moq-net/src/lite/subscriber.rs`); both name `Timescale::MILLI` + explicitly instead. - Update the bindings and `doc/lib` pages that describe the default. Public API: breaking (`Timescale` loses `Default`; an undeclared track is From 64dc16708b42302e5f10d188113d2eb354dff517 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 7 Oct 2026 12:25:20 -0700 Subject: [PATCH 05/10] quest: leave announce-blind viewers on a replaced broadcast open Co-Authored-By: Claude Opus 5.5 --- quest/m0/broadcast-epoch/restart.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/quest/m0/broadcast-epoch/restart.md b/quest/m0/broadcast-epoch/restart.md index 827ef7def7..89f8c22202 100644 --- a/quest/m0/broadcast-epoch/restart.md +++ b/quest/m0/broadcast-epoch/restart.md @@ -24,9 +24,10 @@ Decided in planning (2026-10-07, from #4970's review): better route without an epoch takes over with a hard switch, ending subscriptions in flight. The maintainer chose sticky subscriptions plus `Restart` instead (2026-10-07), and this quest absorbs the rest of that - takeover plan. Accepted consequence: a client that doesn't read announces - (IETF subscribers, the go/python/c interop clients, third-party players) - stays on a replaced broadcast until its route goes. + takeover plan. Open for the maintainer: a client that doesn't read + announces (IETF subscribers, the go/python/c interop clients, third-party + players) stays on a replaced broadcast until its route goes. Settle + whether that is accepted before implementing. - **Source identity.** On a route without an epoch, a different route entry is a different source: any other announcing session, a peer reconnect included. Routes without an epoch can't resume across routes anyway, so From 612b01199669a3aa1872050838c36a493c57f790 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 7 Oct 2026 12:27:32 -0700 Subject: [PATCH 06/10] quest: the maintainer accepts announce-blind viewers staying on a replaced broadcast Reverts 64dc16708; confirmed by the maintainer 2026-10-07. Co-Authored-By: Claude Opus 5.5 --- quest/m0/broadcast-epoch/restart.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/quest/m0/broadcast-epoch/restart.md b/quest/m0/broadcast-epoch/restart.md index 89f8c22202..827ef7def7 100644 --- a/quest/m0/broadcast-epoch/restart.md +++ b/quest/m0/broadcast-epoch/restart.md @@ -24,10 +24,9 @@ Decided in planning (2026-10-07, from #4970's review): better route without an epoch takes over with a hard switch, ending subscriptions in flight. The maintainer chose sticky subscriptions plus `Restart` instead (2026-10-07), and this quest absorbs the rest of that - takeover plan. Open for the maintainer: a client that doesn't read - announces (IETF subscribers, the go/python/c interop clients, third-party - players) stays on a replaced broadcast until its route goes. Settle - whether that is accepted before implementing. + takeover plan. Accepted consequence: a client that doesn't read announces + (IETF subscribers, the go/python/c interop clients, third-party players) + stays on a replaced broadcast until its route goes. - **Source identity.** On a route without an epoch, a different route entry is a different source: any other announcing session, a peer reconnect included. Routes without an epoch can't resume across routes anyway, so From 0ef6b1513b9ed8acc35e8661bfe093870542fe48 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 7 Oct 2026 14:09:28 -0700 Subject: [PATCH 07/10] quest: scope round-trip restart recovery to epochs; keep the hash tiebreak Co-Authored-By: Claude Opus 5.5 --- quest/m0/broadcast-epoch/README.md | 9 ++++++--- quest/m0/broadcast-epoch/restart.md | 13 ++++++++++++- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/quest/m0/broadcast-epoch/README.md b/quest/m0/broadcast-epoch/README.md index 6551506894..88c58193c0 100644 --- a/quest/m0/broadcast-epoch/README.md +++ b/quest/m0/broadcast-epoch/README.md @@ -10,7 +10,10 @@ failover seamless, and a restart is a new epoch at the same path: the newest epoch wins new requests and announce consumers see a `Restart` (or an end and start on older versions), so viewers re-request rather than stall on a replaced broadcast. Subscriptions already on the old one stay until the -application drops them or its route goes. +application drops them or its route goes. Recovery within a round trip needs +an epoch: without one, a restarted publisher that loses the routing hash to +its lingering old session is reached only once that session closes and its +route is withdrawn. The epoch rides moq-lite 07 announcements and requests as metadata, so the path never changes and every older version and moq-transport keeps working: @@ -68,8 +71,8 @@ the IETF resume point and lite-05/06 `widen_frame_bounds` still serve any mid-group start: a public `Subscription::with_start` or a downstream Frame Start a relay forwards upstream. -This README owns an end-to-end relay test: republish a name while the old -publisher's session stays open. A lite-07 viewer and a lite-06 or IETF viewer +This README owns an end-to-end relay test: republish a name under a new +epoch while the old publisher's session stays open. A lite-07 viewer and a lite-06 or IETF viewer that follow the announce `Restart` (or END then START) both reach the new epoch within one RTT-scale bound rather than the idle timeout, and killing the newest epoch falls back to a still-live older one. diff --git a/quest/m0/broadcast-epoch/restart.md b/quest/m0/broadcast-epoch/restart.md index 827ef7def7..20b8bfcc9a 100644 --- a/quest/m0/broadcast-epoch/restart.md +++ b/quest/m0/broadcast-epoch/restart.md @@ -45,6 +45,13 @@ Decided in planning (2026-10-07, from #4970's review): an equal-cost pool (a transcoder worker joining, a relay peer reconnecting), restarts the paths it moves. Two routes with the same epoch and identical metadata are a seamless failover and stay invisible. +- **Equal-cost restarts keep the hash (2026-10-07).** The rendezvous-hash + tiebreak stays, with no newest-wins: a transcode pool's replicas advertise + the same path, and newest-wins would churn every viewer on each worker + restart. So recovery within a round trip needs an epoch. Without one, a + restarted publisher that loses the hash to its lingering old session wins, + and sends `Restart`, only once that session closes (idle timeout) and its + route is withdrawn. - **Sticky subscriptions (reverses the 2026-10-06 hard switch).** A newer epoch no longer ends subscriptions in flight with `Unroutable` (`Pick::Superseded`, `front.rs` `supersede`). The front keeps serving its @@ -99,7 +106,11 @@ announces, while the incumbent's subscriptions continue) and `identical_reannounce_is_invisible` (`origin.rs`) for routes without an epoch. `route_dies_without_an_epoch` keeps its expectation, but its `standby()` prices `B` strictly worse so it doesn't win before the trigger, -and a re-request then lands on `B`. Run `just drafts check` and +and a re-request then lands on `B`. Add a regression case where an +epochless replacement at equal cost loses the hash to the lingering 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. Run +`just drafts check` and `just test interop --all`. Docs: update `doc/concept/moq-lite.md` (publisher epochs) and From 0ec672980881730ad352afd967d02bc19a8d992c Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 7 Oct 2026 14:37:08 -0700 Subject: [PATCH 08/10] quest: Restart updates the gateway and cluster docs and #4962's gateway tests Co-Authored-By: Claude Opus 5.5 --- quest/m0/broadcast-epoch/restart.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/quest/m0/broadcast-epoch/restart.md b/quest/m0/broadcast-epoch/restart.md index 20b8bfcc9a..e4380d797d 100644 --- a/quest/m0/broadcast-epoch/restart.md +++ b/quest/m0/broadcast-epoch/restart.md @@ -59,7 +59,9 @@ Decided in planning (2026-10-07, from #4970's review): on the old route while any downstream still holds it. JS `route()` (`js/net/src/origin.ts`) closes the old front when it swaps to a better entry, so it changes too. Update the epoch tests from #4942 and #4962's - gateway tests that assert `Unroutable`. + three gateway tests that assert `Unroutable` (`a_reconnect_replaces_the_stale_*` + in `moq-rtmp` `server.rs`, `moq-srt` `ts.rs`, and `moq-rtc` `whip.rs`): the + stale viewer keeps receiving, and a fresh request reaches the reconnect. - **No pinned joins.** `Consumer::request` joins an existing front only while the route it resolved through still wins. Since #4942 an epochless front stays on its first route (`pick`'s `Some(None)` arm in `origin.rs`, the @@ -114,7 +116,10 @@ withdrawn, then `Restart` and a re-request lands on the replacement. Run `just test interop --all`. Docs: update `doc/concept/moq-lite.md` (publisher epochs) and -`doc/lib/{rs,js}` announce sections inline. +`doc/lib/{rs,js}` announce sections inline, plus `doc/bin/rtmp.md`, +`doc/bin/srt.md`, `doc/bin/rtc.md`, and `doc/bin/relay/cluster.md`, which +(after #4962) describe the hard switch: a reconnect "replaces it at once" and +stale subscriptions end with `Unroutable`. Public API: breaking, a new `AnnounceEvent::Restart` variant (Rust) and `"restart"` kind (JS). Wire: a new lite-07 announce message; older versions From aa9bbb707c2e70e683ce983050924aa162c7d971 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 7 Oct 2026 16:18:44 -0700 Subject: [PATCH 09/10] quest: scope Restart's hash caveat to distinct hop chains and qualify the player promise --- quest/m0/broadcast-epoch/README.md | 7 ++++--- quest/m0/broadcast-epoch/restart.md | 26 +++++++++++++++----------- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/quest/m0/broadcast-epoch/README.md b/quest/m0/broadcast-epoch/README.md index 88c58193c0..fb66418903 100644 --- a/quest/m0/broadcast-epoch/README.md +++ b/quest/m0/broadcast-epoch/README.md @@ -10,9 +10,10 @@ failover seamless, and a restart is a new epoch at the same path: the newest epoch wins new requests and announce consumers see a `Restart` (or an end and start on older versions), so viewers re-request rather than stall on a replaced broadcast. Subscriptions already on the old one stay until the -application drops them or its route goes. Recovery within a round trip needs -an epoch: without one, a restarted publisher that loses the routing hash to -its lingering old session is reached only once that session closes and its +application drops them or its route goes. Without an epoch, a restarted +publisher on the same hop chain as its lingering old session wins at once +(the newest announcement breaks the tie), but one on a different chain that +loses the routing hash is reached only once the old session closes and its route is withdrawn. The epoch rides moq-lite 07 announcements and requests as metadata, so the diff --git a/quest/m0/broadcast-epoch/restart.md b/quest/m0/broadcast-epoch/restart.md index e4380d797d..b056502d19 100644 --- a/quest/m0/broadcast-epoch/restart.md +++ b/quest/m0/broadcast-epoch/restart.md @@ -46,12 +46,14 @@ Decided in planning (2026-10-07, from #4970's review): reconnecting), restarts the paths it moves. Two routes with the same epoch and identical metadata are a seamless failover and stay invisible. - **Equal-cost restarts keep the hash (2026-10-07).** The rendezvous-hash - tiebreak stays, with no newest-wins: a transcode pool's replicas advertise + tiebreak stays ahead of recency: a transcode pool's replicas advertise the same path, and newest-wins would churn every viewer on each worker - restart. So recovery within a round trip needs an epoch. Without one, a - restarted publisher that loses the hash to its lingering old session wins, - and sends `Restart`, only once that session closes (idle timeout) and its - route is withdrawn. + restart. `route_order` already breaks a full tie (same hop chain, so the + same hash) by the newest announcement, so a restart on the old session's + chain wins at once. Without an epoch, a restart on a different chain (say, + through another relay) that loses the hash to its lingering old session + wins, and sends `Restart`, only once that session closes (idle timeout) + and its route is withdrawn. - **Sticky subscriptions (reverses the 2026-10-06 hard switch).** A newer epoch no longer ends subscriptions in flight with `Unroutable` (`Pick::Superseded`, `front.rs` `supersede`). The front keeps serving its @@ -83,8 +85,9 @@ Decided in planning (2026-10-07, from #4970's review): subscription between routes without an Epoch "stays on its route and ends with it" holds as written. - **Players.** `moq play` (#4970) and `@moq/watch` restart on `Restart` - instead of treating every epochless `Update` as a restart, so a GOAWAY - drain or re-price no longer restarts playback. + instead of treating every epochless `Update` as a restart, so a re-price + that keeps its winner, or a same-epoch failover, no longer restarts + playback. An epochless drain onto a different entry still does. `moqsrc` (planned in #4960) switches on `Restart` too. On lite-06 and moq-transport, a pair not coalesced reaches players as `End` then `Start`: a stop, then a fresh play. @@ -92,8 +95,8 @@ Decided in planning (2026-10-07, from #4970's review): `SessionClosed` (#4999), since players recover on announcements, not errors; announcing only once the old front dies, since viewers stay blank until the old session times out; switching only on a strictly better - route, which leaves an equal-cost restart blank; the newest winning ties, - which lets a joining worker take every tied path; and waiting for lite-07 + route, which leaves an equal-cost restart blank; the newest winning ahead + of the hash, which lets a joining worker take every tied path; and waiting for lite-07 to be the default. Tests: a newer epoch and an epochless source change each deliver `Restart` @@ -109,8 +112,9 @@ announces, while the incumbent's subscriptions continue) and epoch. `route_dies_without_an_epoch` keeps its expectation, but its `standby()` prices `B` strictly worse so it doesn't win before the trigger, and a re-request then lands on `B`. Add a regression case where an -epochless replacement at equal cost loses the hash to the lingering old -route: no `Restart` and new requests stay on the old route until it is +epochless replacement at equal cost, on a different hop chain chosen to lose +the hash, competes with the lingering 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. Run `just drafts check` and `just test interop --all`. From 8217559511d37351476a7afc8a4383a6e5168d89 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 7 Oct 2026 17:15:38 -0700 Subject: [PATCH 10/10] quest: Restart's hash case needs equal-length chains; correct gateway and doc pointers --- quest/m0/broadcast-epoch/README.md | 6 +++--- quest/m0/broadcast-epoch/restart.md | 31 ++++++++++++++++------------- 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/quest/m0/broadcast-epoch/README.md b/quest/m0/broadcast-epoch/README.md index fb66418903..a2e33c5674 100644 --- a/quest/m0/broadcast-epoch/README.md +++ b/quest/m0/broadcast-epoch/README.md @@ -12,9 +12,9 @@ and start on older versions), so viewers re-request rather than stall on a replaced broadcast. Subscriptions already on the old one stay until the application drops them or its route goes. Without an epoch, a restarted publisher on the same hop chain as its lingering old session wins at once -(the newest announcement breaks the tie), but one on a different chain that -loses the routing hash is reached only once the old session closes and its -route is withdrawn. +(the newest announcement breaks the tie), but one on a different chain of +the same length and cost that loses the routing hash is reached only once +the old session closes and its route is withdrawn. The epoch rides moq-lite 07 announcements and requests as metadata, so the path never changes and every older version and moq-transport keeps working: diff --git a/quest/m0/broadcast-epoch/restart.md b/quest/m0/broadcast-epoch/restart.md index b056502d19..f42392d8a2 100644 --- a/quest/m0/broadcast-epoch/restart.md +++ b/quest/m0/broadcast-epoch/restart.md @@ -50,8 +50,9 @@ Decided in planning (2026-10-07, from #4970's review): the same path, and newest-wins would churn every viewer on each worker restart. `route_order` already breaks a full tie (same hop chain, so the same hash) by the newest announcement, so a restart on the old session's - chain wins at once. Without an epoch, a restart on a different chain (say, - through another relay) that loses the hash to its lingering old session + chain wins at once. Without an epoch, a restart on a different chain of + the same length and cost (say, a single hop to another origin) that loses + the hash to its lingering old session wins, and sends `Restart`, only once that session closes (idle timeout) and its route is withdrawn. - **Sticky subscriptions (reverses the 2026-10-06 hard switch).** A newer @@ -60,9 +61,10 @@ Decided in planning (2026-10-07, from #4970's review): subscribers until its route goes. A relay keeps its upstream subscription on the old route while any downstream still holds it. JS `route()` (`js/net/src/origin.ts`) closes the old front when it swaps to a better - entry, so it changes too. Update the epoch tests from #4942 and #4962's - three gateway tests that assert `Unroutable` (`a_reconnect_replaces_the_stale_*` - in `moq-rtmp` `server.rs`, `moq-srt` `ts.rs`, and `moq-rtc` `whip.rs`): the + entry, so it changes too. Update the epoch tests from #4942 and the three + gateway tests that assert `Unroutable` (`a_reconnect_replaces_the_stale_*` + in `moq-rtmp` `server.rs`, `moq-srt` `ts.rs`, and `moq-rtc` + `server/whip.rs`): the stale viewer keeps receiving, and a fresh request reaches the reconnect. - **No pinned joins.** `Consumer::request` joins an existing front only while the route it resolved through still wins. Since #4942 an epochless front @@ -110,20 +112,21 @@ and moq-transport, coalescing depends on read timing, so those tests accept announces, while the incumbent's subscriptions continue) and `identical_reannounce_is_invisible` (`origin.rs`) for routes without an epoch. `route_dies_without_an_epoch` keeps its expectation, but its -`standby()` prices `B` strictly worse so it doesn't win before the trigger, -and a re-request then lands on `B`. Add a regression case where an -epochless replacement at equal cost, on a different hop chain chosen to lose -the hash, competes with the lingering old route: no `Restart` and new -requests stay on the old route until it is +`standby()` prices `B` strictly worse so it doesn't win before the trigger +(check its other callers in `route_change.rs`), and a re-request then lands +on `B`. Add a regression case where an epochless replacement at equal cost +and chain length, on a different hop chain whose hash is the larger one (the +pinned `pool/job-0` pair in `origin.rs` has one), competes with the lingering +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. Run -`just drafts check` and -`just test interop --all`. +`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`, `doc/bin/srt.md`, `doc/bin/rtc.md`, and `doc/bin/relay/cluster.md`, which -(after #4962) describe the hard switch: a reconnect "replaces it at once" and -stale subscriptions end with `Unroutable`. +describe the hard switch: a reconnect "replaces it at once", stale +subscriptions end with `Unroutable` (`rtmp.md`, `srt.md`), and a flapping +moq-lite 07 link "cuts the viewers" (`cluster.md`). Public API: breaking, a new `AnnounceEvent::Restart` variant (Rust) and `"restart"` kind (JS). Wire: a new lite-07 announce message; older versions