diff --git a/quest/m0/broadcast-epoch/README.md b/quest/m0/broadcast-epoch/README.md index 3d83f9489a..a1e4f77c55 100644 --- a/quest/m0/broadcast-epoch/README.md +++ b/quest/m0/broadcast-epoch/README.md @@ -5,10 +5,16 @@ A path and the epoch on its route are the only content identity, and no first-party publisher reuses a pair for different content. Only routes with the same epoch resume a subscription from the first frame it lacks; a route -without one keeps its subscriptions until a better route takes over. So epochs are what make +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` (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. 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 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: @@ -33,14 +39,17 @@ Decided: - The route's epoch is taken as given: nothing mints one by default (decided 2026-10-06). Each first-party publisher mints one per run and announces it; a replica announces a shared one. A route without one, such as a - transcoder's prefix claim, is never stitched to another worker's output: a - better route takes over with a hard switch and an announcement (decided - 2026-10-07, replacing "stays on the worker that first served a - subscription", which let dead routes linger). -- The newest epoch wins a prefix ahead of cost (decided 2026-10-06), and - replaces the old one with a hard switch: subscriptions in flight end with - `Unroutable`. When it goes and an older one is still live, the older one - wins again as a new broadcast. + transcoder's prefix claim, is never stitched to another worker's output. + A better route without an epoch wins new requests and is announced as a + `Restart`, replacing "stays on the worker that first served a + subscription", which let dead routes linger (decided 2026-10-07). +- The newest epoch wins a prefix ahead of cost (decided 2026-10-06). The + hard switch that ended subscriptions in flight with `Unroutable` (decided + 2026-10-06 for epochs, and 2026-10-07 in #5013 for routes without one) 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, @@ -63,15 +72,16 @@ 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 -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. +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. ## Required - [Apps](/quest/m0/broadcast-epoch/apps.md) - moq-cli, the browser publish and watch components, and demo/web restart into a new epoch and reset on the switch -- [Un-epoched takeover](/quest/m0/broadcast-epoch/unepoched-takeover.md) - a better route without an epoch takes over with a hard switch and an announcement, so a restarted lite-06 publisher reaches viewers +- [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 - [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 - [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 diff --git a/quest/m0/broadcast-epoch/restart.md b/quest/m0/broadcast-epoch/restart.md new file mode 100644 index 0000000000..097a1d36b6 --- /dev/null +++ b/quest/m0/broadcast-epoch/restart.md @@ -0,0 +1,137 @@ +# [L] A replaced broadcast restarts announce consumers, and subscriptions stay sticky + +## Goal + +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`. + +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): + +- **Supersedes #5013's hard switch.** #5013 decided (2026-10-07) that a + 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. +- **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 (`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. + Any new winner counts, including one decided by the rendezvous-hash + tiebreak: without an epoch a relay can't tell a restarted publisher from + an extra replica. So a cost change that moves the winner, or a change to + 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 ahead of recency: a transcode pool's replicas advertise + 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 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 + 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. 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 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 + stays on its first route (`pick`'s `Some(None)` arm in `origin.rs`, the + module docs in `model/front.rs`), so a request joins it even after the + winner changed, and 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. 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". The rule that a + 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 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. +- **Rejected** (in #5013): `@moq/watch` resubscribing on `Internal` or + `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 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` +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`. Flip `better_route_keeps_the_incumbent` +(`rs/moq-net/tests/route_change.rs`: the better route wins new requests and +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 +(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`. + +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 +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 +send END then START where they sent UPDATE for a source change. + +## Related + +- [Apps](/quest/m0/broadcast-epoch/apps.md) - #4970 drives `@moq/watch` and `moq play` from announcements, which this relies on diff --git a/quest/m0/broadcast-epoch/unepoched-takeover.md b/quest/m0/broadcast-epoch/unepoched-takeover.md deleted file mode 100644 index c551edb8d3..0000000000 --- a/quest/m0/broadcast-epoch/unepoched-takeover.md +++ /dev/null @@ -1,70 +0,0 @@ -# [M] A better route without an epoch takes over - -## Goal - -When a path's routes carry no epoch, whichever route `route_order` ranks first -always serves it. A new winner takes over with a hard switch, as a newer epoch -does: subscriptions in flight end, and downstream sessions see the switch as an -announcement (End then Start), even when the new route's metadata matches the -old. Viewers re-request and land on the new route, never stitched across the -two. This holds on every version, lite-07 included, for any route without an -epoch. - -So a lite-06 publisher that restarts onto the same path while its old session -lingers reaches viewers within an RTT, instead of leaving them on a dead front -until a reload ([#4970](https://github.com/moq-dev/moq/pull/4970)'s open -decision, and the second case in -[#4999](https://github.com/moq-dev/moq/pull/4999)). - -## Plan - -Decided 2026-10-07 while re-planning #4999 and #5001: - -- Routes without an epoch are no longer sticky. Since - [#4942](https://github.com/moq-dev/moq/pull/4942), a front without an epoch - stays on its first route (`pick`'s `Some(None)` arm in - `rs/moq-net/src/model/origin.rs`, the module docs in `model/front.rs`), so a - dead route lingers and a new request joins it. Before #4942 a better route - was re-announced, and this restores that. -- The switch is hard, like a newer epoch's: the old front ends and its tracks - in flight end, so nothing splices bytes from two publishers. That keeps the - README's "never stitched" rule while dropping "stays on the worker that - first served a subscription". -- Any new winner takes over, including one decided by the rendezvous-hash - tiebreak. Without an epoch a relay can't tell a restarted publisher from an - extra replica, so there is no narrower rule that fixes restarts. -- Consequence, accepted: any change of winner restarts playback for the paths - it moves, on every version. That includes a cost change (a GOAWAY drain - re-price) and a change to an equal-cost pool (a transcoder worker joining, a - relay peer reconnecting), which moves the paths the hash now gives the new - route. Routes with an epoch are unchanged. -- A change of winner between routes without an epoch always reaches downstream - as an announcement, even with identical metadata. Today the announce cursor - hides it ("a reconnect under an identical route is invisible"). Two routes - with the same epoch and identical metadata are a seamless failover and stay - invisible. -- Rejected: `@moq/watch` resubscribing on `Internal` or `SessionClosed` - (#4999), since players recover on announcements, not errors; re-announcing - only once the old front dies, since viewers stay blank until the old session - times out; taking over only on a strictly better route, which leaves an - equal-cost restart blank; the newest winning ties, which lets a joining - worker take every tied path; and waiting for lite-07 to be the default. - -Tests to flip: `better_route_keeps_the_incumbent` in -`rs/moq-net/tests/route_change.rs`, and `identical_reannounce_is_invisible` in -`origin.rs` for routes without an epoch. `route_dies_without_an_epoch` keeps -its expectation (a dead route ends its subscriptions), but its `standby()` -must price `B` strictly worse, or it takes over before the trigger; extend it -so a re-request lands on `B`. js/net's origin already swaps to a better entry -and closes the old front (`route()` in `js/net/src/origin.ts`), and its -announce cursor emits End and Start on a new winner, so the JS half is likely -tests only. - -Wire: no new messages, but a behavior change for other relays. -`drafts/draft-lcurley-moq-lite.md` says a subscription between routes without -an Epoch "stays on its route and ends with it". Replace that with: the relay -ends it when another route wins, and announces the change. - -## Related - -- [Apps](/quest/m0/broadcast-epoch/apps.md) - #4970 drives `@moq/watch` and `moq play` from announcements, which this relies on diff --git a/quest/m1/README.md b/quest/m1/README.md index ca79f7c978..7d8479b21e 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -29,6 +29,7 @@ blocks. The quests that gated m0 lines moved under them. - [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 - [Untimed decisions](/quest/m1/untimed-decisions.md) - the maintainer decides whether moq-archive keeps refusing untimed tracks, and whether a malformed FETCH object ends its track +- [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 - [CMAF sample defaults](/quest/m1/cmaf-sample-defaults.md) - one trun, tfhd, trex resolver and one keyframe rule in the importer, the Rust decoder, and JS - [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 @@ -52,18 +53,20 @@ blocks. The quests that gated m0 lines moved under them. - [Subscribe ranges](/quest/m1/subscribe-ranges/README.md) - a lite-07 SUBSCRIBE asks for past and live ranges in either order and replaces FETCH; relays fill misses by range, including over moq-transport - [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 -- [IETF peer FETCH refused old](/quest/m1/ietf-peer-fetch-old.md) - a cross-relay FETCH over a moq-transport peer link returns groups the origin still holds +- [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 - [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 - [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 - [cpal loads libasound at runtime](/quest/m1/cpal-alsa-runtime.md) - condition: a cpal release whose Linux build carries no load-time libasound requirement diff --git a/quest/m1/ietf-peer-fetch-old.md b/quest/m1/ietf-peer-fetch-old.md index 776d1d5a74..387e7229cf 100644 --- a/quest/m1/ietf-peer-fetch-old.md +++ b/quest/m1/ietf-peer-fetch-old.md @@ -1,18 +1,35 @@ -# [S] IETF peer FETCH refused old +# [M] Cross-relay FETCH over moq-transport serves held groups ## Goal -A cross-relay FETCH over a `moq-transport` peer link returns every group the origin still holds, instead of refusing some as `old`. -The cluster burst drill then runs a third peer link variant over `moq-transport-19`, and its impaired lane passes. +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 `PeerLink` variant, and its +impaired lane passes. ## Plan -Repro: in `rs/moq-relay/tests/drills.rs::cross_cluster`, pin the edge's peer link with `config.connect.version = ["moq-transport-19"]`. -The steady impaired drill then failed in 5 of 5 runs with FETCHes refused `old` for groups the origin still held, e.g. `groups lost: [(5, Failed("old")), (37, Failed("old")), (41, Failed("old"))]`. -Seeds: 11977074354257116273, 6755536760138253279, 17984197590923068922, 10974424043511566981. -The loopback lane and the flapping drill passed. +Found by #4972: in `rs/moq-relay/tests/drills.rs::cross_cluster`, pinning the +edge's peer link with `config.connect.version = ["moq-transport-19"]` makes +the steady impaired drill fail 5 of 5 runs with FETCHes refused `old` for +groups the origin still held, e.g. `groups lost: [(5, Failed("old")), +(37, Failed("old")), (41, Failed("old"))]`. Seeds: 11977074354257116273, +6755536760138253279, 17984197590923068922, 10974424043511566981. The +loopback lane and the flapping drill passed. -Find where the IETF FETCH path decides a group is `old`, fix it at the source, then add the IETF peer link as a `PeerLink` variant. +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`), 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 + 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 diff --git a/quest/m1/interop-browser-timeouts.md b/quest/m1/interop-browser-timeouts.md new file mode 100644 index 0000000000..1dd84434af --- /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, since landed), which may have been the cause: an +FFI publisher's 2 s audio max age parked 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 fixed 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..b0c5ff0bf7 --- /dev/null +++ b/quest/m1/js-probe-lifetime.md @@ -0,0 +1,30 @@ +# [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`, `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 (TRACK or FETCH) that disconnects before the answer drops +broadcast demand, and a second requester keeps it pinned until it leaves too. + +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 new file mode 100644 index 0000000000..10a28b4543 --- /dev/null +++ b/quest/m1/rust-untimed-default.md @@ -0,0 +1,38 @@ +# [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. +- The untimed model (#4822) keeps `Default` as the timescale a lite encoder + 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 +untimed). Wire: none. + +## Related + +- [Publishing never invents a timestamp](/quest/m1/publish-timestamp.md) - the same publishers stop filling in a timestamp