diff --git a/quest/m1/README.md b/quest/m1/README.md index fcc59dbd0a..dae00e5d95 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -28,9 +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 -- [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 +- [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 54e4adbba8..08e8e435ff 100644 --- a/quest/m1/announce-empty-state.md +++ b/quest/m1/announce-empty-state.md @@ -2,15 +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. +`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. 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/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 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 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-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-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 8cf4b4cfb5..0000000000 --- a/quest/m1/announce-page-load.md +++ /dev/null @@ -1,19 +0,0 @@ -# [S] 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`. - -## 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.