Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions quest/m1/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion quest/m1/js-group-handover.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
8 changes: 7 additions & 1 deletion quest/m1/js-untimed-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,20 @@ 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.

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

Expand Down
1 change: 1 addition & 0 deletions quest/m1/test-flakes-2/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
32 changes: 32 additions & 0 deletions quest/m1/test-flakes-2/rejoin-idle-race.md
Original file line number Diff line number Diff line change
@@ -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.
56 changes: 56 additions & 0 deletions quest/m1/untimed-failover.md
Original file line number Diff line number Diff line change
@@ -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
Loading