From 85a206c9e460ee78ec8f1e940cfd4b3078891e5d Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 28 Sep 2026 10:10:45 -0700 Subject: [PATCH 01/11] quest: record the page-load and boundary-ordering decisions for the live marker Co-Authored-By: Claude Opus 5.5 --- quest/m1/announce-page-load.md | 41 +++++++++++++++++++++++----------- 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/quest/m1/announce-page-load.md b/quest/m1/announce-page-load.md index 8cf4b4cfb5..49ae7a1706 100644 --- a/quest/m1/announce-page-load.md +++ b/quest/m1/announce-page-load.md @@ -1,19 +1,34 @@ -# [S] The live marker waits for the first connection on page load +# [M] The live marker waits for the first connection on page load ## Goal -An announcement stream opened before the first session connects does not -report `live` until that session's replay lands, or the connection attempt -gives up. Today it has no session to wait on, so it goes `live` at once and -the broadcasts arrive after it, in both `@moq/net` and `rs/moq-net`. +An announcement stream in `@moq/net` matches `rs/moq-net` around the `Live` +marker: + +- Opened before the first session connects, it does not yield `Live` on an + empty set. `Live` comes only after that first session's initial set has + been delivered. There is no give-up: a failed connection shows up as a + connection error, never as an empty `Live`. +- A change landing in the same tick as the last replayed route is yielded + after `Live`, not folded into the snapshot ahead of it. + +Every other JS place with the same page-load gap follows the same rule. ## Plan -- Open question for the maintainer: what counts as giving up (the first failed - attempt, the backoff ceiling, or never). -- The JS reconnect loop (`js/net/src/connection/reload.ts`) already counts as - an answerer for requests through `expect()`; it can also take a replay hold - on the origin until its first session lands or it gives up. -- Rust has no reconnect loop of its own; decide whether an app that opens a - stream before connecting needs an equivalent hold there. -- Add a caught-up test that opens the stream before the first connection. +- Event names: a separate dev PR renames the variants to + `Start`/`Update`/`End`/`Live` (today `announced`/`updated`/`retracted`/`live`). + Use the new names. +- Page load (decided): the JS reconnect loop (`js/net/src/connection/reload.ts`) + already counts as an answerer for requests through `expect()`; it can also + hold the replay on the origin until its first session's initial set lands. + The hold never times out. Rust already behaves this way and needs no change. +- Boundary ordering (decided): the JS stream is a coalescing diff of the + table, so a same-tick change is merged into the snapshot ahead of `Live` + ([#4261 discussion](https://github.com/moq-dev/moq/pull/4261#discussion_r4113867903)). + Snapshot the table when the last replay hold drops, yield that snapshot, + then `Live`, then diff from the snapshot, as Rust does. +- Tests: a caught-up test that opens the stream before the first connection, + one where the first connection fails and no `Live` arrives, and a same-tick + test where a change lands with the last replayed route and comes after + `Live`. From 44d595cf645575b013635f76015ee0dc5cb1b304 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 28 Sep 2026 11:49:31 -0700 Subject: [PATCH 02/11] quest: narrow the Rust page-load claim, require the event rename Rust streams opened before connect are live at once; only those opened after connect wait on the session's replay. List #4384 under Required so the quest is not startable before the Start/Update/End/Live names exist. Co-Authored-By: Claude Opus 5.5 --- quest/m1/announce-page-load.md | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/quest/m1/announce-page-load.md b/quest/m1/announce-page-load.md index 49ae7a1706..c57ce92da5 100644 --- a/quest/m1/announce-page-load.md +++ b/quest/m1/announce-page-load.md @@ -2,8 +2,8 @@ ## Goal -An announcement stream in `@moq/net` matches `rs/moq-net` around the `Live` -marker: +An announcement stream in `@moq/net` treats the `Live` marker like an +`rs/moq-net` stream opened after `connect`: - Opened before the first session connects, it does not yield `Live` on an empty set. `Live` comes only after that first session's initial set has @@ -16,13 +16,13 @@ Every other JS place with the same page-load gap follows the same rule. ## Plan -- Event names: a separate dev PR renames the variants to - `Start`/`Update`/`End`/`Live` (today `announced`/`updated`/`retracted`/`live`). - Use the new names. +- Event names: use `Start`/`Update`/`End`/`Live` from the rename under Required. - Page load (decided): the JS reconnect loop (`js/net/src/connection/reload.ts`) already counts as an answerer for requests through `expect()`; it can also hold the replay on the origin until its first session's initial set lands. - The hold never times out. Rust already behaves this way and needs no change. + The hold never times out. Rust needs no change: it has no reconnect loop, a + stream opened after `connect` already waits on that session's replay, and + one opened before `connect` is live at once. - Boundary ordering (decided): the JS stream is a coalescing diff of the table, so a same-tick change is merged into the snapshot ahead of `Live` ([#4261 discussion](https://github.com/moq-dev/moq/pull/4261#discussion_r4113867903)). @@ -32,3 +32,7 @@ Every other JS place with the same page-load gap follows the same rule. one where the first connection fails and no `Live` arrives, and a same-tick test where a change lands with the last replayed route and comes after `Live`. + +## Required + +- [#4384](https://github.com/moq-dev/moq/pull/4384) lands on `dev`, renaming the announce events to `Start`/`Update`/`End`/`Live` From 1d6b807cfd931d533d8f11a65f1ce5b0432167f4 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 28 Sep 2026 12:08:56 -0700 Subject: [PATCH 03/11] quest: define the Live boundary by hold release, not by tick Rust snapshots the pending set when the last replay hold drops, so only changes applied after that point follow the marker. Co-Authored-By: Claude Opus 5.5 --- quest/m1/announce-page-load.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/quest/m1/announce-page-load.md b/quest/m1/announce-page-load.md index c57ce92da5..e4fa4845e3 100644 --- a/quest/m1/announce-page-load.md +++ b/quest/m1/announce-page-load.md @@ -9,8 +9,9 @@ An announcement stream in `@moq/net` treats the `Live` marker like an empty set. `Live` comes only after that first session's initial set has been delivered. There is no give-up: a failed connection shows up as a connection error, never as an empty `Live`. -- A change landing in the same tick as the last replayed route is yielded - after `Live`, not folded into the snapshot ahead of it. +- A change applied after the last replay hold drops is yielded after `Live`, + even when it lands in the same tick, not folded into the snapshot ahead of + it. A change applied before the hold drops stays ahead of `Live`. Every other JS place with the same page-load gap follows the same rule. @@ -30,7 +31,7 @@ Every other JS place with the same page-load gap follows the same rule. then `Live`, then diff from the snapshot, as Rust does. - Tests: a caught-up test that opens the stream before the first connection, one where the first connection fails and no `Live` arrives, and a same-tick - test where a change lands with the last replayed route and comes after + test where a change applied just after the last hold drops comes after `Live`. ## Required From 9f927e1ae83ad0f6422722fcb5bc8e39cacd1ab3 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 28 Sep 2026 13:12:08 -0700 Subject: [PATCH 04/11] quest: drop the rename prerequisite now that #4384 is on dev Co-Authored-By: Claude Opus 5.5 --- quest/m1/announce-page-load.md | 5 ----- 1 file changed, 5 deletions(-) diff --git a/quest/m1/announce-page-load.md b/quest/m1/announce-page-load.md index e4fa4845e3..0d5890bcbb 100644 --- a/quest/m1/announce-page-load.md +++ b/quest/m1/announce-page-load.md @@ -17,7 +17,6 @@ Every other JS place with the same page-load gap follows the same rule. ## Plan -- Event names: use `Start`/`Update`/`End`/`Live` from the rename under Required. - Page load (decided): the JS reconnect loop (`js/net/src/connection/reload.ts`) already counts as an answerer for requests through `expect()`; it can also hold the replay on the origin until its first session's initial set lands. @@ -33,7 +32,3 @@ Every other JS place with the same page-load gap follows the same rule. one where the first connection fails and no `Live` arrives, and a same-tick test where a change applied just after the last hold drops comes after `Live`. - -## Required - -- [#4384](https://github.com/moq-dev/moq/pull/4384) lands on `dev`, renaming the announce events to `Start`/`Update`/`End`/`Live` From 3c2d99b1dd2552080fce8d806151449d5bc833fd Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 29 Sep 2026 05:10:11 -0700 Subject: [PATCH 05/11] quest: live marker is Rust's owed-prefix barrier; fold announce-live-apps The maintainer chose Rust parity: Live follows once the prefixes owed when the last replay hold drops are delivered or cancelled, while the stream keeps draining, instead of a frozen snapshot. announce-live-apps splits into page-load (no give-up, as decided) and empty-state (apps, error state). Co-Authored-By: Claude Opus 5.5 --- quest/m1/README.md | 1 - quest/m1/announce-empty-state.md | 13 +++++++++--- quest/m1/announce-live-apps.md | 36 -------------------------------- quest/m1/announce-page-load.md | 33 ++++++++++++++++------------- 4 files changed, 29 insertions(+), 54 deletions(-) delete mode 100644 quest/m1/announce-live-apps.md diff --git a/quest/m1/README.md b/quest/m1/README.md index fcc59dbd0a..28a65b9d5b 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -28,7 +28,6 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Session close](/quest/m1/session-close.md) - a graceful session end withdraws announces and waits one second for the ack - [Drain before close](/quest/m1/drain-before-close.md) - a closing client delivers its queued stream finishes, so `moq import` ends the catalog cleanly over a real relay - [Raw stream codes](/quest/m1/raw-stream-codes.md) - raw QUIC stream resets and stops carry the application's code, not an HTTP/3-mapped one -- [Live in apps](/quest/m1/announce-live-apps.md) - the demo and `@moq/room` show "no broadcasts" from the `live` marker, which waits for the first session on page load - [Page-load marker](/quest/m1/announce-page-load.md) - an announcement stream opened before the first connection waits for its replay before `live` - [Empty state](/quest/m1/announce-empty-state.md) - watch, room, and the demo show "no broadcasts" once `live` arrives with nothing announced - [JS active count](/quest/m1/js-active-count.md) - @moq/net speaks MoQ Active Count, so its IETF announce consumers go live without a timer diff --git a/quest/m1/announce-empty-state.md b/quest/m1/announce-empty-state.md index 54e4adbba8..061983b2db 100644 --- a/quest/m1/announce-empty-state.md +++ b/quest/m1/announce-empty-state.md @@ -4,12 +4,19 @@ Once an announcement stream reports `live` with nothing announced, `js/watch`, `js/room`, and `demo/web` show a "no broadcasts" state instead of a spinner -that never resolves. +that never resolves, and never a false empty state before the first session +answered. A connection that fails shows an error state, not an empty one. ## Plan -- Today each consumer skips the `live` marker. Track it next to the - announced set and render the empty state only after it arrives. +- Today each consumer skips the `live` marker (`demo/web/src/index.ts`, + `js/room/src/room.ts`, `js/watch/src/broadcast.ts`, `js/moq-boy`). Track it + next to the announced set: loading before `live`, the empty state after it + with nothing announced, and an error state when the connection fails. +- Libraries expose the state as a signal; wording stays in the demo. + +Public API: any state signal on `@moq/room` or `@moq/watch` is additive. +Wire: none. ## Required diff --git a/quest/m1/announce-live-apps.md b/quest/m1/announce-live-apps.md deleted file mode 100644 index c6f9969516..0000000000 --- a/quest/m1/announce-live-apps.md +++ /dev/null @@ -1,36 +0,0 @@ -# [M] Apps show "no broadcasts" from the live marker - -## Goal - -A browser page listing broadcasts shows an empty state once the relay has -said there are none, never a spinner that never resolves and never a false -empty state before the first session answered. The demo watch page and -`@moq/room` use `@moq/net`'s `live` marker, and `@moq/net` settles when an -origin stream opened before the first connection goes live. - -## Plan - -- #4261 (on `dev`) adds the `live` event, and #4266 the same marker in the - bindings. Open #4384 renames the announce events to Start/Update/End/Live; - follow its names if it lands first. The consumers it touches - (`demo/web/src/index.ts`, `js/room/src/room.ts`, `js/watch/src/broadcast.ts`, - `js/moq-boy`) skip it today. -- Page load: an origin stream opened before any session connects has no - session to wait on, so today it goes `live` at once and broadcasts arrive - after it. Settled: the reconnect loop (`js/net/src/connection/reload.ts`), - which already answers requests through `expect()`, holds the marker until - its first session lands `live` or its first dial gives up. An empty list - then means the relay said so or is unreachable, which a UI can tell apart. - Once a session is up, its own `live` ends the hold: every wire guarantees - one (ANNOUNCE_OK, ANNOUNCE_INIT, or the quiet-stream fallback). A peer that - accepts the announce stream and never answers is a peer bug, so no extra - timeout. - Check whether Rust's reconnecting client has the same gap. -- Apps: loading before `live`, an explicit empty state after it with nothing - announced, and an error state when the connection gives up. Libraries - expose the state as a signal; wording stays in the demo. -- Tests: an origin stream opened before connect is not `live` until the first - session is; a connection that cannot connect ends the wait. - -Public API: when `@moq/net` emits `live` changes; any state signal on -`@moq/room` or `@moq/watch` is additive. Lands on `dev` with #4261. Wire: none. diff --git a/quest/m1/announce-page-load.md b/quest/m1/announce-page-load.md index 0d5890bcbb..85a9d2d825 100644 --- a/quest/m1/announce-page-load.md +++ b/quest/m1/announce-page-load.md @@ -9,9 +9,11 @@ An announcement stream in `@moq/net` treats the `Live` marker like an empty set. `Live` comes only after that first session's initial set has been delivered. There is no give-up: a failed connection shows up as a connection error, never as an empty `Live`. -- A change applied after the last replay hold drops is yielded after `Live`, - even when it lands in the same tick, not folded into the snapshot ahead of - it. A change applied before the hold drops stays ahead of `Live`. +- `Live` is a barrier on the prefixes still owed when the last replay hold + drops: it follows once each of them has been delivered or cancelled. The + stream keeps draining the live table meanwhile, so a change to an owed + prefix folds into its pre-`Live` event, a retraction can cancel it, and a + new prefix that sorts ahead of an owed one can arrive before `Live`. Every other JS place with the same page-load gap follows the same rule. @@ -20,15 +22,18 @@ Every other JS place with the same page-load gap follows the same rule. - Page load (decided): the JS reconnect loop (`js/net/src/connection/reload.ts`) already counts as an answerer for requests through `expect()`; it can also hold the replay on the origin until its first session's initial set lands. - The hold never times out. Rust needs no change: it has no reconnect loop, a - stream opened after `connect` already waits on that session's replay, and - one opened before `connect` is live at once. -- Boundary ordering (decided): the JS stream is a coalescing diff of the - table, so a same-tick change is merged into the snapshot ahead of `Live` - ([#4261 discussion](https://github.com/moq-dev/moq/pull/4261#discussion_r4113867903)). - Snapshot the table when the last replay hold drops, yield that snapshot, - then `Live`, then diff from the snapshot, as Rust does. + The hold never times out. Rust needs no change: a Rust app calls `connect` + before it opens the stream, which then waits on that session's replay, and + a stream opened before `connect` is live at once. +- Boundary ordering (decided): match Rust's owed-prefix barrier + (`OriginConsumerState::landed` records `LiveState::Owed(pending keys)`, and + `take` keeps draining the shared, lexicographic `pending` map). When the + last replay hold drops, record the prefixes whose diff is still pending, + keep emitting from the live table, and yield `Live` once that set is empty. + No frozen snapshot: the JS stream is already a coalescing diff of the + table, which is the same model. - Tests: a caught-up test that opens the stream before the first connection, - one where the first connection fails and no `Live` arrives, and a same-tick - test where a change applied just after the last hold drops comes after - `Live`. + one where the first connection fails and no `Live` arrives, and barrier + tests for a change to an owed prefix after the hold drops (folded ahead of + `Live`), a retraction of one (cancelled, `Live` still follows), and a new + prefix sorting ahead of an owed one (may precede `Live`). From d6724c0806ad11a919feda2a911b33facb3178fe Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 29 Sep 2026 05:16:29 -0700 Subject: [PATCH 06/11] quest: the JS announce queue must coalesce per prefix for the barrier Co-Authored-By: Claude Opus 5.5 --- quest/m1/announce-page-load.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/quest/m1/announce-page-load.md b/quest/m1/announce-page-load.md index 85a9d2d825..271ee44207 100644 --- a/quest/m1/announce-page-load.md +++ b/quest/m1/announce-page-load.md @@ -30,8 +30,10 @@ Every other JS place with the same page-load gap follows the same rule. `take` keeps draining the shared, lexicographic `pending` map). When the last replay hold drops, record the prefixes whose diff is still pending, keep emitting from the live table, and yield `Live` once that set is empty. - No frozen snapshot: the JS stream is already a coalescing diff of the - table, which is the same model. + No frozen snapshot. The JS stream is not that model today: + `AnnounceState.queue` (`js/net/src/announced.ts`) appends every diff, so a + queued event can't be folded, cancelled, or overtaken. Replace it with one + pending entry per prefix, taken in path order, as Rust's `pending` map is. - Tests: a caught-up test that opens the stream before the first connection, one where the first connection fails and no `Live` arrives, and barrier tests for a change to an owed prefix after the hold drops (folded ahead of From 5af1ea0479562c42535ca7f98c43595116131f83 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 29 Sep 2026 05:20:27 -0700 Subject: [PATCH 07/11] quest: watch's empty state rides its named broadcast request Co-Authored-By: Claude Opus 5.5 --- quest/m1/announce-empty-state.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/quest/m1/announce-empty-state.md b/quest/m1/announce-empty-state.md index 061983b2db..67139529e6 100644 --- a/quest/m1/announce-empty-state.md +++ b/quest/m1/announce-empty-state.md @@ -10,9 +10,14 @@ answered. A connection that fails shows an error state, not an empty one. ## Plan - Today each consumer skips the `live` marker (`demo/web/src/index.ts`, - `js/room/src/room.ts`, `js/watch/src/broadcast.ts`, `js/moq-boy`). Track it - next to the announced set: loading before `live`, the empty state after it - with nothing announced, and an error state when the connection fails. + `js/room/src/room.ts`, `js/moq-boy`). Track it next to the announced set: + loading before `live`, the empty state after it with nothing announced, and + an error state when the connection fails. +- `js/watch` is different: its main broadcast waits on + `origin.request(name, { announced: true })` in `#runBroadcast`, and its + announcement stream opens only for relative catalog references. The named + request needs its own caught-up-and-absent state, behind the same page-load + hold, for watch to show "not live" instead of waiting. - Libraries expose the state as a signal; wording stays in the demo. Public API: any state signal on `@moq/room` or `@moq/watch` is additive. From 157f37d353739b98da7b8d4d0a34dcfc2301e51e Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 29 Sep 2026 05:24:39 -0700 Subject: [PATCH 08/11] quest: record the page-load marker's API impact Co-Authored-By: Claude Opus 5.5 --- quest/m1/announce-page-load.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/quest/m1/announce-page-load.md b/quest/m1/announce-page-load.md index 271ee44207..a16db8781e 100644 --- a/quest/m1/announce-page-load.md +++ b/quest/m1/announce-page-load.md @@ -39,3 +39,6 @@ Every other JS place with the same page-load gap follows the same rule. tests for a change to an owed prefix after the hold drops (folded ahead of `Live`), a retraction of one (cancelled, `Live` still follows), and a new prefix sorting ahead of an owed one (may precede `Live`). + +Public API: `@moq/net` changes when its announcement stream yields `Live` +and how it orders and coalesces events, so it lands on `dev`. Wire: none. From 12b86acb2e58474ee5ee75571d16b771c5a6bd56 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 29 Sep 2026 05:30:39 -0700 Subject: [PATCH 09/11] quest: page-load hold covers one-shot connections, with tests and a benchmark Co-Authored-By: Claude Opus 5.5 --- quest/m1/announce-empty-state.md | 3 +++ quest/m1/announce-page-load.md | 15 ++++++++++++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/quest/m1/announce-empty-state.md b/quest/m1/announce-empty-state.md index 67139529e6..46cc95bf2b 100644 --- a/quest/m1/announce-empty-state.md +++ b/quest/m1/announce-empty-state.md @@ -19,6 +19,9 @@ answered. A connection that fails shows an error state, not an empty one. request needs its own caught-up-and-absent state, behind the same page-load hold, for watch to show "not live" instead of waiting. - Libraries expose the state as a signal; wording stays in the demo. +- Tests in `js/room/src/room.test.ts` and `js/watch/src/broadcast.test.ts`: + loading before `live`, empty once caught up with nothing announced, and + error (never empty) when the connection fails. Public API: any state signal on `@moq/room` or `@moq/watch` is additive. Wire: none. diff --git a/quest/m1/announce-page-load.md b/quest/m1/announce-page-load.md index a16db8781e..7961683137 100644 --- a/quest/m1/announce-page-load.md +++ b/quest/m1/announce-page-load.md @@ -1,4 +1,4 @@ -# [M] The live marker waits for the first connection on page load +# [L] The live marker waits for the first connection on page load ## Goal @@ -22,7 +22,11 @@ Every other JS place with the same page-load gap follows the same rule. - Page load (decided): the JS reconnect loop (`js/net/src/connection/reload.ts`) already counts as an answerer for requests through `expect()`; it can also hold the replay on the origin until its first session's initial set lands. - The hold never times out. Rust needs no change: a Rust app calls `connect` + The hold never times out. The one-shot `Connection.connect` and + `Connection.accept` (`connect.ts`, `accept.ts`) register their replay + source only after the handshake, so they take the same hold before it and + release it on failure, which surfaces as the connection error. + Rust needs no change: a Rust app calls `connect` before it opens the stream, which then waits on that session's replay, and a stream opened before `connect` is live at once. - Boundary ordering (decided): match Rust's owed-prefix barrier @@ -34,8 +38,13 @@ Every other JS place with the same page-load gap follows the same rule. `AnnounceState.queue` (`js/net/src/announced.ts`) appends every diff, so a queued event can't be folded, cancelled, or overtaken. Replace it with one pending entry per prefix, taken in path order, as Rust's `pending` map is. +- Benchmark: the pending structure grows with both prefixes and announcement + consumers, so add a `js/net/bench` case swept over prefix count and + consumer count, with slow consumers draining it, and wire it into the + nightly benchmark run. - Tests: a caught-up test that opens the stream before the first connection, - one where the first connection fails and no `Live` arrives, and barrier + one where the first connection fails and no `Live` arrives, the same pair + for one-shot `connect` and `accept`, and barrier tests for a change to an owed prefix after the hold drops (folded ahead of `Live`), a retraction of one (cancelled, `Live` still follows), and a new prefix sorting ahead of an owed one (may precede `Live`). From bc2e943bd3593388d8e134b84be000b6706eb710 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 29 Sep 2026 05:35:09 -0700 Subject: [PATCH 10/11] quest: scope the live marker quest to page load The maintainer decided on 2026-09-29 to fix only the page-load gap. Rust's per-prefix barrier stays the target ordering and arrives with generated lite, so the JS queue rewrite and its benchmark are dropped. Also removes the earlier maintainer attributions, which were AI-written. Co-Authored-By: Claude Opus 5.5 --- quest/m1/announce-page-load.md | 81 +++++++++++++++------------------- 1 file changed, 36 insertions(+), 45 deletions(-) diff --git a/quest/m1/announce-page-load.md b/quest/m1/announce-page-load.md index 7961683137..caca31c2e2 100644 --- a/quest/m1/announce-page-load.md +++ b/quest/m1/announce-page-load.md @@ -1,53 +1,44 @@ -# [L] The live marker waits for the first connection on page load +# [S] The live marker waits for the first connection on page load ## Goal -An announcement stream in `@moq/net` treats the `Live` marker like an -`rs/moq-net` stream opened after `connect`: - -- Opened before the first session connects, it does not yield `Live` on an - empty set. `Live` comes only after that first session's initial set has - been delivered. There is no give-up: a failed connection shows up as a - connection error, never as an empty `Live`. -- `Live` is a barrier on the prefixes still owed when the last replay hold - drops: it follows once each of them has been delivered or cancelled. The - stream keeps draining the live table meanwhile, so a change to an owed - prefix folds into its pre-`Live` event, a retraction can cancel it, and a - new prefix that sorts ahead of an owed one can arrive before `Live`. +An announcement stream in `@moq/net` opened before the first session connects +does not yield `Live` on an empty set. `Live` comes only after that first +session's initial set has been delivered. There is no give-up: a failed +connection shows up as a connection error, never as an empty `Live`, so a +player can't conclude "offline" before the relay has answered. Every other JS place with the same page-load gap follows the same rule. +Out of scope: the order of events that race the `Live` boundary once the +replay lands. JS keeps its append-only announce queue. + ## Plan -- Page load (decided): the JS reconnect loop (`js/net/src/connection/reload.ts`) - already counts as an answerer for requests through `expect()`; it can also - hold the replay on the origin until its first session's initial set lands. - The hold never times out. The one-shot `Connection.connect` and - `Connection.accept` (`connect.ts`, `accept.ts`) register their replay - source only after the handshake, so they take the same hold before it and - release it on failure, which surfaces as the connection error. - Rust needs no change: a Rust app calls `connect` - before it opens the stream, which then waits on that session's replay, and - a stream opened before `connect` is live at once. -- Boundary ordering (decided): match Rust's owed-prefix barrier - (`OriginConsumerState::landed` records `LiveState::Owed(pending keys)`, and - `take` keeps draining the shared, lexicographic `pending` map). When the - last replay hold drops, record the prefixes whose diff is still pending, - keep emitting from the live table, and yield `Live` once that set is empty. - No frozen snapshot. The JS stream is not that model today: - `AnnounceState.queue` (`js/net/src/announced.ts`) appends every diff, so a - queued event can't be folded, cancelled, or overtaken. Replace it with one - pending entry per prefix, taken in path order, as Rust's `pending` map is. -- Benchmark: the pending structure grows with both prefixes and announcement - consumers, so add a `js/net/bench` case swept over prefix count and - consumer count, with slow consumers draining it, and wire it into the - nightly benchmark run. -- Tests: a caught-up test that opens the stream before the first connection, - one where the first connection fails and no `Live` arrives, the same pair - for one-shot `connect` and `accept`, and barrier - tests for a change to an owed prefix after the hold drops (folded ahead of - `Live`), a retraction of one (cancelled, `Live` still follows), and a new - prefix sorting ahead of an owed one (may precede `Live`). - -Public API: `@moq/net` changes when its announcement stream yields `Live` -and how it orders and coalesces events, so it lands on `dev`. Wire: none. +- Decided by the maintainer (2026-09-29): fix page load only. Rust's + per-prefix barrier (`OriginConsumerState::landed` in + `rs/moq-net/src/model/origin.rs` records the prefixes still owed, and `Live` + follows once each is delivered or cancelled) is the target ordering. It + arrives when [generated lite](/quest/m1/rs2ts/lite.md) replaces js/net's + model layer, so don't rebuild the JS queue around per-prefix folding by + hand. Both orderings answer "is it offline?" the same way. +- The JS reconnect loop (`js/net/src/connection/reload.ts`) already counts as + an answerer for requests through `expect()`; it also takes a replay hold on + the origin (`replaying` in `js/net/src/origin.ts`) until its first + session's initial set lands. The hold never times out. +- The one-shot `Connection.connect` and `Connection.accept` (`connect.ts`, + `accept.ts`) register their replay source only after the handshake, so they + take the same hold before it and release it on failure, which surfaces as + the connection error. +- Rust needs no change: a Rust app calls `connect` before it opens the + stream, which then waits on that session's replay. +- Tests: open the stream before the first connection and get `Live` only + after its replay; fail the first connection and get no `Live`; the same + pair for one-shot `connect` and `accept`. + +Public API: `@moq/net` changes when its announcement stream yields `Live`, so +it lands on `dev`. Wire: none. + +## Related + +- [Generated lite](/quest/m1/rs2ts/lite.md) - brings Rust's per-prefix `Live` barrier to JS From 9e58503912dbccb1dcd4dc2c0bc13fcbb22db56d Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 29 Sep 2026 06:17:54 -0700 Subject: [PATCH 11/11] quest: announce streams toggle between live and offline Replaces the page-load-only scope with the maintainer's 2026-09-29 decisions: a live/offline toggle with no first-connection special case, hold and reconcile across a reconnect, Rust mirroring the shape, and the JS per-prefix queue rewrite now. Renames announce-page-load to announce-offline and moves the empty-state quest onto the toggle. Co-Authored-By: Claude Opus 5.5 --- quest/m1/README.md | 4 +- quest/m1/announce-empty-state.md | 29 +++++---- quest/m1/announce-offline.md | 102 +++++++++++++++++++++++++++++++ quest/m1/announce-page-load.md | 44 ------------- 4 files changed, 121 insertions(+), 58 deletions(-) create mode 100644 quest/m1/announce-offline.md delete mode 100644 quest/m1/announce-page-load.md diff --git a/quest/m1/README.md b/quest/m1/README.md index 28a65b9d5b..dae00e5d95 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -28,8 +28,8 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Session close](/quest/m1/session-close.md) - a graceful session end withdraws announces and waits one second for the ack - [Drain before close](/quest/m1/drain-before-close.md) - a closing client delivers its queued stream finishes, so `moq import` ends the catalog cleanly over a real relay - [Raw stream codes](/quest/m1/raw-stream-codes.md) - raw QUIC stream resets and stops carry the application's code, not an HTTP/3-mapped one -- [Page-load marker](/quest/m1/announce-page-load.md) - an announcement stream opened before the first connection waits for its replay before `live` -- [Empty state](/quest/m1/announce-empty-state.md) - watch, room, and the demo show "no broadcasts" once `live` arrives with nothing announced +- [Live and offline](/quest/m1/announce-offline.md) - announce streams toggle between `live` and `offline`, holding the set across a reconnect +- [Empty state](/quest/m1/announce-empty-state.md) - watch, room, and the demo show loading, "no broadcasts", or reconnecting from the `live`/`offline` toggle - [JS active count](/quest/m1/js-active-count.md) - @moq/net speaks MoQ Active Count, so its IETF announce consumers go live without a timer - [Watch refusal](/quest/m1/watch-refusal.md) - `` shows an origin refusal as an error instead of sitting offline - [kio waiter overflow](/quest/m1/kio-waiter-lost.md) - a retained `Waiter` past 8 lists stops adding a duplicate entry to lists it already recorded diff --git a/quest/m1/announce-empty-state.md b/quest/m1/announce-empty-state.md index 46cc95bf2b..08e8e435ff 100644 --- a/quest/m1/announce-empty-state.md +++ b/quest/m1/announce-empty-state.md @@ -2,30 +2,35 @@ ## Goal -Once an announcement stream reports `live` with nothing announced, `js/watch`, -`js/room`, and `demo/web` show a "no broadcasts" state instead of a spinner -that never resolves, and never a false empty state before the first session -answered. A connection that fails shows an error state, not an empty one. +`js/watch`, `js/room`, and `demo/web` follow the announcement stream's +`live`/`offline` toggle instead of a spinner that never resolves: loading +before the first `live`, a "no broadcasts" state after `live` with nothing +announced, and a reconnecting state on `offline` that keeps the held list on +screen. Never an empty state before a connection has answered. ## Plan - Today each consumer skips the `live` marker (`demo/web/src/index.ts`, - `js/room/src/room.ts`, `js/moq-boy`). Track it next to the announced set: - loading before `live`, the empty state after it with nothing announced, and - an error state when the connection fails. + `js/room/src/room.ts`, `js/moq-boy`). Track the toggle next to the + announced set. +- A connection that fails keeps the stream not-live, so the page stays in + loading or reconnecting; show the connection's own error or retry status + there (`Reload.status`, or the rejected one-shot `connect`), never the empty + state. - `js/watch` is different: its main broadcast waits on `origin.request(name, { announced: true })` in `#runBroadcast`, and its announcement stream opens only for relative catalog references. The named - request needs its own caught-up-and-absent state, behind the same page-load - hold, for watch to show "not live" instead of waiting. + request needs its own caught-up-and-absent and offline states, driven by + the same toggle, for watch to show "not live" or "reconnecting" instead of + waiting. - Libraries expose the state as a signal; wording stays in the demo. - Tests in `js/room/src/room.test.ts` and `js/watch/src/broadcast.test.ts`: - loading before `live`, empty once caught up with nothing announced, and - error (never empty) when the connection fails. + loading before `live`, empty once live with nothing announced, reconnecting + on `offline` with the list kept, and never empty when the connection fails. Public API: any state signal on `@moq/room` or `@moq/watch` is additive. Wire: none. ## Required -- [Page-load marker](/quest/m1/announce-page-load.md) - otherwise the empty state flashes on page load before the first connection replays +- [Live and offline](/quest/m1/announce-offline.md) - the toggle these states are driven by diff --git a/quest/m1/announce-offline.md b/quest/m1/announce-offline.md new file mode 100644 index 0000000000..ba9873b998 --- /dev/null +++ b/quest/m1/announce-offline.md @@ -0,0 +1,102 @@ +# [XL] Announce streams toggle between live and offline + +## Goal + +An announcement stream in `@moq/net` and `rs/moq-net` reports when it stops +being live, not only when it starts. `live` fires once a connection's replay +has landed; a new `offline` event fires when no connection feeding the stream +is up. Events strictly alternate from an implicit not-live start: `live`, +`offline`, `live`, and so on. + +- Every connection path follows the same rule (JS `Reload`, one-shot + `connect`/`accept`, and reconnects), so page load is no special case: a + stream opened before any connection starts not-live, and a failed attempt + never lands, so it never yields `live`. +- On `offline` the stream emits no `end`: the set is held as it was. When the + next replay lands, the stream emits only the real diffs (`end` what's gone, + `start` what's new, `update` the rest), then `live`. A reconnect blip tears + nothing down in a player. +- JS delivers through one pending entry per prefix, like Rust's `pending` + map, so both sides share the per-prefix `live` barrier and the same + coalescing. + +## Plan + +Decided by the maintainer (2026-09-29): + +- Toggle, with no special case for the first connection. Why: treating the + first connection as special is weird; a UI needs a marker for when things + stop being live (reconnecting), not only for when they are. +- `offline` is an event in the announce stream, not app state read from + `Reload.status`. Why: the stream is what knows whether its set is current. +- Never report `live` until there's actually a live connection: a failed + one-shot `connect`/`accept` leaves the stream not-live. Why: a false `live` + on an empty set makes a player conclude "offline" too early. +- The initial state is implicit: no `offline` at open. Why: a stream starts + not-live, so the first event is either a route or `live`. +- Hold and reconcile: on `offline`, emit no `end`; reconcile when the next + replay lands. Why: a blip must not tear down every player. +- Rust mirrors the same shape in `OriginConsumer`: `offline` when the last + network source feeding the consumer's scope goes away, `live` when a new + one's replay lands. A local-only origin with no connections is live at + once, as today. Why: one model across Rust, JS, and the bindings. +- Rewrite the JS queue now instead of waiting for generated lite. Why: the + per-prefix pending map gives the reconcile, and Rust's per-prefix barrier + comes with it, so JS matches Rust today. + +Implementation: + +- Sources. Rust's `Producer::replaying` guard (`rs/moq-net/src/model/origin.rs`) + lives only while a session replays. Widen it into a per-session source that + lives for the whole session and marks when its replay landed; the lite and + IETF subscribers (`rs/moq-net/src/{lite,ietf}/subscriber.rs`) hold it. A + cursor is live once every connected source overlapping its scope has landed + (the per-prefix barrier: `LiveState::Owed` as today), and offline once none + is connected. JS's `replaying` signal (`js/net/src/origin.ts`) widens the + same way. `Reload` (`js/net/src/connection/reload.ts`) registers its source + before its first dial and keeps it across reconnects; one-shot + `Connection.connect`/`accept` register theirs before the handshake and + drop it on failure. +- Local-only: an origin no connection has fed is live at once. Once a + connection has been attached, only a landed replay makes a stream live, so + a failed one-shot never releases into `live`. +- Reconcile. While offline, a cursor stops delivering and keeps folding + changes per prefix. Across the gap, a prefix that ends and comes back is + compared by route (hops, cost), not by the session serving it: unchanged + delivers nothing, changed delivers `update`. Rust's pending map folds + `Unannounce` then `Announce` into an `UnannounceAnnounce` today; it needs + this offline fold. Prefixes still pending when the replay lands are owed + ahead of `live`. +- JS queue. Replace the append-only `AnnounceState.queue` + (`js/net/src/announced.ts`) and the per-tick table diff in + `#runAnnounced` with one pending entry per prefix, taken in path order, + folding and cancelling as Rust's `apply_announce`/`apply_unannounce` do. +- Open, ask the maintainer before building: whether the hold applies to + session egress cursors (`rs/moq-net/src/{lite,ietf}/publisher.rs`). Held + retractions there keep advertising a dead route to peers during the gap, + which delays failover. Recommendation: egress forwards retractions at once + and only app-facing cursors hold. +- Bindings: moq-ffi's announce event (`rs/moq-ffi/src/origin.rs`) gains + `Offline`; follow the moq-ffi row of the cross-package sync table. +- Docs, updated with the code: `doc/concept/moq-lite.md`, + `doc/lib/js/net.md`, `doc/lib/rs/moq-net.md`, and the binding pages that + describe the `live` marker. +- Benchmark: the pending map grows with both prefixes and announcement + consumers. Add a `js/net/bench` case swept over prefix count and consumer + count, with slow consumers draining it, and wire it into + `.github/workflows/nightly.yml` next to the other JS origin benchmarks. + Add an offline-then-reconnect case over the same sweep to + `rs/moq-net/benches/origin.rs`. +- Tests, in Rust and JS: a stream opened before the first connection gets + `live` only after its replay; a failed first connection (one-shot and + `Reload`) yields no `live`; a disconnect yields `offline` with no `end`; a + reconnect with the same set yields only `live`, and with a changed set the + real diffs then `live`; a second connection dropping while another is up + yields nothing; barrier cases for a change to an owed prefix (folded ahead + of `live`), a retraction of one (cancelled, `live` still follows), and a + new prefix sorting ahead of an owed one (may precede `live`). + +Public API: breaks `@moq/net`'s announce `Event` type and `rs/moq-net`'s +announce consumer (`AnnounceEvent`), and the moq-ffi bindings, so it lands on +`dev`. Wire: none; `offline` is local connection state, and the drafts carry +only the initial-set count (`Active Count`), no live marker. diff --git a/quest/m1/announce-page-load.md b/quest/m1/announce-page-load.md deleted file mode 100644 index caca31c2e2..0000000000 --- a/quest/m1/announce-page-load.md +++ /dev/null @@ -1,44 +0,0 @@ -# [S] The live marker waits for the first connection on page load - -## Goal - -An announcement stream in `@moq/net` opened before the first session connects -does not yield `Live` on an empty set. `Live` comes only after that first -session's initial set has been delivered. There is no give-up: a failed -connection shows up as a connection error, never as an empty `Live`, so a -player can't conclude "offline" before the relay has answered. - -Every other JS place with the same page-load gap follows the same rule. - -Out of scope: the order of events that race the `Live` boundary once the -replay lands. JS keeps its append-only announce queue. - -## Plan - -- Decided by the maintainer (2026-09-29): fix page load only. Rust's - per-prefix barrier (`OriginConsumerState::landed` in - `rs/moq-net/src/model/origin.rs` records the prefixes still owed, and `Live` - follows once each is delivered or cancelled) is the target ordering. It - arrives when [generated lite](/quest/m1/rs2ts/lite.md) replaces js/net's - model layer, so don't rebuild the JS queue around per-prefix folding by - hand. Both orderings answer "is it offline?" the same way. -- The JS reconnect loop (`js/net/src/connection/reload.ts`) already counts as - an answerer for requests through `expect()`; it also takes a replay hold on - the origin (`replaying` in `js/net/src/origin.ts`) until its first - session's initial set lands. The hold never times out. -- The one-shot `Connection.connect` and `Connection.accept` (`connect.ts`, - `accept.ts`) register their replay source only after the handshake, so they - take the same hold before it and release it on failure, which surfaces as - the connection error. -- Rust needs no change: a Rust app calls `connect` before it opens the - stream, which then waits on that session's replay. -- Tests: open the stream before the first connection and get `Live` only - after its replay; fail the first connection and get no `Live`; the same - pair for one-shot `connect` and `accept`. - -Public API: `@moq/net` changes when its announcement stream yields `Live`, so -it lands on `dev`. Wire: none. - -## Related - -- [Generated lite](/quest/m1/rs2ts/lite.md) - brings Rust's per-prefix `Live` barrier to JS