From 1199b52a65e2c6217587d5e82a7a90936b7a5afe Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 10 Nov 2026 17:14:50 -0800 Subject: [PATCH 1/4] chore(quest): claim quest/m2/watch-connecting-status.md Quest-Claim: 4901521589eaf135 From bca8fc81a30192ac5266d93c828ac2b9b0f12b8a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 10 Nov 2026 17:20:50 -0800 Subject: [PATCH 2/4] fix(watch)!: a watcher reads connecting, not offline, while its connection is down Co-Authored-By: Claude Opus 5.5 --- demo/web/src/index.ts | 1 + doc/lib/js/watch.md | 5 +- js/watch/src/broadcast.test.ts | 5 + js/watch/src/broadcast.ts | 96 ++++++++++++++----- js/watch/src/include.test.ts | 5 + js/watch/src/offline.test.ts | 89 ++++++++++++----- .../src/ui/components/center-play.test.ts | 2 +- js/watch/src/ui/components/live-badge.ts | 5 +- .../src/ui/components/refused-indicator.ts | 21 ++-- js/watch/verify/Failover.tla | 69 +++++++------ js/watch/verify/Failover.view.ts | 11 ++- js/watch/verify/README.md | 16 ++-- js/watch/verify/Watch.tla | 93 ++++++++++-------- js/watch/verify/Watch.view.ts | 7 +- quest/m2/README.md | 1 - quest/m2/watch-connecting-status.md | 35 ------- 16 files changed, 285 insertions(+), 176 deletions(-) delete mode 100644 quest/m2/watch-connecting-status.md diff --git a/demo/web/src/index.ts b/demo/web/src/index.ts index 812d9fdd959..accd4448e54 100644 --- a/demo/web/src/index.ts +++ b/demo/web/src/index.ts @@ -306,6 +306,7 @@ ui.run((effect) => { else if (stream === "unsupported") setPill("bcast-status", "bcast-text", "Update player", "bad"); else if (stream === "error") setPill("bcast-status", "bcast-text", "Refused", "bad"); else if (watch && stream === "loading") setPill("bcast-status", "bcast-text", "Loading", "wait"); + else if (watch && stream === "connecting") setPill("bcast-status", "bcast-text", "Connecting", "wait"); else setPill("bcast-status", "bcast-text", "Offline", "bad"); }); diff --git a/doc/lib/js/watch.md b/doc/lib/js/watch.md index 2feeb2cc5c9..75b9d4b868c 100644 --- a/doc/lib/js/watch.md +++ b/doc/lib/js/watch.md @@ -78,7 +78,10 @@ one, the player inserts a second recovery point with a zero count before its first slice. The slices are untouched; the stripes clear as the sweep completes. -`el.broadcast.out.status` is `offline`, `loading`, `live`, `error`, or `unsupported`. +`el.broadcast.out.status` is `connecting`, `offline`, `loading`, `live`, `error`, or `unsupported`. +It reads `connecting` until a session could have announced the broadcast: while the +connection dials, redials, or was refused (`el.connection.error` says why), and until the +relay's initial announcements arrive. Only then does a missing broadcast read `offline`. On `unsupported`, update the player to read the broadcast's newer catalog; the overlay shows this notice. On `error` the origin refused the broadcast, `el.broadcast.out.error` says why, and the player does not ask again until it is pointed elsewhere or re-enabled. diff --git a/js/watch/src/broadcast.test.ts b/js/watch/src/broadcast.test.ts index 653672239b4..1fe4f24d3d6 100644 --- a/js/watch/src/broadcast.test.ts +++ b/js/watch/src/broadcast.test.ts @@ -7,6 +7,7 @@ import * as Moq from "@moq/net"; import { Origin, Path } from "@moq/net"; import { Effect, Signal } from "@moq/signals"; import * as Mint from "../../net/src/mint.ts"; +import { wireOf } from "../../net/src/wire.ts"; import { Broadcast } from "./broadcast"; function publish(origin: Origin.Producer, path: Path.Valid) { @@ -146,6 +147,8 @@ describe("unsupported catalog", () => { // broadcast cut every viewer off before it drained the tail the broadcast still served. it("keeps playing a finished catalog until the broadcast ends", async () => { const owner = new Origin.Producer(); + // A session that could announce the broadcast, so its absence reads offline. + wireOf(owner).attach(true); const name = Path.from("room/live.hang"); const published = publish(owner, name); const producer = new Json.Snapshot.Producer({ @@ -177,6 +180,7 @@ it("keeps playing a finished catalog until the broadcast ends", async () => { // A catalog track that ends before its first snapshot has nothing to play, so it reads as offline. it("reports a catalog that ends empty as offline", async () => { const owner = new Origin.Producer(); + wireOf(owner).attach(true); const name = Path.from("room/live.hang"); const published = publish(owner, name); const producer = new Json.Snapshot.Producer({ @@ -765,6 +769,7 @@ describe("refusal", () => { for (const follow of [true, false]) { it(`reports a refusal until a fresh request (follow: ${follow})`, async () => { const owner = new Origin.Producer(); + wireOf(owner).attach(true); const route = owner.claim(Path.from("room")); const requests = route.requested(); const name = new Signal(Path.from("room/refused.hang")); diff --git a/js/watch/src/broadcast.ts b/js/watch/src/broadcast.ts index a237ab2a67c..69accb53c41 100644 --- a/js/watch/src/broadcast.ts +++ b/js/watch/src/broadcast.ts @@ -45,13 +45,14 @@ function provesOther(active: Moq.Broadcast.Consumer | undefined, instance: Moq.I return !Instance.isMinted(active.instance) && active.instance !== instance; } -// Whether an announced route covers `path`, until closed. Hidden routes count: a service claim +// Whether an announced route covers `path`, and whether the origin has heard every attached +// session's initial announcements for it, until closed. Hidden routes count: a service claim // under a `.`-named prefix is kept out of listings, but it still covers the renditions it would // produce, and this is never shown to anyone. function watchCovered( origin: Moq.Origin.Reader, path: Moq.Path.Valid, -): { covered: Getter; close: () => void } { +): { covered: Getter; synced: Getter; close: () => void } { const announced = origin.announced(Path.Pattern.literal(path), { hidden: true, covering: true }); const covered = new Signal(false); const active = new Set(); @@ -69,7 +70,7 @@ function watchCovered( } covered.set(false); })(); - return { covered, close: () => announced.close() }; + return { covered, synced: announced.synced, close: () => announced.close() }; } // Every section carrying a `broadcast` reference must be listed here, same as `Catalog.checkResolvable`; @@ -134,9 +135,12 @@ export type BroadcastInput = { /** Signals the component derives. */ export type BroadcastOutput = { + // "connecting" means no session can answer yet: none is attached, as while the connection + // dials, redials, or was refused, or one is still delivering its initial announcements. + // "offline" means one could answer and the broadcast is not there. // "error" means the origin refused the broadcast; `error` says why. // "unsupported" means its catalog requires a newer player. - status: Signal<"offline" | "loading" | "live" | "error" | "unsupported">; + status: Signal<"connecting" | "offline" | "loading" | "live" | "error" | "unsupported">; active: Signal; // Why the origin refused the broadcast, while `status` is "error". A refusal is final: @@ -151,13 +155,17 @@ export type BroadcastOutput = { type Status = GetterType; +// What the catalog run reports, where "absent" means no handle to read; `#runStatus` turns that +// into "connecting" or "offline". +type Progress = Exclude | "absent"; + /** A catalog source that can wait for announcement before subscribing. */ export class Broadcast { /** The wired inputs, read-only. */ readonly in: Readonlys; readonly #out: BroadcastOutput = { - status: new Signal("offline"), + status: new Signal("connecting"), active: new Signal(undefined), error: new Signal(undefined), catalog: new Signal(undefined), @@ -169,9 +177,18 @@ export class Broadcast { // its relay about the broadcasts a catalog actually references. readonly #watches = new Map< Moq.Path.Valid, - { origin: Moq.Origin.Reader; covered: Getter; close: () => void; users: number } + { + origin: Moq.Origin.Reader; + covered: Getter; + synced: Getter; + close: () => void; + users: number; + } >(); + // `#runCatalog`'s view of the broadcast, before "absent" is told apart. + readonly #progress = new Signal("absent"); + // Requests are shared by callers; each owns its restart watch in the origin. readonly #references = new Map< Moq.Path.Valid, @@ -214,6 +231,33 @@ export class Broadcast { this.#signals.run(this.#runBroadcast.bind(this)); this.#signals.run(this.#runCatalog.bind(this)); this.#signals.run(this.#runFiltered.bind(this)); + this.#signals.run(this.#runStatus.bind(this)); + } + + // Without a handle, the broadcast is offline only once a session could have announced it. + // A connection holds the origin's announcements unsynced while it is not established, and + // a bare origin has no session at all; either way nobody has answered yet. + #runStatus(effect: Effect): void { + const progress = effect.get(this.#progress); + if (progress !== "absent") { + this.#out.status.set(progress); + return; + } + + // A disabled broadcast asks nobody, so there is no answer to wait for. + if (!effect.get(this.in.enabled)) { + this.#out.status.set("offline"); + return; + } + + const origin = effect.get(this.in.origin); + const discovery = origin && effect.get(origin.discovery); + let connecting = discovery === undefined; + // A session without discovery never announces, so there is nothing to wait for. + if (origin && discovery === true) { + connecting = !effect.get(this.#watchPath(effect, origin, effect.get(this.in.name)).synced); + } + this.#out.status.set(connecting ? "connecting" : "offline"); } // Publish the catalog minus every rendition this consumer cannot use: one naming a @@ -230,13 +274,17 @@ export class Broadcast { effect.set(this.#out.catalog, raw ? filterCatalog(raw, usable) : undefined); } - // Whether `path` is covered by an announced route, for `relativeBroadcast`'s - // cross-broadcast refs. Announcements are prefix routes, so a route at "room/" covers - // "room/alice/cam.hang" without naming it. That is how a rendition produced only on demand - // gets selected: its service claims a covering prefix, and nothing announces the exact path - // until this subscribes. Watches exactly `path`, shared by every caller asking about it. - // The blind cases (announcement gate off, no discovery) never reach here; see `#relativeTarget`. - #isPathAnnounced(effect: Effect, origin: Moq.Origin.Reader, path: Moq.Path.Valid): boolean { + // Watch the announcements covering exactly `path`, shared by every caller asking about it + // until the effect reruns. `covered` gates `relativeBroadcast`'s cross-broadcast refs: + // announcements are prefix routes, so a route at "room/" covers "room/alice/cam.hang" without + // naming it. That is how a rendition produced only on demand gets selected: its service claims + // a covering prefix, and nothing announces the exact path until this subscribes. `synced` tells + // `#runStatus` whether the broadcast's absence is known yet. + #watchPath( + effect: Effect, + origin: Moq.Origin.Reader, + path: Moq.Path.Valid, + ): { covered: Getter; synced: Getter } { let entry = this.#watches.get(path); if (!entry || entry.origin !== origin) { entry?.close(); @@ -255,7 +303,7 @@ export class Broadcast { held.close(); }); }); - return effect.get(held.covered); + return held; } // Share one origin request across callers reading the same referenced broadcast. @@ -341,12 +389,12 @@ export class Broadcast { if (!enabled) return; // Even a manual catalog is unplayable once the origin refuses its media. `#runBroadcast` - // clears the error on a fresh request, and this run's cleanup drops back to "offline". + // clears the error on a fresh request, and this run's cleanup drops back to "absent". if (effect.get(this.#out.error)) { - effect.set(this.#out.status, "error", "offline"); + effect.set(this.#progress, "error", "absent"); return; } - effect.cleanup(() => this.#out.status.set("offline")); + effect.cleanup(() => this.#progress.set("absent")); const catalogFormat = effect.get(this.in.catalogFormat); const name = effect.get(this.in.name); @@ -361,7 +409,7 @@ export class Broadcast { // exception would surface as an unhandled error. const catalog = effect.get(this.in.catalog); let accepted: Catalog.Root | undefined; - let status: Status = "loading"; + let status: Progress = "loading"; try { accepted = catalog && @@ -373,7 +421,7 @@ export class Broadcast { } this.#raw.set(accepted, true); effect.cleanup(() => this.#raw.set(undefined, true)); - this.#out.status.set(status); + this.#progress.set(status); return; } @@ -381,7 +429,7 @@ export class Broadcast { // A withdrawn handle can close before its removal propagates through the active signal. if (!broadcast || effect.get(broadcast.closed) !== undefined) return; - this.#out.status.set("loading"); + this.#progress.set("loading"); // The hang catalog is reconstructed from snapshots (and future deltas) via @moq/json, which reads // it compressed or not and subscribes itself; MSF stays on its own one-blob-per-group fetch. @@ -411,7 +459,7 @@ export class Broadcast { effect.cleanup(() => this.#raw.set(undefined)); effect.spawn(async () => { - let status: Status = "offline"; + let status: Progress = "absent"; let received = false; let finished = false; try { @@ -429,7 +477,7 @@ export class Broadcast { console.debug("received catalog", format, this.in.name.peek(), update); this.#raw.set(Catalog.checkResolvable(Catalog.checkRenditions(update), name), true); - this.#out.status.set("live"); + this.#progress.set("live"); } } catch (err) { if (err instanceof Catalog.UnsupportedVersion) status = "unsupported"; @@ -439,7 +487,7 @@ export class Broadcast { } finally { if (!finished) { this.#raw.set(undefined); - this.#out.status.set(status); + this.#progress.set(status); } } }); @@ -472,7 +520,7 @@ export class Broadcast { // gate, only report the path usable once it is announced: the request then resolves // from the table, never blind. if (effect.get(this.in.follow) && effect.get(origin.discovery) !== false) { - if (!this.#isPathAnnounced(effect, origin, resolved)) return undefined; + if (!effect.get(this.#watchPath(effect, origin, resolved).covered)) return undefined; } return { local: false, path: resolved }; diff --git a/js/watch/src/include.test.ts b/js/watch/src/include.test.ts index fa5376512e9..05c181fb3eb 100644 --- a/js/watch/src/include.test.ts +++ b/js/watch/src/include.test.ts @@ -5,6 +5,7 @@ import * as Json from "@moq/json"; import { Instance, Origin, Path, type Track } from "@moq/net"; import { Effect, Signal } from "@moq/signals"; import * as Mint from "../../net/src/mint.ts"; +import { wireOf } from "../../net/src/wire.ts"; import { Broadcast } from "./broadcast"; import { Source } from "./video/source"; @@ -103,6 +104,8 @@ it("opens a listed include only on selection, keeping its rendition IDs its own" it("reads an unavailable include as offline without touching the parent", async () => { const origin = new Origin.Producer(); + // A session that could announce the include, so its absence reads offline. + wireOf(origin).attach(true); publish(origin, "room/main.hang"); const claim = origin.claim(Path.from("room/absent.hang")); const requests = claim.requested(); @@ -220,6 +223,8 @@ it("tracks the include entry without reopening it on unrelated catalog updates", it("follows a restarted contributor only for an entry that names no instance", async () => { const origin = new Origin.Producer(); + // A session that could announce the include, so its absence reads offline. + wireOf(origin).attach(true); publish(origin, "room/main.hang"); const [oldInstance, newInstance] = [Instance.random(), Instance.random()]; const old = publish(origin, "room/extra.hang", oldInstance); diff --git a/js/watch/src/offline.test.ts b/js/watch/src/offline.test.ts index c716b93e597..42032c618b2 100644 --- a/js/watch/src/offline.test.ts +++ b/js/watch/src/offline.test.ts @@ -1,7 +1,6 @@ -// Replays of the OfflineMeansOffline and RefusalIsExplained paths in `verify/Watch.tla`: the -// player reads "offline" whenever it holds no broadcast handle, so the viewer is told OFFLINE -// while it is still connecting, after its own connection drops, and after the relay refused it, -// and the status badge that could say otherwise hides behind that notice. +// The OfflineMeansOffline and RefusalIsExplained paths in `verify/Watch.tla`: the player reads +// "connecting" rather than "offline" until a session could have announced the broadcast, so the +// viewer is never told OFFLINE about its own connection, and a refused token says why. import { expect, test } from "bun:test"; import * as Catalog from "@moq/hang/catalog"; import * as Json from "@moq/json"; @@ -13,11 +12,12 @@ import { createMockTransportPair, waitFor } from "../../net/src/mock.ts"; import { Broadcast } from "./broadcast"; import type MoqWatch from "./element"; import { offlineIndicator } from "./ui/components/offline-indicator"; +import { refusedIndicator } from "./ui/components/refused-indicator"; const url = new URL("https://relay.example/room"); -test("residual: a live broadcast reads offline until connected and again once the connection drops", async () => { - // The publisher is live on the relay the whole time. +test("a live broadcast reads connecting until connected and again once the connection drops", async () => { + // The publisher is live on the relay until the end. const relay = new Origin.Producer(); const name = Path.from("live.hang"); const route = relay.route(name); @@ -29,23 +29,42 @@ test("residual: a live broadcast reads offline until connected and again once th const viewer = new Origin.Producer(); const source = new Broadcast({ origin: viewer, name, catalogFormat: "hang", enabled: true }); - try { - // 0. Still dialing: OFFLINE. - expect(source.out.status.peek()).toBe("offline"); - + const seen = new Set(); + const watching = new Effect(); + watching.run((effect) => { + seen.add(effect.get(source.out.status)); + }); + const dial = async () => { const pair = createMockTransportPair(Lite.ALPN_06); - const [client, server] = await Promise.all([ + return Promise.all([ connect({ url, transport: pair.client, consume: viewer }), accept({ transport: pair.server, url, publish: relay.consume() }), ]); + }; + try { + // Still dialing. + expect(source.out.status.peek()).toBe("connecting"); + + let [client, server] = await dial(); await waitFor(source.out.status, (status) => status === "live"); - // The viewer's connection drops: OFFLINE, though the publisher never stopped. + // The viewer's connection drops, though the publisher never stopped. client.abort(); server.abort(); - await waitFor(source.out.status, (status) => status === "offline"); + await waitFor(source.out.status, (status) => status === "connecting"); expect(route.broadcast.closed.peek()).toBeUndefined(); + + [client, server] = await dial(); + await waitFor(source.out.status, (status) => status === "live"); + expect(seen.has("offline")).toBe(false); + + // Only the publisher stopping, with a session there to say so, reads offline. + route.close(); + await waitFor(source.out.status, (status) => status === "offline"); + client.abort(); + server.abort(); } finally { + watching.close(); source.close(); producer.finish(); route.close(); @@ -54,7 +73,7 @@ test("residual: a live broadcast reads offline until connected and again once th } }); -test("residual: the badge's CONNECTING and DISCONNECTED hide behind OFFLINE", async () => { +test("the badge says CONNECTING or DISCONNECTED in place of OFFLINE, and a refused token says why", async () => { // The only DOM these components touch. const made: { style: { display?: string }; dataset: Record; textContent: string }[] = []; const original = globalThis.document; @@ -78,11 +97,15 @@ test("residual: the badge's CONNECTING and DISCONNECTED hide behind OFFLINE", as }, } as unknown as Document; - // A connection that is redialing, or that stopped on a refused token: no session, so no - // broadcast handle, so the broadcast reads offline. + // A connection that is redialing, or that stopped on a refused token: no session, so the + // broadcast reads connecting. const watch = { - connection: { url: new Signal(url), status: new Signal("connecting") }, - broadcast: { out: { status: new Signal("offline") } }, + connection: { + url: new Signal(url), + status: new Signal("connecting"), + error: new Signal(undefined), + }, + broadcast: { out: { status: new Signal("connecting"), error: new Signal(undefined) } }, sync: { out: { delay: new Signal({ asMillis: () => 0 }) } }, } as unknown as MoqWatch; const state = { tab: new Signal("latency"), panel: new Signal(false) } as never; @@ -93,16 +116,36 @@ test("residual: the badge's CONNECTING and DISCONNECTED hide behind OFFLINE", as const { liveBadge } = await import("./ui/components/live-badge"); const effect = new Effect(); + type Made = (typeof made)[number]; try { - const badge = liveBadge(effect, watch, state) as unknown as (typeof made)[number]; - const offline = offlineIndicator(effect, watch) as unknown as (typeof made)[number]; + const badge = liveBadge(effect, watch, state) as unknown as Made; + const offline = offlineIndicator(effect, watch) as unknown as Made; + const refused = refusedIndicator(effect, watch) as unknown as Made; + const flush = async () => { + for (let i = 0; i < 5; i++) await Promise.resolve(); + }; + for (const status of ["connecting", "disconnected"]) { (watch.connection.status as Signal).set(status); - for (let i = 0; i < 5; i++) await Promise.resolve(); + await flush(); expect(badge.dataset.variant).toBe(status === "connecting" ? "connecting" : "error"); - expect(badge.style.display).toBe("none"); - expect(offline.style.display).toBe(""); + expect(badge.style.display).toBe(""); + expect(offline.style.display).toBe("none"); } + + // Connected, with the relay's announcements still on their way. + (watch.connection.status as Signal).set("connected"); + await flush(); + expect(badge.dataset.variant).toBe("connecting"); + expect(offline.style.display).toBe("none"); + + // The relay refused the token, so the loop stopped. + (watch.connection.status as Signal).set("disconnected"); + (watch.connection.error as Signal).set(new Error("unauthorized")); + await flush(); + expect(refused.style.display).toBe(""); + expect(made.some((el) => el.textContent === "Could not connect: unauthorized")).toBe(true); + expect(offline.style.display).toBe("none"); } finally { effect.close(); globalThis.document = original; diff --git a/js/watch/src/ui/components/center-play.test.ts b/js/watch/src/ui/components/center-play.test.ts index cdfcb1066ab..b6b2f1457ff 100644 --- a/js/watch/src/ui/components/center-play.test.ts +++ b/js/watch/src/ui/components/center-play.test.ts @@ -30,7 +30,7 @@ test("hides the play button while the catalog needs a newer player", async () => }), } as unknown as Document; - const status = new Signal<"offline" | "loading" | "live" | "error" | "unsupported">("live"); + const status = new Signal<"connecting" | "offline" | "loading" | "live" | "error" | "unsupported">("live"); const watch = { controls: { paused: new Signal(true) }, video: { source: { out: { error: new Signal(undefined) } } }, diff --git a/js/watch/src/ui/components/live-badge.ts b/js/watch/src/ui/components/live-badge.ts index 2a86b81eb59..16dd3732f5c 100644 --- a/js/watch/src/ui/components/live-badge.ts +++ b/js/watch/src/ui/components/live-badge.ts @@ -8,11 +8,12 @@ type Variant = "live" | "loading" | "connecting" | "error"; function deriveStatus( url: URL | undefined, connection: "connecting" | "connected" | "disconnected", - broadcast: "offline" | "loading" | "live" | "error" | "unsupported", + broadcast: "connecting" | "offline" | "loading" | "live" | "error" | "unsupported", ): { variant: Variant; text: string } { if (!url) return { variant: "error", text: "No URL" }; if (connection === "disconnected") return { variant: "error", text: "Disconnected" }; - if (connection === "connecting") return { variant: "connecting", text: "Connecting" }; + // Connected but still hearing the relay's announcements reads the same to a viewer. + if (connection === "connecting" || broadcast === "connecting") return { variant: "connecting", text: "Connecting" }; if (broadcast === "offline") return { variant: "error", text: "Offline" }; if (broadcast === "error") return { variant: "error", text: "Refused" }; if (broadcast === "unsupported") return { variant: "error", text: "Update player" }; diff --git a/js/watch/src/ui/components/refused-indicator.ts b/js/watch/src/ui/components/refused-indicator.ts index 6bacfd69612..facda1ced1b 100644 --- a/js/watch/src/ui/components/refused-indicator.ts +++ b/js/watch/src/ui/components/refused-indicator.ts @@ -1,7 +1,7 @@ import type { Effect } from "@moq/signals"; import type MoqWatch from "../../element"; -/** Shows why the origin refused the broadcast, in place of the offline notice. */ +/** Shows why the relay refused the connection, or the origin the broadcast, in place of the offline notice. */ export function refusedIndicator(parent: Effect, watch: MoqWatch): HTMLElement { const container = document.createElement("div"); container.className = "watch-ui__notice watch-ui__refused-indicator"; @@ -13,13 +13,18 @@ export function refusedIndicator(parent: Effect, watch: MoqWatch): HTMLElement { container.appendChild(text); parent.run((effect) => { - const error = effect.get(watch.broadcast.out.error); - container.style.display = error ? "" : "none"; - if (!error) return; - - text.textContent = error.message - ? `This broadcast was refused: ${error.message}` - : "This broadcast was refused"; + // A connection that gave up, as on a refused token, explains more than an older + // refusal of the broadcast, which no session is asking about any more. + const connection = effect.get(watch.connection.error); + const broadcast = effect.get(watch.broadcast.out.error); + container.style.display = connection || broadcast ? "" : "none"; + if (connection) { + text.textContent = connection.message ? `Could not connect: ${connection.message}` : "Could not connect"; + } else if (broadcast) { + text.textContent = broadcast.message + ? `This broadcast was refused: ${broadcast.message}` + : "This broadcast was refused"; + } }); return container; diff --git a/js/watch/verify/Failover.tla b/js/watch/verify/Failover.tla index 879316f3ea5..ce904acf89c 100644 --- a/js/watch/verify/Failover.tla +++ b/js/watch/verify/Failover.tla @@ -7,17 +7,18 @@ \* handover while the replacement dials the configured URL. A GOAWAY naming a relay pins every \* later dial to it (`#redirect`, "an assignment, not a detour"), until the URL changes; one the \* default `same-host` policy refuses stops the loop. A session that dies retracts its -\* announcements, so the watch's handle clears and the screen reads OFFLINE (Watch.tla); a route -\* of the same instance taking over keeps the handle and resumes its tracks (`Following.active`). -\* `path` is whether the relays reach the origin at all. +\* announcements, so the watch's handle clears and the screen reads CONNECTING until a new session +\* delivers its initial announcements (`heard`, Watch.tla); a route of the same instance taking +\* over keeps the handle and resumes its tracks (`Following.active`). `path` is whether the relays +\* reach the origin at all; a serving relay's announcement of it reaches the viewer in the same step. EXTENDS Naturals, FiniteSets \* `Redirects`: whether relays name a URI in their GOAWAY. moq-relay never does. CONSTANTS Relays, Redirects -VARIABLES up, on, loop, pin, path, handle, media +VARIABLES up, on, loop, pin, path, heard, handle, media -vars == <> +vars == <> Init == /\ up = Relays @@ -25,6 +26,7 @@ Init == /\ loop = "serving" /\ pin = 0 /\ path = "ok" + /\ heard = TRUE /\ handle = TRUE /\ media = "playing" @@ -32,13 +34,15 @@ Init == Targets == IF pin # 0 THEN {pin} \cap up ELSE up Lose == handle' = FALSE /\ media' = "waiting" +\* The serving session is gone: its announcements retract, and the origin is unsynced again. +Leave == on' = 0 /\ heard' = FALSE /\ Lose \* A relay drains with an empty URI: it leaves the configured URL, and the replacement dials. GoawayRestart == /\ loop = "serving" /\ up' = up \ {on} /\ loop' = "migrating" - /\ UNCHANGED <> + /\ UNCHANGED <> \* A relay drains naming another relay, which the policy follows (`follow`, or `same-host` for \* a sibling port): later dials go only there. @@ -47,15 +51,14 @@ GoawayTo(r) == /\ up' = up \ {on} /\ loop' = "migrating" /\ pin' = r - /\ UNCHANGED <> + /\ UNCHANGED <> \* A relay names another host under the default `same-host` policy: RefusedRedirect gives up. GoawayRefused == /\ Redirects /\ loop = "serving" /\ up' = up \ {on} /\ loop' = "stopped" - /\ on' = 0 - /\ Lose + /\ Leave /\ UNCHANGED <> \* The replacement connects within the handover: the same instance keeps the handle. @@ -63,72 +66,80 @@ Replaced == /\ loop = "migrating" /\ \E t \in Targets : on' = t /\ loop' = "serving" - /\ UNCHANGED <> + /\ UNCHANGED <> \* The handover ends before a replacement serves: nothing serves. HandoverEnds == /\ loop = "migrating" - /\ on' = 0 /\ loop' = "dialing" - /\ Lose + /\ Leave /\ UNCHANGED <> Crash(r) == /\ r \in up /\ up' = up \ {r} /\ IF on = r /\ loop = "serving" - THEN on' = 0 /\ loop' = "waiting" /\ Lose - ELSE UNCHANGED <> + THEN loop' = "waiting" /\ Leave + ELSE UNCHANGED <> /\ UNCHANGED <> Recover(r) == /\ r \notin up /\ r # on /\ up' = up \cup {r} - /\ UNCHANGED <> + /\ UNCHANGED <> -BackoffEnds == loop = "waiting" /\ loop' = "dialing" /\ UNCHANGED <> +BackoffEnds == loop = "waiting" /\ loop' = "dialing" /\ UNCHANGED <> Connected == /\ loop = "dialing" /\ \E t \in Targets : on' = t /\ loop' = "serving" - /\ UNCHANGED <> + /\ UNCHANGED <> DialFails == /\ loop = "dialing" /\ Targets = {} /\ loop' = "waiting" - /\ UNCHANGED <> - -\* The broadcast's announcement reaches the viewer, which subscribes again. -Announced == - /\ on # 0 /\ path = "ok" /\ ~handle - /\ handle' = TRUE + /\ UNCHANGED <> + +\* The new session's initial announcements arrive: the viewer subscribes again if the relays +\* reach the broadcast, and knows it is unreachable otherwise. +Initial == + /\ on # 0 /\ ~heard + /\ heard' = TRUE + /\ handle' = (path = "ok") /\ UNCHANGED <> -Frames == handle /\ media = "waiting" /\ media' = "playing" /\ UNCHANGED <> +Frames == handle /\ media = "waiting" /\ media' = "playing" /\ UNCHANGED <> \* The relays move to another path to the origin: the same instance, so its tracks resume. Reroute == /\ path = "ok" /\ handle /\ media = "playing" /\ media' = "waiting" - /\ UNCHANGED <> + /\ UNCHANGED <> -PathLost == path = "ok" /\ path' = "lost" /\ Lose /\ UNCHANGED <> -PathBack == path = "lost" /\ path' = "ok" /\ UNCHANGED <> +PathLost == path = "ok" /\ path' = "lost" /\ Lose /\ UNCHANGED <> +PathBack == + /\ path = "lost" /\ path' = "ok" + /\ handle' = (on # 0 /\ heard) + /\ UNCHANGED <> Next == \/ GoawayRestart \/ \E r \in Relays : GoawayTo(r) \/ GoawayRefused \/ Replaced \/ HandoverEnds \/ \E r \in Relays : Crash(r) \/ Recover(r) \/ BackoffEnds \/ Connected \/ DialFails - \/ Announced \/ Frames \/ Reroute \/ PathLost \/ PathBack + \/ Initial \/ Frames \/ Reroute \/ PathLost \/ PathBack Spec == Init /\ [][Next]_vars \* Rules. +\* offline-indicator.ts: no handle, though a serving session's announcements have arrived. Without +\* one the screen reads CONNECTING, or the loop's error once it stopped. +OfflineShown == ~handle /\ on # 0 /\ heard + \* The viewer sees OFFLINE only when no relay can reach the broadcast. -OfflineOnlyWhenUnreachable == ~handle => (path = "lost" \/ up = {}) +OfflineOnlyWhenUnreachable == OfflineShown => (path = "lost" \/ up = {}) \* A viewer whose relay left keeps reconnecting while some relay is up. KeepsReconnecting == (on = 0 /\ up # {}) => loop # "stopped" diff --git a/js/watch/verify/Failover.view.ts b/js/watch/verify/Failover.view.ts index bf0c1cbed52..b8b67a7729b 100644 --- a/js/watch/verify/Failover.view.ts +++ b/js/watch/verify/Failover.view.ts @@ -8,6 +8,7 @@ type Vars = { loop: "serving" | "migrating" | "dialing" | "waiting" | "stopped"; pin: number; path: "ok" | "lost"; + heard: boolean; handle: boolean; media: "playing" | "waiting"; }; @@ -27,7 +28,11 @@ const connection = (v: Vars) => { } }; -const screen = (v: Vars) => (!v.handle ? "OFFLINE" : v.media === "waiting" ? "spinner" : "playing"); +const screen = (v: Vars) => { + if (v.handle) return v.media === "waiting" ? "spinner" : "playing"; + if (v.on && v.heard) return "OFFLINE"; + return v.loop === "stopped" ? "could not connect, and why" : "CONNECTING"; +}; export const title = "Relay failover, from the viewer's side"; @@ -61,8 +66,8 @@ export const step = (action: string, from: Vars, to: Vars) => { return "the redial connects"; case "DialFails": return "the redial fails"; - case "Announced": - return "the broadcast's announcement arrives"; + case "Initial": + return "the relay's announcements arrive"; case "Frames": return "frames arrive"; case "Reroute": diff --git a/js/watch/verify/README.md b/js/watch/verify/README.md index 351db7e871d..692485bc8ee 100644 --- a/js/watch/verify/README.md +++ b/js/watch/verify/README.md @@ -9,16 +9,15 @@ can hit. `Watch.tla` follows one `` through the relay connection (`@moq/net`'s reconnect loop), the publisher going live and stopping, the catalog, the media, and pause. What the screen shows is derived as `src/ui/components` derives it: the OFFLINE notice, the buffering spinner, -the unsupported notice, and the status badge. +the unsupported notice, the refusal notice, and the status badge. | Rule | Holds when | Today | | --- | --- | --- | | `NeverFrozenSilently` | A viewer whose video is not moving, and who did not pause it, is told something. | Holds. | -| `OfflineMeansOffline` | The player says OFFLINE only when the broadcast is not live. | Breaks: OFFLINE shows while the player is still connecting, and again whenever the connection drops, because the broadcast reads offline whenever the player holds no handle. | -| `RefusalIsExplained` | A viewer the relay refused is told so. | Breaks: a refused token stops the reconnect loop, and the viewer sees OFFLINE for good. | +| `OfflineMeansOffline` | The player says OFFLINE only when the broadcast is not live. | Holds: the broadcast reads `connecting` until a session's initial announcements arrive, and the badge says CONNECTING or DISCONNECTED meanwhile. | +| `RefusalIsExplained` | A viewer the relay refused is told so. | Holds: the refusal notice shows the connection's error. | -The badge that could say CONNECTING or DISCONNECTED hides while the broadcast reads offline, -which is whenever the connection is down. `src/offline.test.ts` replays both breaks. +`src/offline.test.ts` replays both paths. ## Relay failover @@ -27,14 +26,14 @@ come back, drain with a GOAWAY, and lose or move their path to the origin. | Rule | Holds when | Today | | --- | --- | --- | -| `OfflineOnlyWhenUnreachable` | The viewer sees OFFLINE only when no relay can reach the broadcast. | Breaks: the viewer's relay crashing shows OFFLINE until the redial lands and the announcement arrives, though the other relay serves the broadcast. | +| `OfflineOnlyWhenUnreachable` | The viewer sees OFFLINE only when no relay can reach the broadcast. | Holds: the viewer's relay crashing reads CONNECTING until the redial lands and its announcements arrive. | | `KeepsReconnecting` | A viewer whose relay left keeps reconnecting while some relay is up. | Holds with moq-relay (`Failover.cfg`). Breaks with a relay whose GOAWAY names another host (`Redirect.cfg`): the default `same-host` policy refuses it and stops the loop. | | `RedialsALiveRelay` | A reconnecting viewer can land on a relay that is up. | Holds with moq-relay. Breaks under `Redirect.cfg`: a followed redirect is sticky, so once that relay dies every redial goes back to it, never to the configured URL. | A GOAWAY whose replacement connects within the handover keeps playing, and a move to another path to the origin only stalls. `Failover.cfg` plays relays as moq-relay runs them, draining with an empty-URI GOAWAY; `Redirect.cfg` lets relays name a URI. The crash replays through -`src/offline.test.ts` (a dropped session reads offline), the refused redirect through `a refused +`src/offline.test.ts` (a dropped session reads connecting), the refused redirect through `a refused redirect ends the connection instead of reconnecting` in `@moq/net`, and the sticky redirect through `residual: a redirected connection redials only its dead target` there. @@ -51,7 +50,8 @@ its config, and its `.view.ts`. ## Left out -Rendition selection, audio, captions, the `visible` gate, latency, and manual catalogs. The +Rendition selection, audio, captions, the `visible` gate, latency, and manual catalogs. An +announcement's transit: one reaches a synced viewer in the step that makes it. The catalog failing mid-stream, a relay refusing the broadcast itself (`refused-indicator.ts`), and broadcasts referenced from another's catalog. In `Failover.tla`, a GOAWAY's replacement keeps the handle as a route of the same instance does; a lite-06 session mints a new instance, so the player diff --git a/js/watch/verify/Watch.tla b/js/watch/verify/Watch.tla index dab21e935ca..0dd7cdf562f 100644 --- a/js/watch/verify/Watch.tla +++ b/js/watch/verify/Watch.tla @@ -2,75 +2,88 @@ \* A viewer watching one broadcast in , as the viewer sees it. \* \* `net` is the relay connection (js/net/src/connection/reload.ts): dialing, serving, waiting -\* out a backoff, or stopped after the relay refused the token. `active` is the broadcast handle -\* Broadcast.#runBroadcast follows, which the session's announcement sets and its retraction or the -\* publisher's end clears. `status` is Broadcast.out.status from #runCatalog. `media` is whether -\* frames are painting. What the screen shows is derived exactly as js/watch/src/ui/components -\* derive it: the OFFLINE notice, the buffering spinner, the unsupported notice, and the status -\* badge, which hides while the broadcast reads offline. +\* out a backoff, or stopped after the relay refused the token. `heard` is whether the serving +\* session has delivered its initial announcements; the connection holds the origin's listings +\* unsynced until then. `active` is the broadcast handle Broadcast.#runBroadcast follows, which +\* the session's announcement sets and its retraction or the publisher's end clears. `status` is +\* Broadcast.out.status: "connecting" without a handle until a session could have announced the +\* broadcast (#runStatus), and #runCatalog's progress otherwise. `media` is whether frames are +\* painting. What the screen shows is derived exactly as js/watch/src/ui/components derive it. +\* An announcement reaches a synced viewer in the step that makes it; its transit is left out. EXTENDS Naturals -VARIABLES net, live, active, status, media, paused +VARIABLES net, heard, live, active, status, media, paused -vars == <> +vars == <> Init == /\ net = "connecting" + /\ heard = FALSE /\ live \in BOOLEAN /\ active = FALSE - /\ status = "offline" + /\ status = "connecting" /\ media = "none" /\ paused = FALSE +Synced == net = "connected" /\ heard + \* The relay connection. -Connect == net = "connecting" /\ net' = "connected" /\ UNCHANGED <> -DialFails == net = "connecting" /\ net' = "retrying" /\ UNCHANGED <> -Redial == net = "retrying" /\ net' = "connecting" /\ UNCHANGED <> -\* An UNAUTHORIZED, MALFORMED_AUTH_TOKEN, or EXPIRED close stops the loop (Reload.#retry). -Refused == net = "connecting" /\ net' = "refused" /\ UNCHANGED <> -\* The entries a session fed retract when it dies, so the handle clears and #runCatalog's -\* cleanup sets "offline". +Connect == net = "connecting" /\ net' = "connected" /\ UNCHANGED <> +DialFails == net = "connecting" /\ net' = "retrying" /\ UNCHANGED <> +Redial == net = "retrying" /\ net' = "connecting" /\ UNCHANGED <> +\* An UNAUTHORIZED, MALFORMED_AUTH_TOKEN, or EXPIRED close stops the loop (Reload.#retry) and +\* sets Reload.error. +Refused == net = "connecting" /\ net' = "refused" /\ UNCHANGED <> +\* The session's initial announcements arrive: the broadcast is announced, or now known absent. +Initial == + /\ net = "connected" /\ ~heard + /\ heard' = TRUE + /\ active' = live + /\ status' = IF live THEN "loading" ELSE "offline" + /\ UNCHANGED <> +\* The entries a session fed retract when it dies, and the connection takes the origin's sync +\* back, so the handle clears and the status reads "connecting". Drop == /\ net = "connected" /\ net' = "retrying" + /\ heard' = FALSE /\ active' = FALSE - /\ status' = "offline" + /\ status' = "connecting" /\ media' = "none" /\ UNCHANGED <> -\* The publisher. -Start == ~live /\ live' = TRUE /\ UNCHANGED <> +\* The publisher, whose announcement or retraction reaches a synced viewer. +Start == + /\ ~live + /\ live' = TRUE + /\ active' = Synced + /\ status' = IF Synced THEN "loading" ELSE status + /\ UNCHANGED <> Stop == /\ live /\ live' = FALSE /\ active' = FALSE - /\ status' = "offline" + /\ status' = IF Synced THEN "offline" ELSE status /\ media' = "none" - /\ UNCHANGED <> + /\ UNCHANGED <> -\* The broadcast's announcement reaches the viewer's origin, and #runCatalog subscribes. -Announced == - /\ net = "connected" /\ live /\ ~active - /\ active' = TRUE - /\ status' = "loading" - /\ UNCHANGED <> -CatalogArrives == active /\ status = "loading" /\ status' = "live" /\ UNCHANGED <> +CatalogArrives == active /\ status = "loading" /\ status' = "live" /\ UNCHANGED <> \* Catalog.UnsupportedVersion. -CatalogTooNew == active /\ status = "loading" /\ status' = "unsupported" /\ UNCHANGED <> +CatalogTooNew == active /\ status = "loading" /\ status' = "unsupported" /\ UNCHANGED <> \* Media. Frames == /\ status = "live" /\ ~paused /\ media # "playing" /\ media' = "playing" - /\ UNCHANGED <> -Stall == media = "playing" /\ media' = "stalled" /\ UNCHANGED <> -Pause == ~paused /\ paused' = TRUE /\ media' = "none" /\ UNCHANGED <> -Play == paused /\ paused' = FALSE /\ UNCHANGED <> + /\ UNCHANGED <> +Stall == media = "playing" /\ media' = "stalled" /\ UNCHANGED <> +Pause == ~paused /\ paused' = TRUE /\ media' = "none" /\ UNCHANGED <> +Play == paused /\ paused' = FALSE /\ UNCHANGED <> Next == - \/ Connect \/ DialFails \/ Redial \/ Refused \/ Drop + \/ Connect \/ DialFails \/ Redial \/ Refused \/ Initial \/ Drop \/ Start \/ Stop - \/ Announced \/ CatalogArrives \/ CatalogTooNew + \/ CatalogArrives \/ CatalogTooNew \/ Frames \/ Stall \/ Pause \/ Play Spec == Init /\ [][Next]_vars @@ -83,10 +96,12 @@ Spinner == (status = "loading" \/ (status = "live" /\ media # "playing")) /\ Onl OfflineShown == status = "offline" \* unsupported-indicator.ts. UpdateShown == status = "unsupported" -\* live-badge.ts hides while the broadcast reads offline or unsupported; only then can it say -\* CONNECTING or DISCONNECTED. +\* refused-indicator.ts shows Reload.error, set once the loop gives up. +RefusedShown == net = "refused" +\* live-badge.ts hides while the broadcast reads offline or unsupported, and says CONNECTING or +\* DISCONNECTED while the connection is down or its announcements have not arrived. BadgeShown == status \notin {"offline", "unsupported"} -ConnectionShown == BadgeShown /\ net # "connected" +ConnectionShown == BadgeShown /\ (net # "connected" \/ status = "connecting") \* Rules. @@ -98,6 +113,6 @@ NeverFrozenSilently == OfflineMeansOffline == OfflineShown => ~live \* A viewer the relay refused is told so, rather than shown OFFLINE. -RefusalIsExplained == net = "refused" => ~OfflineShown +RefusalIsExplained == net = "refused" => (RefusedShown /\ ~OfflineShown) ==== diff --git a/js/watch/verify/Watch.view.ts b/js/watch/verify/Watch.view.ts index e6af6436bc2..5d5b45df4cf 100644 --- a/js/watch/verify/Watch.view.ts +++ b/js/watch/verify/Watch.view.ts @@ -3,9 +3,10 @@ type Vars = { net: "connecting" | "connected" | "retrying" | "refused"; + heard: boolean; live: boolean; active: boolean; - status: "offline" | "loading" | "live" | "unsupported"; + status: "connecting" | "offline" | "loading" | "live" | "unsupported"; media: "none" | "playing" | "stalled"; paused: boolean; }; @@ -13,6 +14,8 @@ type Vars = { const connection = { connecting: "dialing", connected: "connected", retrying: "waiting to redial", refused: "refused" }; const screen = (v: Vars) => { + if (v.net === "refused") return "could not connect, and why"; + if (v.status === "connecting") return "CONNECTING"; if (v.status === "unsupported") return "UPDATE YOUR PLAYER"; if (v.status === "offline") return "OFFLINE"; if (v.paused) return "paused"; @@ -33,10 +36,10 @@ const steps: Record = { DialFails: "a dial fails", Redial: "the backoff ends", Refused: "the relay refuses the token", + Initial: "the relay's announcements arrive", Drop: "the connection drops", Start: "the publisher goes live", Stop: "the publisher stops", - Announced: "the broadcast's announcement arrives", CatalogArrives: "the catalog arrives", CatalogTooNew: "the catalog needs a newer player", Frames: "frames arrive", diff --git a/quest/m2/README.md b/quest/m2/README.md index 017490c24d6..f74bca0f29d 100644 --- a/quest/m2/README.md +++ b/quest/m2/README.md @@ -206,6 +206,5 @@ quests that require them. - [Finishing a media producer returns a Finished handle](/quest/m2/finished-handle.md) - `finish(self)` consumes the media crates' producers, as it does moq-net's, so appending after finish doesn't compile - [Probe](/quest/m2/probe/README.md) - a session probes up to the bitrate it wants to send or receive, so a stopped simulcast rendition resumes and a viewer up-switches; browser padding is parked in m3 (held in m2 by open claims; moves to m3) - [Finish the milestone re-cut](/quest/m2/recut-held.md) - claim-held epics move to the milestone their kind and priority give them once their claims merge -- [A watcher reads connecting, not offline, while its connection is down](/quest/m2/watch-connecting-status.md) - `@moq/watch` status says OFFLINE only when connected and unannounced - [moq-transport is a compatibility layer again](/quest/m2/ietf-scope/README.md) - our extensions, JS FETCH serving, and track listing leave moq-transport; live media and joining FETCH stay - [The reconnect loop stops judging errors by kind](/quest/m2/reconnect-no-error-kinds.md) - after a first session every failure retries with backoff; `Verdict::Refused`, `Verdict::Fatal`, and `From<&Error> for Verdict` go diff --git a/quest/m2/watch-connecting-status.md b/quest/m2/watch-connecting-status.md deleted file mode 100644 index fd436e7a7ee..00000000000 --- a/quest/m2/watch-connecting-status.md +++ /dev/null @@ -1,35 +0,0 @@ -# [M] A watcher reads connecting, not offline, while its connection is down - -## Goal - -`@moq/watch` says a broadcast is offline only when its relay connection is up -and the broadcast is not announced. While the player dials, redials, or was -refused, its status and UI say so instead, and a refused token shows why. - -## Plan - -Found 2026-11-10 by the watch and failover charts ([moq-dev/moq#8447](https://github.com/moq-dev/moq/pull/8447)): -`js/watch/verify/Watch.tla` breaks `OfflineMeansOffline` in 0 steps and -`RefusalIsExplained`, and `Failover.tla` breaks `OfflineOnlyWhenUnreachable` -in 1. `Broadcast.status` in `js/watch/src/broadcast.ts` reads `"offline"` -whenever the player holds no broadcast handle, so OFFLINE flashes on every -page load and after any drop of the viewer's own connection, and the badge -that could say CONNECTING or DISCONNECTED (`live-badge.ts`) is hidden exactly -then. Viewers read their own network blip as "the stream ended". The two -`residual:` tests in `js/watch/src/offline.test.ts` pin it. - -Decided 2026-11-10 (maintainer, /quest-plan): ✅ `Broadcast.status` gains a -value for "the connection is not up" (name it by role, such as -`"connecting"`), and the UI shows it and `connection.error` for a refused -token (rejected: a UI-only fix that keeps `"offline"` in the API, so API -consumers keep misreading a dead connection). This changes `@moq/watch`'s -status union, so it is breaking and joins m2; its Impact section says how to -migrate. Mirror the shape in any binding that reports watch status. - -Flip the residual tests, update the Watch and Failover charts so their rules -hold, and refresh `js/watch/verify/README.md`. - -Caused by: no single PR; status has conflated "no handle" with "offline" -since the watch element read status from the broadcast handle. - -Public API: breaking, `@moq/watch` status. Wire: none. From dfef0ae764820975f4898ac85b8ec7c190f78ce4 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 10 Nov 2026 17:38:45 -0800 Subject: [PATCH 3/4] chore(watch): refresh the API report for the connecting status Co-Authored-By: Claude Opus 5.5 --- js/watch/api/index.api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/watch/api/index.api.md b/js/watch/api/index.api.md index 037844e6fb8..05da76ed8ed 100644 --- a/js/watch/api/index.api.md +++ b/js/watch/api/index.api.md @@ -125,7 +125,7 @@ export type BroadcastOutput = { active: Signals.Signal; catalog: Signals.Signal; error: Signals.Signal; - status: Signals.Signal<"offline" | "loading" | "live" | "error" | "unsupported">; + status: Signals.Signal<"connecting" | "offline" | "loading" | "live" | "error" | "unsupported">; }; export { BufferedRange } from "@moq/hang/container"; From 5855cf39a03d9a19d0bf1c216605cffa282d6c66 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 11 Nov 2026 00:12:03 -0800 Subject: [PATCH 4/4] doc(watch): an unset origin reads connecting Co-Authored-By: Claude Opus 5.5 --- js/watch/src/broadcast.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/js/watch/src/broadcast.ts b/js/watch/src/broadcast.ts index 69accb53c41..a2cf2b32a4f 100644 --- a/js/watch/src/broadcast.ts +++ b/js/watch/src/broadcast.ts @@ -109,6 +109,7 @@ export type CatalogFormat = (typeof CATALOG_FORMATS)[number]; export type BroadcastInput = { // The read-side origin to borrow. Independent of any connection: whichever sessions feed // the origin resolve the broadcast, and the handle spans their reconnects. + // Unset reads "connecting", like an origin with no session attached, never "offline". origin: Getter; // Whether to start downloading the broadcast. Defaults to true.