diff --git a/quest/m1/README.md b/quest/m1/README.md index 1a17f595ea..7fbf04734d 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -76,6 +76,7 @@ QUIC studies there on that rule. - [Publishing never invents a timestamp](/quest/m1/publish-timestamp.md) - no Rust or binding publish API fills in a timestamp; an untimed payload goes out untimed - [JS publishing never invents a timestamp](/quest/m1/js-publish-timestamp.md) - the same in @moq/json, @moq/flate, and @moq/net - [Untimed model](/quest/m1/untimed-model.md) - moq-net frames and datagrams carry an absent timestamp to every subscriber; no receiver fills in arrival time +- [Untimed failover](/quest/m1/untimed-failover.md) - a resumed group no route continues is given up even when media time can't judge its drift - [JS untimed model](/quest/m1/js-untimed-model.md) - the same in @moq/net - [Untimed lite-07](/quest/m1/lite-untimed.md) - lite-07 encodes an absent timestamp in both languages; lite-05/06 write send time - [Plan: untimed verbatim PES](/quest/m1/plan-ts-pes-untimed.md) - decide how a verbatim TS track carries a PES that has no PTS, then write the implementation quest diff --git a/quest/m1/js-group-handover.md b/quest/m1/js-group-handover.md index 8787785850..f3492702de 100644 --- a/quest/m1/js-group-handover.md +++ b/quest/m1/js-group-handover.md @@ -21,7 +21,7 @@ old relay drops its upstream pull, the new relay subscribes upstream from scratch at the live edge. A group boundary that lands inside that window loses the group in flight: about 1 run in 5 at 100 ms groups. -Rust solves this with #4741's single-writer pump (`model/pump.rs`): each track a +Rust solves this with #4741's single-writer pump (`model/resume.rs`): each track a front serves is one producer, a route change subscribes the new route from the first frame the logical track lacks (mid-group), an open group is continued in place, duplicates are dropped by frame index, and the old route @@ -40,6 +40,10 @@ settle along the way: properties differ (timescale, retention, priority, order). Match it. - `js/watch` and `js/hang` consumers that re-subscribe on `active` changes. Check whether they still need to. +- Giving up a resumed group no route continues. Mirror Rust's rule from + [Untimed failover](/quest/m1/untimed-failover.md): when media time can't + judge its drift, give it up once the new route holds a newer group and + nothing can still fill it. Add unit coverage at the origin level against stand-in sessions, then flip `test/drain` to zero budget (drop the resubscribe loop in `drain.ts` and the diff --git a/quest/m1/js-untimed-model.md b/quest/m1/js-untimed-model.md index c5d8dc6a5f..15e25b840d 100644 --- a/quest/m1/js-untimed-model.md +++ b/quest/m1/js-untimed-model.md @@ -27,6 +27,11 @@ Things to look out for: a track that never declared one must not claim a timeline downstream. - A FETCH keeps timestamps whenever the track or the object gives units. Only objects with neither are untimed. +- On drafts 14-16, where SUBSCRIBE_OK can't carry TIMESCALE, the publisher + writes an object-scope TIMESCALE beside each Timestamp (the `stamped` path + in `js/net/src/ietf/publisher.ts`), as Rust does once #4822 lands. This + replaces the JS half of [IETF timestamp + units](/quest/m1/ietf-timestamp-units.md), which planned to send none there. - `Frame.timestamp` and `Datagram.timestamp` become optional. Update callers in js/hang and js/loc (end markers) and anything in js/watch that reads them. @@ -34,7 +39,8 @@ Things to look out for: Test: an untimed frame survives a JS subscribe on each receive path. Run `just test interop --all`. -Public API: breaking. Wire: none. +Public API: breaking. Wire: on drafts 14-16, timed objects gain an +object-scope TIMESCALE beside the Timestamp; nothing else changes. ## Related diff --git a/quest/m1/test-flakes-2/README.md b/quest/m1/test-flakes-2/README.md index c48c60452b..345570e472 100644 --- a/quest/m1/test-flakes-2/README.md +++ b/quest/m1/test-flakes-2/README.md @@ -39,6 +39,7 @@ Public API: none. Wire: none. - [moq-mux debounce clock](/quest/m1/test-flakes-2/mux-debounce-clock.md) - the TS export debounce test advances on the paused clock - [js/publish audio clock](/quest/m1/test-flakes-2/publish-audio-clock.md) - the audio encoder delay test runs on mock time - [WebSocket paused TLS dial](/quest/m1/test-flakes-2/websocket-paused-tls.md) - the fixed-address WebSocket tests stop pausing the clock over a real dial +- [Rejoin after idle](/quest/m1/test-flakes-2/rejoin-idle-race.md) - an IETF rejoin never gets the stale warm cache first, because the copy goes idle before the cancel ## Related diff --git a/quest/m1/test-flakes-2/rejoin-idle-race.md b/quest/m1/test-flakes-2/rejoin-idle-race.md new file mode 100644 index 0000000000..4437939535 --- /dev/null +++ b/quest/m1/test-flakes-2/rejoin-idle-race.md @@ -0,0 +1,32 @@ +# [S] Rejoin after the copy goes idle + +## Goal + +A reader rejoining a parked IETF track never gets the stale warm cache +first, and moq-tokio `broadcast_rejoin_skips_a_stale_warm_cache` +(`rs/moq-tokio/tests/broadcast.rs`) passes in every full-suite run. + +## Plan + +It failed once under a loaded `just check` ("moq-transport-17: a rejoining +reader was served the stale cache first: group 3") and passed 8/8 alone. + +Likely cause: on idle, the IETF subscriber awaits `cancel_subscribe` before +it calls `track.set_idle()` (`rs/moq-net/src/ietf/subscriber.rs`, around +line 2178 at time of writing). Draft-17 has no UNSUBSCRIBE, so the cancel is +a STOP_SENDING followed by a `close().await` that waits for the peer. +The publisher sees the cancel and its demand drops, which is what the test +waits on, about a round trip before the copy is marked idle. A resubscribe +in that gap finds a copy that still looks live. Its newest group, 3, has no +successor, so it isn't stale, and it is served. + +Decided 2026-10-05 11:39 +0200: fix the product, since a real client +rejoining in that gap gets the stale group too. Reproduce the race first, +then mark the copy idle as soon as `End::Idle` is decided, before the +awaited cancel. The test then waits on an event that comes after idle and +needs no change. Rejected: only making the test wait for the subscriber's +idle, which leaves the window open for clients. Check the other drafts and +lite for the same ordering. Add a deterministic regression test if one is +easy. + +Public API: none. Wire: none. diff --git a/quest/m1/untimed-failover.md b/quest/m1/untimed-failover.md new file mode 100644 index 0000000000..af55674f5a --- /dev/null +++ b/quest/m1/untimed-failover.md @@ -0,0 +1,56 @@ +# [S] Give up a resumed group drift can't judge + +## Goal + +After a route failover, a resumed group that no remaining route continues is +given up even when its track's media time can't measure how far behind it is, +such as on an all-untimed track. Today a reader holding that group waits until +the track ends. + +## Plan + +Found in #4822. `Recover::poll` (`rs/moq-net/src/model/resume.rs`, around line +854 at time of writing) gives up a group the serving route can't continue +once `poll_stale` reports drift past the reader's `max_age`. `drifted` +(`model/track.rs`) needs a timed live edge and a timed successor (`reach`). +Without both, nothing convicts the group. Only the holder of that +`group::Consumer` blocks: track cursors keep handing out newer groups. The +replaced copy's lease also stays alive. On main this still worked, because +receivers stamped arrival time on untimed frames. + +Decided 2026-10-05 11:39 +0200: + +- A separate quest, not a fix inside #4822, to keep that PR reviewable. It + ships the regression for a short window, which is why this quest is ranked + right after it. +- The rule: when drift can't be measured (no timed live edge, or the + successor is untimed), give up once the serving copy holds a newer group + and no route or recovery fetch can still fill this one. This applies to + mixed tracks too, so there is one rule. No clock is involved. + Rejected: a wall-clock deadline of `max_age` after the stall, which stands + wall time in for media time, the substitution the untimed model removed. + Also rejected: leaving it and documenting it. + +Settle what "nothing pending" means against the recovery fetch +(`a_pending_recovery_fetch_does_not_disable_expiry`), so a fetch about to +fill the group isn't cut short. A FETCH reader has no budget and stays as it +is. + +Test: a model test in `resume.rs` failing over an all-untimed track with an +abandoned group, plus one where the successor is untimed on a timed track. +Both fail without the fix. Run each against a cold route and against one +whose copy already caches untimed groups past the resumed one, so a cursor +that skips ahead can't hide the stall. #4822 is changing `Cursor::new` so an +explicit start is honoured on untimed tracks; build on that, not on the +older jump to the latest untimed group. + +Public API: none. Wire: none. + +## Required + +- [Untimed model](/quest/m1/untimed-model.md) - introduces untimed groups, and the stall + +## Related + +- [JS track handover](/quest/m1/js-group-handover.md) - mirrors this rule in JS +- [Wall-clock age-out](/quest/m1/cache-wall-eviction.md) - retention of untimed groups, not a blocked reader