From d42788ac235611b9a246d0aa352e7394ff66a510 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 10 Oct 2026 12:31:12 -0700 Subject: [PATCH 1/3] quest: plan export source pinning and the stitch catalog bound Co-Authored-By: Claude Opus 5.5 --- quest/m0/broadcast-epoch/README.md | 1 + quest/m0/broadcast-epoch/source-pin.md | 35 ++++++++++++++++++++++++++ quest/m1/README.md | 1 + quest/m1/ts-follow-catalog-bound.md | 30 ++++++++++++++++++++++ 4 files changed, 67 insertions(+) create mode 100644 quest/m0/broadcast-epoch/source-pin.md create mode 100644 quest/m1/ts-follow-catalog-bound.md diff --git a/quest/m0/broadcast-epoch/README.md b/quest/m0/broadcast-epoch/README.md index 3b4082003a..cce1026afe 100644 --- a/quest/m0/broadcast-epoch/README.md +++ b/quest/m0/broadcast-epoch/README.md @@ -107,6 +107,7 @@ maintainer's pre-release merge of `release` into `main` keeps `main`'s - [JS consume identity](/quest/m0/broadcast-epoch/js-consume-identity.md) - requests carry the serving prefix's epoch and new winners cannot reuse an old cached broadcast - [JS restart keeps the request](/quest/m0/broadcast-epoch/js-restart-keeps-request.md) - a resolved request remains on its old instance, matching Rust's sticky subscriptions +- [Source pin](/quest/m0/broadcast-epoch/source-pin.md) - an export's later requests stay on the broadcast it resolved, so an epochless replacement never splices into the old program - [Publish catalog restart](/quest/m0/broadcast-epoch/publish-catalog-restart.md) - `@moq/publish` never reuses catalog group numbers under one name and epoch after a re-announce - [Bindings](/quest/m0/broadcast-epoch/bindings.md) - moq-ffi and every wrapper expose the epoch and let a publisher announce one - [Stats totals and prefix tracks](/quest/m0/broadcast-epoch/stats-split.md) - the same release retires the per-path stats maps for totals and on-demand prefix tracks (decided 2026-10-05) diff --git a/quest/m0/broadcast-epoch/source-pin.md b/quest/m0/broadcast-epoch/source-pin.md new file mode 100644 index 0000000000..c19d9c6caa --- /dev/null +++ b/quest/m0/broadcast-epoch/source-pin.md @@ -0,0 +1,35 @@ +# [S] An export's later requests stay on the broadcast it resolved + +## Goal + +`moq_mux::Source` keeps the first `broadcast::Consumer` it resolves for each +path, its own and every cross-broadcast reference, and serves every later +request for that path from that handle. A late rendition subscribe or SI +repoint never lands on a replacement, epoch or not. On a replaced instance +it fails loudly instead of splicing the new publisher's data into the old +program. + +## Plan + +Found by Codex on #5147 (2026-10-10), planned the same day. `Source` pins +its own path by epoch (`epoch` in `rs/moq-mux/src/source.rs`, set through +`pinned` from `ts::Export`). An epochless route (every default lite-06 +session) has `None`, so each later request re-resolves the path, and if a +replacement won in the meantime, it serves that request even without +`--stitch`. + +- Decided: pin by handle, replacing the epoch field. One mechanism covers + epoch and epochless routes. A replaced instance's sticky front refuses new + tracks, so a late request on it errors, and the Follower decides what + happens next (`Replaced`, or a stitch). +- Decided: pin every resolved path, not only the source's own, so a replaced + sibling (`broadcast: ./source`) cannot splice in either. +- `ts::Follower` / `Export::follow` build a new `Source` for the broadcast + they follow, so a stitch starts with an empty pin map. +- Tests, with mocked time, on an epochless route: an export resolves `live`, + a replacement wins `live`, and then a rendition added to the old catalog + (and an SI repoint) fails rather than reading the replacement. The same + for a sibling reference. Today's epoch-pin test (`source.rs`) moves to + the handle. + +Public API: none (`pinned` is crate-private). Wire: none. diff --git a/quest/m1/README.md b/quest/m1/README.md index 542d1a9121..c854a0187e 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -178,6 +178,7 @@ bench all require its crate. - [Catalog warmup](/quest/m1/catalog-warmup.md) - `warmup` on video and audio renditions, in the catalog and the draft - [Audio warmup](/quest/m1/audio-warmup.md) - a viewer joining an Opus rendition mid-stream never hears the unconverged first 80 ms - [#3021](/quest/m1/3021-moq-gst-anchor-generated-media-timelines-to-wall-clock.md) - moq-gst picks the broadcast wall epoch; a restarted source is a new epoch, not a forward re-anchor +- [TS stitch catalog bound](/quest/m1/ts-follow-catalog-bound.md) - `--linger` bounds a mid-stream `--stitch` until the replacement's catalog arrives, so it ends loudly instead of stalling - [TS passthrough export](/quest/m1/ts-passthrough-export.md) - `export ts --passthrough` writes the `m2ts` track back byte-identical (less late drops) on a fixed delay - [FLV and MKV export delay](/quest/m1/export-delay.md) - FLV and MKV interleave through the shared jitter buffer on a fixed delay, breaking the CLI once - [MKV lacing](/quest/m1/mkv-lacing.md) - laced MKV blocks import as one timed frame each, refused without DefaultDuration diff --git a/quest/m1/ts-follow-catalog-bound.md b/quest/m1/ts-follow-catalog-bound.md new file mode 100644 index 0000000000..76774f3ed9 --- /dev/null +++ b/quest/m1/ts-follow-catalog-bound.md @@ -0,0 +1,30 @@ +# [S] A TS export stitch is bounded by the linger until its catalog arrives + +## Goal + +When `moq export ts --stitch` (or moq-srt egress with `stitch`) switches to +a replacement mid-stream, `--linger` bounds the wait for the replacement's +catalog, as it bounds a same-epoch return. A replacement that never delivers +its first catalog snapshot ends the export with an error instead of stalling +it with nothing going out. + +## Plan + +Found while iterating #5147 (2026-10-10), planned the same day. `ts::Follower` +(`rs/moq-mux/src/container/ts/follower.rs`) carries a linger deadline for a +return, which #5147 extended to the returned catalog's first snapshot, but a +mid-stream stitch carries none (`None` for a stitch mid-stream). + +- Decided: the linger is the one bound, from the `Restart` to the + replacement's first catalog snapshot. With `--linger 0` a stitch only + succeeds when the catalog is already there. Rejected: bounding by + `--delay`, and a fixed constant. +- On expiry the export ends with an error naming the replacement that never + served its catalog. +- Tests, with mocked time: a stitch onto a replacement whose catalog never + answers ends at the linger. One whose catalog answers within it switches + as today. +- Docs: the `--stitch` and `--linger` text in `doc/bin/cli.md` and + `doc/bin/srt.md`. + +Public API: none. Behavior: a stitch can now end the export. Wire: none. From 58339742a60e42eda6ef949f6e9ecd96803bab5f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 10 Oct 2026 12:40:24 -0700 Subject: [PATCH 2/3] quest: seed and refresh the source pin, scope the stitch bound to mid-stream Co-Authored-By: Claude Opus 5.5 --- quest/m0/broadcast-epoch/source-pin.md | 17 +++++++++++++---- quest/m1/ts-follow-catalog-bound.md | 10 +++++++--- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/quest/m0/broadcast-epoch/source-pin.md b/quest/m0/broadcast-epoch/source-pin.md index c19d9c6caa..4fceb7184e 100644 --- a/quest/m0/broadcast-epoch/source-pin.md +++ b/quest/m0/broadcast-epoch/source-pin.md @@ -24,12 +24,21 @@ replacement won in the meantime, it serves that request even without happens next (`Replaced`, or a stitch). - Decided: pin every resolved path, not only the source's own, so a replaced sibling (`broadcast: ./source`) cannot splice in either. -- `ts::Follower` / `Export::follow` build a new `Source` for the broadcast - they follow, so a stitch starts with an empty pin map. +- The pin lifecycle follows the export's instance. A stitch builds a new + `Source` whose own-path entry is seeded with the broadcast `follow` was + given (sibling entries start empty), so its first track request can't + resolve a newer replacement. A same-epoch return replaces the own-path + handle with the returned broadcast, since the old one's routes are gone. +- The guarantee starts at a path's first resolution. A sibling the catalog + first references after a replacement resolves whatever serves it then. + The export never held another instance of that path, so that isn't a + splice. - Tests, with mocked time, on an epochless route: an export resolves `live`, a replacement wins `live`, and then a rendition added to the old catalog (and an SI repoint) fails rather than reading the replacement. The same - for a sibling reference. Today's epoch-pin test (`source.rs`) moves to - the handle. + for a sibling reference already resolved. A stitch whose replacement is + itself replaced before the first track request still reads the broadcast + it followed. A same-epoch return reads the returned handle. Today's + epoch-pin test (`source.rs`) moves to the handle. Public API: none (`pinned` is crate-private). Wire: none. diff --git a/quest/m1/ts-follow-catalog-bound.md b/quest/m1/ts-follow-catalog-bound.md index 76774f3ed9..2ab57704a0 100644 --- a/quest/m1/ts-follow-catalog-bound.md +++ b/quest/m1/ts-follow-catalog-bound.md @@ -12,8 +12,11 @@ it with nothing going out. Found while iterating #5147 (2026-10-10), planned the same day. `ts::Follower` (`rs/moq-mux/src/container/ts/follower.rs`) carries a linger deadline for a -return, which #5147 extended to the returned catalog's first snapshot, but a -mid-stream stitch carries none (`None` for a stitch mid-stream). +return, which #5147 extended to the returned catalog's first snapshot. A +stitch that starts after the old broadcast ended inherits that deadline. A +mid-stream stitch, taken from `Running` while the old instance still serves, +carries none (`settling: None` in `State::Following`), so its catalog wait is +unbounded. - Decided: the linger is the one bound, from the `Restart` to the replacement's first catalog snapshot. With `--linger 0` a stitch only @@ -25,6 +28,7 @@ mid-stream stitch carries none (`None` for a stitch mid-stream). answers ends at the linger. One whose catalog answers within it switches as today. - Docs: the `--stitch` and `--linger` text in `doc/bin/cli.md` and - `doc/bin/srt.md`. + `doc/bin/srt.md`, including that the default `--linger 0s` lets a stitch + follow only a replacement whose catalog is already there. Public API: none. Behavior: a stitch can now end the export. Wire: none. From 8b55b804723c01dcd6ce10da2e1d0b0ab495971b Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 10 Oct 2026 12:48:20 -0700 Subject: [PATCH 3/3] quest: report source-pin's public behavior change Co-Authored-By: Claude Opus 5.5 --- quest/m0/broadcast-epoch/source-pin.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/quest/m0/broadcast-epoch/source-pin.md b/quest/m0/broadcast-epoch/source-pin.md index 4fceb7184e..02ac13c458 100644 --- a/quest/m0/broadcast-epoch/source-pin.md +++ b/quest/m0/broadcast-epoch/source-pin.md @@ -41,4 +41,8 @@ replacement won in the meantime, it serves that request even without it followed. A same-epoch return reads the returned handle. Today's epoch-pin test (`source.rs`) moves to the handle. -Public API: none (`pinned` is crate-private). Wire: none. +Public API: no signature change, but a behavior change. A `Source` reused +across a route replacement now returns its pinned handle for a path it +already resolved (`broadcast`, `catalog`, and track requests), or fails on +the replaced instance, instead of resolving the current route. Document it +on `Source`. Wire: none.