diff --git a/demo/web/src/index.ts b/demo/web/src/index.ts index 4de5258def..ba61088032 100644 --- a/demo/web/src/index.ts +++ b/demo/web/src/index.ts @@ -79,7 +79,7 @@ const metaSignal = new Signals.Signal(undefined); const relayUrl = new Signals.Signal(new URL(RELAY_URL)); // Discovery connection (the tiles each open their own connection internally). -const connection = new Net.Connection.Reload({ url: relayUrl, enabled: true }); +const connection = new Net.Connection.Shared({ url: relayUrl }); // --------------------------------------------------------------------------- // Per-broadcast tile (a in the left column) @@ -382,8 +382,8 @@ ui.run((effect) => { section.hidden = false; // Report the transport negotiated by the live connection. - const conn = effect.get(connection.established); - $("network-transport").textContent = conn ? (conn.transport === "websocket" ? "WebSocket" : "WebTransport") : ""; + const transport = effect.get(connection.transport); + $("network-transport").textContent = transport ? (transport === "websocket" ? "WebSocket" : "WebTransport") : ""; const video = effect.get(watch.video.out.stats); const audio = effect.get(watch.audio.out.stats); diff --git a/demo/web/src/publish.ts b/demo/web/src/publish.ts index bf2e9779ac..26f23e8f3e 100644 --- a/demo/web/src/publish.ts +++ b/demo/web/src/publish.ts @@ -363,8 +363,8 @@ ui.run((effect) => { // Report the transport negotiated by the live connection. ui.run((effect) => { - const conn = effect.get(publish.connection.established); - $("network-transport").textContent = conn ? (conn.transport === "websocket" ? "WebSocket" : "WebTransport") : ""; + const transport = effect.get(publish.connection.transport); + $("network-transport").textContent = transport ? (transport === "websocket" ? "WebSocket" : "WebTransport") : ""; }); // Audio: the resolved audio config (codec / sample rate / channels / bitrate). diff --git a/demo/web/src/stats.ts b/demo/web/src/stats.ts index 0ff7d5a5f2..d7208789eb 100644 --- a/demo/web/src/stats.ts +++ b/demo/web/src/stats.ts @@ -84,18 +84,18 @@ const selectedNode = new Signals.Signal(undefined); // The relay URL, editable at runtime (see the input binding below). const relayUrl = new Signals.Signal(new URL(RELAY_URL)); -const connection = new Net.Connection.Reload({ url: relayUrl, enabled: true }); +const connection = new Net.Connection.Shared({ url: relayUrl }); // ---- Discover nodes + subscribe to each ----------------------------------- const discovery = new Signals.Effect(); discovery.run((effect) => { - const conn = effect.get(connection.established); + const origin = effect.get(connection.origin); nodeStats.set({}); - if (!conn) return; + if (!origin) return; const prefix = Net.Path.from(STATS_PREFIX); - const announced = conn.announced(prefix); + const announced = origin.announced(prefix); effect.cleanup(() => announced.close()); // One sub-effect per node so we can tear a node's subscriptions down when it @@ -117,7 +117,7 @@ discovery.run((effect) => { if (subs.has(node)) continue; const ne = new Signals.Effect(); subs.set(node, ne); - subscribeNode(ne, conn, path, node); + subscribeNode(ne, origin, path, node); } else { subs.get(node)?.close(); subs.delete(node); @@ -129,7 +129,7 @@ discovery.run((effect) => { }); }); -function subscribeNode(effect: Signals.Effect, conn: Net.Connection.Established, path: Net.Path.Valid, node: string) { +function subscribeNode(effect: Signals.Effect, origin: Net.Origin.Table, path: Net.Path.Valid, node: string) { nodeStats.mutate((s) => { s[node] = { egress: {}, @@ -138,8 +138,11 @@ function subscribeNode(effect: Signals.Effect, conn: Net.Connection.Established, }; }); - const consumer = conn.consume(path); - effect.cleanup(() => consumer.close()); + // The path was just announced, so the request resolves from the table immediately. + const request = origin.request(path); + effect.cleanup(() => request.close()); + const consumer = request.active.peek(); + if (!consumer) return; const sub = (trackName: string, key: K) => { const track = consumer.subscribe(trackName); @@ -256,7 +259,7 @@ sampler.run((effect) => { // Only sample while connected; the interval restarts on reconnect. Drop the // rolling history when disconnected so a reconnect doesn't splice new // samples onto stale ones across the downtime gap. - if (!effect.get(connection.established)) { + if (effect.get(connection.status) !== "connected") { history.clear(); clusterMembership = ""; clock.update((n) => n + 1); diff --git a/doc/lib/js/@moq/net.md b/doc/lib/js/@moq/net.md index 72db0a77ce..78f37dd354 100644 --- a/doc/lib/js/@moq/net.md +++ b/doc/lib/js/@moq/net.md @@ -44,6 +44,37 @@ See [`js/net/examples/discovery.ts`](https://github.com/moq-dev/moq/blob/main/js ## Core Concepts +### Origins + +A routing table of broadcasts by path, independent of any connection. Publishing goes through an origin, not a session: create one, publish broadcasts into it, and hand it to a connection via the `publish` option. The connection announces and serves the table for as long as the session lasts, and a reconnect re-announces whatever is still published. + +```ts +const origin = new Moq.Origin.Producer(); +await Moq.Connection.connect(url, { publish: origin.consume() }); + +const broadcast = origin.publish(Moq.Path.from("my-broadcast")); +broadcast.createTrack("chat"); +``` + +Closing the connection unannounces the broadcasts but does not close them; they stay in the origin for the next session. Closing a broadcast's producer unpublishes just that path. + +The other direction works the same way: pass an origin as the `subscribe` option and everything the peer announces appears in its table, gone when the session dies. + +`origin.request(path)` is how you consume by path, whether or not anything announced it: + +```ts +// One origin can back both directions of the same connection. +await Moq.Connection.connect(url, { publish: origin.consume(), subscribe: origin }); + +const request = origin.request(Moq.Path.from("some-broadcast")); +const broadcast = request.active.peek(); // or effect.get(request.active) +request.close(); // when done +``` + +`request.active` follows whatever the table routes: a local publish first, so a page that publishes and watches the same broadcast reads its own copy with no round trip, then any session's announcement, swapping when a republish takes the path. When nothing routes it (a relay without discovery, or subscribing before the publisher exists on purpose), an attached session answers it blind instead. Either way the request stands across reconnects, so hold it for as long as you want the path, and close it when you don't. + +Use `origin.announced(prefix)` to discover what is available rather than asking for a path you already know. + ### Broadcasts A collection of related tracks. diff --git a/doc/lib/js/@moq/publish.md b/doc/lib/js/@moq/publish.md index 02dc959df7..b9c4e70654 100644 --- a/doc/lib/js/@moq/publish.md +++ b/doc/lib/js/@moq/publish.md @@ -116,9 +116,14 @@ The overlay has no `simulcast` control; enable it via the attribute on the neste ```typescript import * as Publish from "@moq/publish"; +import * as Moq from "@moq/net"; + +// A connection shared with every other component pointed at the same URL. Its `origin` is +// where the broadcasts live, so they survive a reconnect. +const connection = new Moq.Connection.Shared({ url: new URL("https://relay.example.com/anon") }); const broadcast = new Publish.Broadcast({ - connection, + origin: connection.origin, enabled: true, name: "alice.hang", // Publish two video renditions: video/hd plus a lower-resolution video/sd. diff --git a/doc/lib/js/@moq/watch.md b/doc/lib/js/@moq/watch.md index 0e03554f37..c0aa012c1a 100644 --- a/doc/lib/js/@moq/watch.md +++ b/doc/lib/js/@moq/watch.md @@ -94,9 +94,14 @@ from the broadcast name extension by default. `room/alice.hang` uses hang, ```typescript import * as Watch from "@moq/watch"; +import * as Moq from "@moq/net"; + +// A connection shared with every other component pointed at the same URL. Its `origin` is +// where the broadcasts live, so the handle spans reconnects. +const connection = new Moq.Connection.Shared({ url: new URL("https://relay.example.com/anon") }); const broadcast = new Watch.Broadcast({ - connection, + origin: connection.origin, enabled: true, name: "alice.hang", catalogFormat: "msf", @@ -109,7 +114,7 @@ broadcast.catalogFormat.set("msf"); ### Manual catalogs Use `catalog-format="manual"` (or `catalogFormat: "manual"`) to skip the catalog -track entirely and supply a `Catalog.Root` directly. The connection and +track entirely and supply a `Catalog.Root` directly. The origin and broadcast name are still required, since they're used to subscribe to the media tracks named by the catalog. Update the catalog at any time by writing to the signal: @@ -118,7 +123,7 @@ the signal: import * as Watch from "@moq/watch"; const broadcast = new Watch.Broadcast({ - connection, + origin: connection.origin, enabled: true, name: "alice.hang", catalogFormat: "manual", @@ -268,13 +273,16 @@ The `` element automatically discovers the nested `` an ```typescript import * as Watch from "@moq/watch"; +import * as Moq from "@moq/net"; import { Signal } from "@moq/signals"; +const connection = new Moq.Connection.Shared({ url: new URL("https://relay.example.com/anon") }); + // Inputs are read-only on the component, so keep a handle to anything you want to change later. const reload = new Signal(true); const broadcast = new Watch.Broadcast({ - connection, + origin: connection.origin, enabled: true, name: "alice.hang", reload, diff --git a/js/clock/src/main.ts b/js/clock/src/main.ts index 0ae2294ac0..d2016a0088 100755 --- a/js/clock/src/main.ts +++ b/js/clock/src/main.ts @@ -72,12 +72,13 @@ ENVIRONMENT VARIABLES: } async function publish(config: Config) { - const connection = await Moq.Connection.connect(new URL(config.url)); + // The origin holds what we publish; the connection announces and serves it. + const origin = new Moq.Origin.Producer(); + await Moq.Connection.connect(new URL(config.url), { publish: origin.consume() }); console.log("✅ Connected to relay:", config.url); // Create a new "broadcast", which is a collection of tracks. - const broadcast = new Moq.Broadcast.Producer(); - connection.publish(Moq.Path.from(config.broadcast), broadcast); + const broadcast = origin.publish(Moq.Path.from(config.broadcast)); console.log("✅ Published broadcast:", config.broadcast); diff --git a/js/moq-boy/src/element.tsx b/js/moq-boy/src/element.tsx index d07066c3b4..6594287157 100644 --- a/js/moq-boy/src/element.tsx +++ b/js/moq-boy/src/element.tsx @@ -25,6 +25,8 @@ export default class MoqBoy extends HTMLElement { static observedAttributes = OBSERVED; readonly connection: Moq.Connection.Reload; + /** The origin viewer broadcasts are published into, served across reconnects. */ + readonly origin = new Moq.Origin.Producer(); readonly expanded = new Moq.Signals.Signal(undefined); /** Reactive map of active game sessions. Emits on add/remove. */ @@ -42,8 +44,15 @@ export default class MoqBoy extends HTMLElement { super(); cleanup.register(this, this.#signals); - this.connection = new Moq.Connection.Reload({ enabled: this.#enabled }); + // One origin, both directions: viewer broadcasts are published into it and the + // relay's announced games arrive in it, with no risk of echoing either back. + this.connection = new Moq.Connection.Reload({ + enabled: this.#enabled, + publish: this.origin.consume(), + subscribe: this.origin, + }); this.#signals.cleanup(() => this.connection.close()); + this.#signals.cleanup(() => this.origin.close()); // Discover game sessions via announcements. this.#signals.run(this.#runDiscovery.bind(this)); @@ -113,15 +122,14 @@ export default class MoqBoy extends HTMLElement { } #runDiscovery(effect: Moq.Signals.Effect) { - const conn = effect.get(this.connection.established); - if (!conn) return; - const base = effect.get(this.#prefix); const gamePrefix = effect.get(this.#gamePrefixOverride) ?? `${base}/game`; const viewerPrefix = effect.get(this.#viewerPrefixOverride) ?? `${base}/viewer`; const prefix = Moq.Path.from(gamePrefix); - const announced = conn.announced(prefix); + // The origin's stream spans reconnects: entries retract when the session dies and + // return when the next one re-announces them, so this loop never needs to restart. + const announced = this.origin.consume().announced(prefix); effect.cleanup(() => announced.close()); effect.spawn(async () => { @@ -138,6 +146,7 @@ export default class MoqBoy extends HTMLElement { const config: GameConfig = { sessionId: id, connection: this.connection, + origin: this.origin, expanded: this.expanded, gamePrefix, viewerPrefix, diff --git a/js/moq-boy/src/game.ts b/js/moq-boy/src/game.ts index 44c3d85c60..374f5084c1 100644 --- a/js/moq-boy/src/game.ts +++ b/js/moq-boy/src/game.ts @@ -18,6 +18,8 @@ export interface GameConfig { sessionId: string; /** MoQ connection to the relay. */ connection: Moq.Connection.Reload; + /** The origin viewer broadcasts are published into; the connection serves it. */ + origin: Moq.Origin.Producer; /** Shared signal tracking which game is currently expanded. */ expanded: Moq.Signals.Signal; /** MoQ path prefix for game broadcasts (e.g. "anon/boy/game"). */ @@ -114,7 +116,7 @@ export class Game { // Video pipeline. this.broadcast = new Watch.Broadcast({ - connection: connection.established, + origin: config.origin, name: Moq.Path.from(`${gamePrefix}/${sessionId}`), enabled: true, }); @@ -124,6 +126,7 @@ export class Game { broadcast: this.broadcast, target: this.#target, supported: Watch.Video.Decoder.supported, + probe: connection.probe, }); this.#signals.cleanup(() => this.videoSource.close()); @@ -138,7 +141,7 @@ export class Game { const videoJitter = new Moq.Signals.Signal(undefined); this.sync = new Watch.Sync({ latency: this.latency, - connection: connection.established, + probe: connection.probe, video: videoJitter, audio: this.audioSource.out.jitter, }); @@ -185,7 +188,7 @@ export class Game { this.#signals.run(this.#runStatus.bind(this)); // Command publishing. - this.#signals.run(this.#runCommands.bind(this, connection)); + this.#signals.run(this.#runCommands.bind(this, connection, config.origin)); } /** Send a button state update. */ @@ -275,7 +278,9 @@ export class Game { }); } - #runCommands(connection: Moq.Connection.Reload, effect: Moq.Signals.Effect) { + #runCommands(connection: Moq.Connection.Reload, origin: Moq.Origin.Producer, effect: Moq.Signals.Effect) { + // Publishing goes through the origin, but gate on a live connection anyway: a command + // broadcast for a game nobody is connected to is feedback into the void. const conn = effect.get(connection.established); if (!conn) return; @@ -293,8 +298,7 @@ export class Game { const viewerId = Math.random().toString(36).slice(2, 8); this.viewerId.set(viewerId); - const viewerBroadcast = new Moq.Broadcast.Producer(); - conn.publish(Moq.Path.from(`${this.#viewerPrefix}/${this.sessionId}/${viewerId}`), viewerBroadcast); + const viewerBroadcast = origin.publish(Moq.Path.from(`${this.#viewerPrefix}/${this.sessionId}/${viewerId}`)); effect.cleanup(() => { viewerBroadcast.close(); this.viewerId.set(undefined); diff --git a/js/net/examples/publish.ts b/js/net/examples/publish.ts index 30ec86663e..28daa986a6 100644 --- a/js/net/examples/publish.ts +++ b/js/net/examples/publish.ts @@ -2,17 +2,17 @@ import * as Moq from "@moq/net"; async function main() { const url = new URL("https://cdn.moq.dev/anon"); - const connection = await Moq.Connection.connect(url); - // Create a broadcast (a collection of tracks) - const broadcast = new Moq.Broadcast.Producer(); + // The origin holds what we publish; the connection announces and serves it. + const origin = new Moq.Origin.Producer(); + await Moq.Connection.connect(url, { publish: origin.consume() }); + + // Create a broadcast (a collection of tracks) at a path on the origin + const broadcast = origin.publish(Moq.Path.from("my-broadcast")); // Insert the "chat" track up front. A subscriber is served directly from this // track, no requested() round-trip needed. Mirrors the Rust createTrack/insertTrack. void publishTrack(broadcast.createTrack("chat")); - - // Publish the broadcast to the connection - connection.publish(Moq.Path.from("my-broadcast"), broadcast); console.log("Published broadcast: my-broadcast"); // Tracks created on demand (instead of up front) are still supported: handle any diff --git a/js/net/src/announced.test.ts b/js/net/src/announced.test.ts index 1cd3dc31fc..9e254bfc57 100644 --- a/js/net/src/announced.test.ts +++ b/js/net/src/announced.test.ts @@ -1,5 +1,6 @@ import { expect, test } from "bun:test"; import * as Announce from "./announced.ts"; +import { Producer as OriginProducer } from "./origin.ts"; import * as Path from "./path.ts"; const p = (s: string) => Path.from(s); @@ -44,3 +45,64 @@ test("aborting rejects next", async () => { producer.close(new Error("boom")); await expect(consumer.next()).rejects.toThrow("boom"); }); + +async function settle() { + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +test("an origin handle resolves a local publish with no session attached", async () => { + const origin = new OriginProducer(); + const path = p("loopback"); + + const watch = new Announce.Broadcast({ origin, path }); + await settle(); + expect(watch.active.peek()).toBeUndefined(); + + // Loopback needs no connection: the table routes the local publish directly, even + // though `discovery` is still undefined (nothing is attached). + const first = origin.publish(path); + await settle(); + const held = watch.active.peek(); + expect(held).toBeDefined(); + + // A republish swaps the handle to the new broadcast rather than clinging to the + // superseded one. + const second = origin.publish(path); + await settle(); + expect(watch.active.peek()).toBeDefined(); + expect(watch.active.peek()).not.toBe(held); + + // Unpublishing takes it offline. + second.close(); + first.close(); + await settle(); + expect(watch.active.peek()).toBeUndefined(); + + watch.close(); + origin.close(); +}); + +test("the local route wins over a blind request on a no-discovery origin", async () => { + const origin = new OriginProducer(); + const path = p("local-first"); + + // A session without discovery is attached, so the handle stands a request; but the + // local publish must still resolve through the table, not wait on an answer. + const detach = origin.attach(false); + const broadcast = origin.publish(path); + + const watch = new Announce.Broadcast({ origin, path }); + await settle(); + expect(watch.active.peek()).toBeDefined(); + + watch.close(); + detach(); + broadcast.close(); + origin.close(); +}); + +test("BroadcastProps requires a source", () => { + // @ts-expect-error neither source is a compile error; the union demands exactly one. + const neither: Announce.BroadcastProps = { path: p("x") }; + expect(neither.path).toBeDefined(); +}); diff --git a/js/net/src/announced.ts b/js/net/src/announced.ts index b9eaf6d4b3..d235db3352 100644 --- a/js/net/src/announced.ts +++ b/js/net/src/announced.ts @@ -6,6 +6,7 @@ import { Effect, type GetPromise, type Getter, type GetterInit, getter, Once, Signal } from "@moq/signals"; import type * as broadcast from "./broadcast.js"; import type { Established } from "./connection/established.js"; +import type { Request as OriginRequest, Table as OriginTable } from "./origin.js"; import * as Path from "./path.js"; /** @@ -134,20 +135,38 @@ export class Consumer { const warnedNoDiscovery = new WeakSet(); /** - * What to watch, for {@link Broadcast}. + * What to watch, for {@link Broadcast}: a path on exactly one source, enforced by the + * union so a call with neither or both does not compile. * * @public */ -export interface BroadcastProps { - /** - * The connection to watch on. Accepts a live {@link Established} session, or a reactive one - * (a `Connection.Reload`'s `established`), which is how the handle survives reconnects. - */ - connection: GetterInit; - +export type BroadcastProps = { /** The broadcast path to watch. */ path: Path.Valid; -} +} & ( + | { + /** + * The connection to watch on. Accepts a live {@link Established} session, or a + * reactive one (a `Connection.Reload`'s `established`), which is how the handle + * survives reconnects. + */ + connection: GetterInit; + origin?: undefined; + } + | { + /** + * The origin to watch instead of a session. + * + * The handle then follows the origin's table: it resolves whenever anything + * routes the path (a local publish, or any session feeding the origin), which is + * how it spans reconnects without watching the connection itself. While every + * attached session lacks discovery it falls back to a standing request, so + * `active` means assumed present. + */ + origin: GetterInit; + connection?: undefined; + } +); /** * A reactive handle to a single broadcast: {@link Broadcast.active} holds a live @@ -174,9 +193,10 @@ export interface BroadcastProps { * after a publisher finally appears succeeds. * * If discovery fails on a live session (the announcement stream is reset, or the relay - * refuses it) the handle goes offline and stays there: nothing reopens the stream on that - * connection. Build it from a `Connection.Reload` if you need it to recover, since a new - * connection starts a new stream. + * refuses it) a connection-backed handle goes offline and stays there: nothing reopens the + * stream on that connection. Build it from a `Connection.Reload` if you need it to recover, + * since a new connection starts a new stream. An origin-backed handle recovers on its own: + * the session stops counting as discovering, so the handle falls back to a standing request. * * Close it to release the announcement stream and the current broadcast. * @@ -201,16 +221,22 @@ export class Broadcast { #signals = new Effect(); /** - * Watch a path on a connection. + * Watch a path on a connection or an origin. * * Prefer `announcedBroadcast(path)` on the connection itself. Reach for this when the - * session you want to follow isn't either connection type, e.g. your own - * `Getter`. + * source you want to follow isn't either connection type, e.g. your own + * `Getter` or an origin fed by a `subscribe` option. */ - constructor({ connection, path }: BroadcastProps) { + constructor({ connection, path, origin }: BroadcastProps) { this.path = path; this.active = this.#active; + if (origin) { + const source = getter(origin); + this.#signals.run((effect) => this.#runOrigin(effect, source)); + return; + } + const source = getter(connection); this.#signals.run((effect) => { const conn = effect.get(source); @@ -286,6 +312,88 @@ export class Broadcast { }); } + // Follow the origin's table instead of a session's announce stream. The table already + // merges every source (local publishes, every feeding session), so this is simpler than + // the session path: no hop bookkeeping, and the table's identity-diffed announcements + // retract before a republish, which is what lets a plain re-consume suffice. + #runOrigin(effect: Effect, source: Getter): void { + const origin = effect.get(source); + if (!origin) return; + + // The two ways the broadcast can resolve. The table wins: it is knowledge (a local + // publish or an announcement) while a request's answer is only assumed present. + const table = new Signal(undefined); + const requested = new Signal(undefined); + effect.run((nested) => { + nested.set(this.#active, nested.get(table) ?? nested.get(requested), undefined); + }); + + // Follow the table regardless of sessions: a local publish resolves with no + // connection at all (and keeps resolving while one reconnects), and the + // identity-diffed announcements swap the handle on a republish. + const announced = origin.announced(this.path); + effect.cleanup(() => announced.close()); + + let current: broadcast.Consumer | undefined; + + // Held open while the path is announced. A request resolves to the table's route when + // there is one, and a session skips answering a path the table routes, so within the + // announced window this can only ever produce the announced broadcast. + let request: OriginRequest | undefined; + + const offline = () => { + current?.close(); + current = undefined; + request?.close(); + request = undefined; + table.set(undefined); + }; + effect.cleanup(offline); + + effect.spawn(async () => { + for (;;) { + const event = await Promise.race([effect.cancel, announced.next()]); + if (!event) break; + + // Scoped to `path`, so the exact broadcast arrives with an empty suffix; ignore children. + if (event.path !== Path.empty()) continue; + + if (event.active) { + current?.close(); + request ??= origin.request(this.path); + // Cloned: the request borrows the table's front, and this handle owns what + // it hands out. + current = request.active.peek()?.clone(); + table.set(current); + } else { + offline(); + } + } + + // The origin closed, or this run was torn down. Either way nothing routes the path. + offline(); + }); + + // Blind fallback: while any attached session cannot announce, the table is an + // incomplete picture of what is reachable, so stand a request for whichever session + // answers. Gated on exactly `false`: with no session there is nobody to ask, and with + // every session announcing the gate is the point, so a blind subscribe would defeat it. + effect.run((nested) => { + if (nested.get(origin.discovery) !== false) return; + + const request = origin.request(this.path); + nested.cleanup(() => request.close()); + nested.run((inner) => { + inner.set(requested, inner.get(request.active), undefined); + }); + }); + } + + /** Resolves once the handle is closed, so an owner can drop its reference. */ + get closed(): Promise { + return this.#signals.closed; + } + /** Closes the handle and the broadcast it currently holds. Idempotent. */ close() { this.#signals.close(); diff --git a/js/net/src/connection/accept.ts b/js/net/src/connection/accept.ts index bc8802f12f..cff465e175 100644 --- a/js/net/src/connection/accept.ts +++ b/js/net/src/connection/accept.ts @@ -1,7 +1,9 @@ import * as Ietf from "../ietf/index.ts"; import * as Lite from "../lite/index.ts"; +import type { Consumer as OriginConsumer, Producer as OriginProducer } from "../origin.ts"; import { Stream } from "../stream.ts"; import type { Established } from "./established.ts"; +import { forwardAnnounced } from "./forward.ts"; import { exchangeSetup } from "./handshake.ts"; /** Options for {@link accept}. */ @@ -14,8 +16,26 @@ export interface AcceptProps { * Defaults to true. */ discovery?: boolean; + + /** + * The origin whose broadcasts the session announces and serves to the peer. Omit to + * publish nothing. Borrowed, not owned: closing the session leaves its broadcasts alone. + */ + publish?: OriginConsumer; + + /** + * The origin the session feeds with the peer's announced broadcasts. Omit to discover + * nothing. The entries retract when the session dies; see the `subscribe` connect option. + */ + subscribe?: OriginProducer; } +/** The per-session wiring shared by every negotiated protocol path. */ +type SessionProps = { + discovery: boolean; + publish?: OriginConsumer; +}; + /** * Server-side handshake: accepts a transport and performs the server half of the SETUP exchange. * @@ -25,31 +45,40 @@ export interface AcceptProps { * @returns A promise that resolves to a Connection instance */ export async function accept(transport: WebTransport, url: URL, props?: AcceptProps): Promise { + const connection = await acceptInner(transport, url, props); + if (props?.subscribe) forwardAnnounced(connection, props.subscribe); + return connection; +} + +async function acceptInner(transport: WebTransport, url: URL, props?: AcceptProps): Promise { // @ts-expect-error - TODO: add protocol to WebTransport const protocol: string | undefined = transport.protocol; - const discovery = props?.discovery ?? true; + const wiring: SessionProps = { + discovery: props?.discovery ?? true, + publish: props?.publish, + }; if (protocol === Ietf.ALPN.DRAFT_19) { - return acceptAlpn(transport, url, Ietf.Version.DRAFT_19, discovery); + return acceptAlpn(transport, url, Ietf.Version.DRAFT_19, wiring); } else if (protocol === Ietf.ALPN.DRAFT_18) { - return acceptAlpn(transport, url, Ietf.Version.DRAFT_18, discovery); + return acceptAlpn(transport, url, Ietf.Version.DRAFT_18, wiring); } else if (protocol === Ietf.ALPN.DRAFT_17) { - return acceptAlpn(transport, url, Ietf.Version.DRAFT_17, discovery); + return acceptAlpn(transport, url, Ietf.Version.DRAFT_17, wiring); } else if (protocol === Ietf.ALPN.DRAFT_16) { - return acceptSetup(transport, url, Ietf.Version.DRAFT_16, discovery); + return acceptSetup(transport, url, Ietf.Version.DRAFT_16, wiring); } else if (protocol === Ietf.ALPN.DRAFT_15) { - return acceptSetup(transport, url, Ietf.Version.DRAFT_15, discovery); + return acceptSetup(transport, url, Ietf.Version.DRAFT_15, wiring); } else if (protocol === Lite.ALPN_06_WIP) { - return new Lite.Connection({ url, quic: transport, version: Lite.Version.DRAFT_06, discovery }); + return new Lite.Connection({ url, quic: transport, version: Lite.Version.DRAFT_06, ...wiring }); } else if (protocol === Lite.ALPN_05) { - return new Lite.Connection({ url, quic: transport, version: Lite.Version.DRAFT_05, discovery }); + return new Lite.Connection({ url, quic: transport, version: Lite.Version.DRAFT_05, ...wiring }); } else if (protocol === Lite.ALPN_04) { - return new Lite.Connection({ url, quic: transport, version: Lite.Version.DRAFT_04, discovery }); + return new Lite.Connection({ url, quic: transport, version: Lite.Version.DRAFT_04, ...wiring }); } else if (protocol === Lite.ALPN_03) { - return new Lite.Connection({ url, quic: transport, version: Lite.Version.DRAFT_03, discovery }); + return new Lite.Connection({ url, quic: transport, version: Lite.Version.DRAFT_03, ...wiring }); } else if (protocol === Lite.ALPN || protocol === "" || protocol === undefined) { - return acceptNegotiated(transport, url, props); + return acceptNegotiated(transport, url, wiring, props?.version); } else { throw new Error(`unsupported WebTransport protocol: ${protocol}`); } @@ -63,12 +92,12 @@ async function acceptAlpn( transport: WebTransport, url: URL, version: Ietf.IetfVersion, - discovery: boolean, + wiring: SessionProps, ): Promise { const controlStream = await exchangeSetup(transport, version, "moq-lite-js"); return new Ietf.Connection({ - discovery, + ...wiring, client: false, url, quic: transport, @@ -87,7 +116,7 @@ async function acceptSetup( transport: WebTransport, url: URL, version: Ietf.IetfVersion, - discovery: boolean, + wiring: SessionProps, ): Promise { // Accept bidi, read ClientSetup, write ServerSetup const stream = await Stream.accept(transport); @@ -113,7 +142,7 @@ async function acceptSetup( const maxRequestId = 42069n; return new Ietf.Connection({ - discovery, + ...wiring, client: false, url, quic: transport, @@ -123,8 +152,12 @@ async function acceptSetup( }); } -async function acceptNegotiated(transport: WebTransport, url: URL, props?: AcceptProps): Promise { - const discovery = props?.discovery ?? true; +async function acceptNegotiated( + transport: WebTransport, + url: URL, + wiring: SessionProps, + version?: number, +): Promise { const setupVersion = Ietf.Version.DRAFT_14; const stream = await Stream.accept(transport); @@ -140,8 +173,8 @@ async function acceptNegotiated(transport: WebTransport, url: URL, props?: Accep // Pick the requested version, or first matching version from client's list const allVersions = [...Object.values(Lite.Version), ...Object.values(Ietf.Version)] as number[]; let selectedVersion: number; - if (props?.version !== undefined) { - selectedVersion = props.version; + if (version !== undefined) { + selectedVersion = version; } else { const match = client.versions.find((v) => allVersions.includes(v)); if (match === undefined) { @@ -168,12 +201,12 @@ async function acceptNegotiated(transport: WebTransport, url: URL, props?: Accep quic: transport, version: selectedVersion as Lite.Version, session: stream, - discovery, + ...wiring, }); } else if (Object.values(Ietf.Version).includes(selectedVersion as Ietf.Version)) { const maxRequestId = client.parameters.getVarint(Ietf.SetupOption.MaxRequestId) ?? 0n; return new Ietf.Connection({ - discovery, + ...wiring, client: false, url, quic: transport, diff --git a/js/net/src/connection/connect.ts b/js/net/src/connection/connect.ts index ca7efdcb3b..6c3541bfa1 100644 --- a/js/net/src/connection/connect.ts +++ b/js/net/src/connection/connect.ts @@ -1,10 +1,12 @@ import Session, { type Version as QmuxVersion } from "@moq/qmux"; import * as Ietf from "../ietf/index.ts"; import * as Lite from "../lite/index.ts"; +import type { Consumer as OriginConsumer, Producer as OriginProducer } from "../origin.ts"; import { Stream } from "../stream.ts"; import * as Hex from "../util/hex.ts"; import { isWebTransportSupported } from "./browser.ts"; import type { Established } from "./established.ts"; +import { forwardAnnounced } from "./forward.ts"; import { exchangeSetup } from "./handshake.ts"; // Default head start for WebTransport before attempting the WebSocket fallback. @@ -76,6 +78,28 @@ export interface ConnectProps { */ discovery?: boolean; + /** + * The origin whose broadcasts the session announces and serves to the peer. Omit to + * publish nothing. + * + * The origin is borrowed, not owned: closing the session leaves its broadcasts alone, + * and the same origin can back several sessions (or successive reconnects), each + * announcing the table for as long as it lasts. + */ + publish?: OriginConsumer; + + /** + * The origin the session feeds with the peer's announced broadcasts. Omit to discover + * nothing. + * + * Everything the peer announces appears in the origin's table for the session's + * lifetime, consumable by path; the entries retract when the session dies. Pass the + * producer behind {@link ConnectProps.publish} to route both directions through one + * origin: a locally published path is then served back to local consumers directly, and + * the session still never announces the peer's own broadcasts back to it. + */ + subscribe?: OriginProducer; + /** * Aborts the connection attempt with the signal's reason. An already-aborted * signal rejects before anything opens, and aborting after the connection is @@ -84,6 +108,12 @@ export interface ConnectProps { signal?: AbortSignal; } +/** The per-session wiring shared by every negotiated protocol path. */ +type SessionProps = { + discovery: boolean; + publish?: OriginConsumer; +}; + // Relays that don't implement broadcast discovery (SUBSCRIBE_NAMESPACE), so `announced()` would // never yield and a consumer waiting on an announcement would hang forever. Override with the // `discovery` option. Drop a host once its relay ships discovery. @@ -120,7 +150,10 @@ export async function connect(url: URL, props?: ConnectProps): Promise undefined)]); - if (connection && !signal.aborted) return connection; + if (connection && !signal.aborted) { + if (props?.subscribe) forwardAnnounced(connection, props.subscribe); + return connection; + } // Close a connection that settles after the abort. pending.then((conn) => conn.close()).catch(() => {}); @@ -131,12 +164,15 @@ export async function connect(url: URL, props?: ConnectProps): Promise): Promise { - const discovery = props?.discovery ?? defaultDiscovery(url); + const wiring: SessionProps = { + discovery: props?.discovery ?? defaultDiscovery(url), + publish: props?.publish, + }; if (props?.transport) { const transport = props.transport; void abort.then(() => transport.close()); - return connectTransport(url, transport, discovery); + return connectTransport(url, transport, wiring); } // Stop transports after one connects or the caller aborts. @@ -184,10 +220,10 @@ async function connectInner(url: URL, props: ConnectProps | undefined, abort: Pr } // The remaining setup is identical whether the transport was raced or supplied. - return await connectTransport(url, session as WebTransport, discovery); + return await connectTransport(url, session as WebTransport, wiring); } -async function connectTransport(url: URL, session: WebTransport, discovery: boolean): Promise { +async function connectTransport(url: URL, session: WebTransport, wiring: SessionProps): Promise { // qmux Session exposes the negotiated protocol directly (as "" when there is none); // native WebTransport doesn't have a standard .protocol property yet. const protocol: string | undefined = (session as { protocol?: string }).protocol || undefined; @@ -204,19 +240,19 @@ async function connectTransport(url: URL, session: WebTransport, discovery: bool ? Ietf.Version.DRAFT_17 : undefined; if (modernVersion !== undefined) { - return await handshakeAlpn(url, session, modernVersion, discovery); + return await handshakeAlpn(url, session, modernVersion, wiring); } else if (protocol === Ietf.ALPN.DRAFT_16) { setupVersion = Ietf.Version.DRAFT_16; } else if (protocol === Ietf.ALPN.DRAFT_15) { setupVersion = Ietf.Version.DRAFT_15; } else if (protocol === Lite.ALPN_06_WIP) { - return new Lite.Connection({ url, quic: session, version: Lite.Version.DRAFT_06, discovery }); + return new Lite.Connection({ url, quic: session, version: Lite.Version.DRAFT_06, ...wiring }); } else if (protocol === Lite.ALPN_05) { - return new Lite.Connection({ url, quic: session, version: Lite.Version.DRAFT_05, discovery }); + return new Lite.Connection({ url, quic: session, version: Lite.Version.DRAFT_05, ...wiring }); } else if (protocol === Lite.ALPN_04) { - return new Lite.Connection({ url, quic: session, version: Lite.Version.DRAFT_04, discovery }); + return new Lite.Connection({ url, quic: session, version: Lite.Version.DRAFT_04, ...wiring }); } else if (protocol === Lite.ALPN_03) { - return new Lite.Connection({ url, quic: session, version: Lite.Version.DRAFT_03, discovery }); + return new Lite.Connection({ url, quic: session, version: Lite.Version.DRAFT_03, ...wiring }); } else if (protocol === Lite.ALPN || protocol === "" || protocol === undefined) { setupVersion = Ietf.Version.DRAFT_14; } else { @@ -256,12 +292,12 @@ async function connectTransport(url: URL, session: WebTransport, discovery: bool quic: session, version: server.version as Lite.Version, session: stream, - discovery, + ...wiring, }); } else if (Object.values(Ietf.Version).includes(server.version as Ietf.Version)) { const maxRequestId = server.parameters.getVarint(Ietf.SetupOption.MaxRequestId) ?? 0n; return new Ietf.Connection({ - discovery, + ...wiring, client: true, url, quic: session, @@ -282,12 +318,12 @@ async function handshakeAlpn( url: URL, session: WebTransport, version: Ietf.IetfVersion, - discovery: boolean, + wiring: SessionProps, ): Promise { const controlStream = await exchangeSetup(session, version, "moq-lite-js"); return new Ietf.Connection({ - discovery, + ...wiring, client: true, url, quic: session, diff --git a/js/net/src/connection/established.ts b/js/net/src/connection/established.ts index cae0c39973..26b00fcca0 100644 --- a/js/net/src/connection/established.ts +++ b/js/net/src/connection/established.ts @@ -5,7 +5,12 @@ import type * as Path from "../path.ts"; import type { Probe, Stats } from "./stats.ts"; import type { Transport } from "./transport.ts"; -/** An established MoQ session, implemented by both the moq-lite and moq-ietf protocols. */ +/** + * An established MoQ session, implemented by both the moq-lite and moq-ietf protocols. + * + * Publishing goes through an origin, not the session: pass an `Origin.Consumer` as the + * `publish` connect option and the session announces and serves that origin's broadcasts. + */ export interface Established { /** URL of the connected server. */ readonly url: URL; @@ -32,9 +37,6 @@ export interface Established { /** Subscribe to broadcast announcements under an optional path prefix, returning paths relative to that prefix. */ announced(prefix?: Path.Valid): announce.Consumer; - /** Publish a broadcast at the given path. */ - publish(path: Path.Valid, broadcast: broadcast.Producer): void; - /** * Consume the broadcast at the given path, immediately. * diff --git a/js/net/src/connection/forward.test.ts b/js/net/src/connection/forward.test.ts new file mode 100644 index 0000000000..d6bc0252fe --- /dev/null +++ b/js/net/src/connection/forward.test.ts @@ -0,0 +1,186 @@ +import { expect, test } from "bun:test"; +import * as Announce from "../announced.ts"; +import { type Consumer as BroadcastConsumer, Producer as BroadcastProducer } from "../broadcast.ts"; +import { Producer as OriginProducer } from "../origin.ts"; +import * as Path from "../path.ts"; +import type { Established } from "./established.ts"; +import { forwardAnnounced } from "./forward.ts"; + +async function settle() { + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +/** + * A session standing in for the wire: the test drives its announcement stream and decides + * when it dies, which is what separates "discovery failed" from "the session went away". + */ +class FakeSession { + readonly discovery: boolean; + + /** The announce stream handed to the forwarder, so a test can end or abort it. */ + readonly announces = new Announce.Producer(); + + /** + * Every broadcast the forwarder consumed, per path. An announcement consumes the path + * once for the table, so a second consume is the blind answer to a request. + */ + readonly consumed = new Map(); + + closed: Promise; + #die!: (reason: Error | null) => void; + + constructor(discovery = true) { + this.discovery = discovery; + this.closed = new Promise((resolve) => { + this.#die = resolve; + }); + } + + announced(): Announce.Consumer { + return this.announces.consume(); + } + + consume(path: Path.Valid): BroadcastConsumer { + const producer = new BroadcastProducer(); + const existing = this.consumed.get(path); + if (existing) existing.push(producer); + else this.consumed.set(path, [producer]); + return producer.consume(); + } + + /** How many times the forwarder consumed `path` on this session. */ + consumes(path: Path.Valid): number { + return this.consumed.get(path)?.length ?? 0; + } + + /** End the session, the way a dropped connection would. */ + die(): void { + this.#die(null); + } + + /** What `forwardAnnounced` takes; only the members it touches are implemented. */ + get session(): Established { + return this as unknown as Established; + } +} + +test("a discovery failure under a live session downgrades the origin", async () => { + const origin = new OriginProducer(); + const session = new FakeSession(); + const path = Path.from("room"); + + forwardAnnounced(session.session, origin); + + // The relay announces a broadcast, which lands in the table. + session.announces.append({ path, active: true }); + await settle(); + expect(origin.discovery.peek()).toBe(true); + expect(origin.routes(path)).toBe(true); + + // A watcher gated on the announcement is live on that route. + const watched = new Announce.Broadcast({ origin, path }); + await settle(); + expect(watched.active.peek()).toBeDefined(); + + // The relay resets the announce stream but keeps the session: a subscriber is allowed to + // refuse a namespace without closing the connection. + session.announces.close(new Error("namespace rejected")); + await settle(); + await settle(); + + // Everything the stream fed is retracted, and the origin stops claiming a discovery that + // no longer works. Leaving it true is what used to strand every gated watcher offline. + expect(origin.routes(path)).toBe(false); + expect(origin.discovery.peek()).toBe(false); + + // So the watcher falls back to a standing request, which this same session answers. + await settle(); + await settle(); + expect(watched.active.peek()).toBeDefined(); + // The announcement's own consume, plus the blind answer standing in for it now. + expect(session.consumes(path)).toBe(2); + + watched.close(); + origin.close(); +}); + +test("a request outlives the discovery failure that fed it", async () => { + const origin = new OriginProducer(); + const session = new FakeSession(); + const path = Path.from("wanted"); + + forwardAnnounced(session.session, origin); + + // Announced, so the table routes it and no blind answer is needed. + session.announces.append({ path, active: true }); + await settle(); + + const request = origin.request(path); + await settle(); + expect(request.active.peek()).toBeDefined(); + // Only the announcement's consume: a routed path resolves the request on its own, so the + // serving loop leaves it alone rather than parking a blind subscription behind it. + expect(session.consumes(path)).toBe(1); + + // Discovery dies under the live session: the route goes, so the request now needs the + // blind answer the serving loop skipped while the table had it. + session.announces.close(new Error("stream reset")); + await settle(); + await settle(); + + expect(request.active.peek()).toBeDefined(); + expect(session.consumes(path)).toBe(2); + + request.close(); + origin.close(); +}); + +test("a session dying does not downgrade discovery for the next one", async () => { + const origin = new OriginProducer(); + const first = new FakeSession(); + + forwardAnnounced(first.session, origin); + expect(origin.discovery.peek()).toBe(true); + + // A closing session detaches outright rather than downgrading: it is gone, not blind. + first.die(); + await settle(); + await settle(); + expect(origin.discovery.peek()).toBeUndefined(); + + const second = new FakeSession(); + forwardAnnounced(second.session, origin); + expect(origin.discovery.peek()).toBe(true); + + second.die(); + origin.close(); +}); + +test("a request replaced across one coalesced wakeup still gets answered", async () => { + const origin = new OriginProducer(); + const session = new FakeSession(false); + const path = Path.from("wanted"); + + forwardAnnounced(session.session, origin); + + const first = origin.request(path); + await settle(); + expect(first.active.peek()).toBeDefined(); + expect(session.consumes(path)).toBe(1); + + // Withdrawing the last handle defers the slot teardown a microtask. Let that teardown + // run, then ask again before the serving loop wakes, so the delete and the fresh slot + // land in one notification. The loop sees a slot it never answered under a path it did. + first.close(); + await Promise.resolve(); + await Promise.resolve(); + const second = origin.request(path); + await settle(); + await settle(); + + expect(second.active.peek()).toBeDefined(); + expect(session.consumes(path)).toBe(2); + + second.close(); + origin.close(); +}); diff --git a/js/net/src/connection/forward.ts b/js/net/src/connection/forward.ts new file mode 100644 index 0000000000..8d53cc355b --- /dev/null +++ b/js/net/src/connection/forward.ts @@ -0,0 +1,147 @@ +/** + * Feeds a session's announced broadcasts into an origin; the `subscribe` connect option. + * + * @module + */ +import type { Dispose } from "@moq/signals"; +import type { Producer as OriginProducer, RequestSlot } from "../origin.ts"; +import type * as Path from "../path.ts"; +import type { Established } from "./established.ts"; + +/** + * Wire a session into `origin` for the session's lifetime: forward the peer's announced + * broadcasts into the table, and answer the origin's open requests with blind + * subscriptions. + * + * Each active announcement inserts a lazily-subscribing front, so nothing touches the wire + * until somebody consumes the path. A retraction removes the entry, and so does the session + * dying (the announce stream ends with it), which is what scopes remote entries to the + * session that discovered them. A reconnect wires a fresh session to the same origin and + * re-populates it. + * + * Without discovery there are no announcements to forward, so the table stays empty and + * requests are the only way through; see the origin's `request`. + * + * @internal + */ +export function forwardAnnounced(conn: Established, origin: OriginProducer): void { + // Reassigned if discovery dies under a live session, so the origin stops counting this + // one as a discovering session. Called through a closure so the session's death always + // detaches whichever attachment is current. + let detach = origin.attach(conn.discovery); + + let dead = false; + void conn.closed.then(() => { + dead = true; + detach(); + }); + + void serveRequests(conn, origin); + + if (!conn.discovery) { + console.warn("relay does not support broadcast discovery; broadcasts resolve on request only."); + return; + } + + const announced = conn.announced(); + const inserted = new Map(); + + // End the stream the moment the session closes rather than waiting for the wire to + // error it, so the retractions below land promptly. + void conn.closed.then(() => announced.close()); + + void (async () => { + let failure: unknown; + try { + for (;;) { + const event = await announced.next(); + if (!event) break; + + if (event.active) { + // A same-path re-announce supersedes: retract the old front first. + inserted.get(event.path)?.(); + inserted.set(event.path, origin.insertRemote(event.path, conn.consume(event.path))); + } else { + const dispose = inserted.get(event.path); + inserted.delete(event.path); + dispose?.(); + } + } + } catch (err) { + // The session died mid-stream, or the relay refused or reset the stream. The + // cleanup below retracts everything this stream fed either way. + failure = err; + } finally { + for (const dispose of inserted.values()) dispose(); + inserted.clear(); + announced.close(); + + // Discovery ended while the session lives, and nothing reopens the stream on this + // connection. Downgrade the attachment rather than leaving the origin claiming a + // discovery that no longer works: announcement-gated consumers would wait forever + // on a table this session can no longer fill. Now they fall back to standing + // requests, which this session still answers. + if (!dead) { + console.warn("broadcast discovery failed; broadcasts resolve on request only.", failure); + detach(); + detach = origin.attach(false); + } + } + })(); +} + +/** + * Answer the origin's open requests with blind subscriptions for the session's lifetime. + * + * Every session answers, discovery or not: subscribing to an unannounced path is always + * legal, and a missing broadcast surfaces as a reset on the first track. The first session + * to answer wins; when this session dies its answers are withdrawn so a later session + * answers again, which is what makes a request span reconnects. + * + * A path the table already routes is left alone. A request resolves to the table's route over + * any blind answer, so answering one would only park a handle nothing reads. + */ +async function serveRequests(conn: Established, origin: OriginProducer): Promise { + // The withdraws for the answers this session provided, so a dead session only takes + // back its own. Keyed by path but remembering the slot, because a path outlives its + // slot: the last handle closing tears the slot down and a new request installs a fresh + // one, and those two writes coalesce into a single wakeup. Matching on the path alone + // would read the new slot as already answered and leave it unanswered forever. + const answered = new Map(); + + let dead = false; + const closed = conn.closed.then(() => { + dead = true; + }); + + for (;;) { + const map = origin.requests.peek(); + if (!map || dead) break; + + for (const [path, slot] of map) { + if (answered.get(path)?.slot === slot || slot.answer !== undefined) continue; + if (origin.routes(path)) continue; + const withdraw = origin.answer(path, conn.consume(path)); + if (withdraw) answered.set(path, { slot, withdraw }); + } + + // A withdrawn request already released the answer; just forget our claim on the path. + // A replaced slot counts as withdrawn: the answer we hold belongs to the slot that + // went away, not to whatever now occupies the path. + for (const [path, entry] of [...answered]) { + if (map.get(path) === entry.slot) continue; + answered.delete(path); + entry.withdraw(); + } + + // Woken by the table too, not just the requests: a path that stops being routed needs + // the blind answer this loop skipped while it was. + await Promise.race([origin.changed(), closed]); + } + + // Session gone: withdraw our answers, waking a standby session to provide fresh ones. + for (const { withdraw } of answered.values()) { + withdraw(); + } + answered.clear(); +} diff --git a/js/net/src/connection/index.ts b/js/net/src/connection/index.ts index ed0d2facd7..337fec68a4 100644 --- a/js/net/src/connection/index.ts +++ b/js/net/src/connection/index.ts @@ -1,5 +1,6 @@ /** - * Connection helpers: connect to or accept a MoQ session and reconnect on failure. + * Connection helpers: connect to or accept a MoQ session, reconnect on failure, and share + * one connection per relay URL. * * @module */ @@ -7,6 +8,7 @@ export * from "./accept.ts"; export { isWebTransportSupported } from "./browser.ts"; export * from "./connect.ts"; export * from "./established.ts"; +export { Shared, type SharedProps } from "./pool.ts"; export * from "./reload.ts"; export type { Probe, Stats } from "./stats.ts"; export type { Transport } from "./transport.ts"; diff --git a/js/net/src/connection/pool.test.ts b/js/net/src/connection/pool.test.ts new file mode 100644 index 0000000000..1a59b773dd --- /dev/null +++ b/js/net/src/connection/pool.test.ts @@ -0,0 +1,202 @@ +import { afterEach, expect, test } from "bun:test"; +import * as Lite from "../lite/index.ts"; +import { createMockTransportPair } from "../mock.ts"; +import * as Path from "../path.ts"; +import { accept } from "./index.ts"; +import { resetShared, Shared } from "./pool.ts"; + +const url = new URL("https://example.com/pool"); + +async function settle() { + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +// Polls until `pred` holds, so a regression fails the test instead of hanging it. +async function waitUntil(pred: () => boolean, ms = 1000): Promise { + // Date, not performance: the retry test accelerates the latter. + const deadline = Date.now() + ms; + for (;;) { + if (pred()) return; + if (Date.now() > deadline) throw new Error("timed out waiting for condition"); + await settle(); + } +} + +// A tiny window keeps the linger tests quick without mocking timers. The wait is a wide +// multiple of it so a loaded runner's timer drift can't be mistaken for lingering. +const linger = 20; + +async function expired() { + await new Promise((resolve) => setTimeout(resolve, linger * 15)); +} + +const original = globalThis.WebTransport; + +afterEach(() => { + resetShared(); + globalThis.WebTransport = original; +}); + +/** Hand out a live mock transport per dial, counting how many were opened. */ +function stubTransports(): { count: () => number } { + let count = 0; + const stub = function StubWebTransport() { + count++; + const pair = createMockTransportPair(Lite.ALPN_05); + void accept(pair.server, url); + return pair.client; + }; + globalThis.WebTransport = stub as unknown as typeof WebTransport; + return { count: () => count }; +} + +test("two handles on one URL share a connection and an origin", async () => { + const dials = stubTransports(); + + const first = new Shared({ url, linger }); + const second = new Shared({ url }); + + await waitUntil(() => first.status.peek() === "connected"); + await waitUntil(() => second.status.peek() === "connected"); + expect(dials.count()).toBe(1); + expect(second.origin.peek()).toBe(first.origin.peek()); + + // One handle leaving doesn't disturb the other. + first.close(); + await expired(); + expect(second.status.peek()).toBe("connected"); + + second.close(); +}); + +test("the connection closes after the last handle and the linger window", async () => { + const dials = stubTransports(); + + const handle = new Shared({ url, linger }); + await waitUntil(() => handle.status.peek() === "connected"); + const origin = handle.origin.peek(); + + handle.close(); + // Idempotent: a double close must not double-release. + handle.close(); + + await expired(); + expect(origin?.closed.peek()).not.toBeUndefined(); + + // The next handle dials fresh. + const next = new Shared({ url, linger }); + await waitUntil(() => next.status.peek() === "connected"); + expect(dials.count()).toBe(2); + next.close(); +}); + +test("a handle taken within the linger window reuses the warm connection", async () => { + const dials = stubTransports(); + + const first = new Shared({ url, linger: 10_000 }); + await waitUntil(() => first.status.peek() === "connected"); + const origin = first.origin.peek(); + first.close(); + + const second = new Shared({ url }); + await waitUntil(() => second.origin.peek() !== undefined); + expect(dials.count()).toBe(1); + expect(second.origin.peek()).toBe(origin); + second.close(); +}); + +test("disabling a handle releases its share", async () => { + stubTransports(); + + const toggled = new Shared({ url, linger }); + const steady = new Shared({ url }); + await waitUntil(() => toggled.status.peek() === "connected"); + + toggled.enabled.set(false); + await waitUntil(() => toggled.origin.peek() === undefined); + expect(toggled.status.peek()).not.toBe("connected"); + + // The steady handle keeps the connection alive through the toggle. + await expired(); + expect(steady.status.peek()).toBe("connected"); + + // Re-enabling rejoins the shared connection. + toggled.enabled.set(true); + await waitUntil(() => toggled.status.peek() === "connected"); + expect(toggled.origin.peek()).toBe(steady.origin.peek()); + + toggled.close(); + steady.close(); +}); + +test("switching URLs switches origins", async () => { + const dials = stubTransports(); + + const handle = new Shared({ url, linger }); + await waitUntil(() => handle.origin.peek() !== undefined); + const before = handle.origin.peek(); + + handle.url.set(new URL("https://example.com/other")); + await waitUntil(() => handle.origin.peek() !== undefined && handle.origin.peek() !== before); + expect(dials.count()).toBe(2); + + handle.close(); +}); + +test("a shared connection outlasts an outage longer than the default retry window", async () => { + let offline = true; + const stub = function StubWebTransport() { + if (offline) throw new Error("relay is down"); + const pair = createMockTransportPair(Lite.ALPN_05); + void accept(pair.server, url); + return pair.client; + }; + globalThis.WebTransport = stub as unknown as typeof WebTransport; + + // The loop measures its retry window with performance.now, so speeding that clock up makes + // a couple of real backoff waits look like minutes to it. A pooled connection has nobody + // watching `closed` to redial it, so giving up would leave every handle on this URL dark + // until the page reloads, however long the relay has been back. + const real = performance.now.bind(performance); + const start = real(); + performance.now = () => start + (real() - start) * 1000; + + try { + const handle = new Shared({ url, linger }); + await waitUntil(() => handle.status.peek() === "disconnected"); + + // Two backoff waits, which is many times over the default window on this clock. + await new Promise((resolve) => setTimeout(resolve, 2000)); + expect(handle.status.peek()).not.toBe("connected"); + + offline = false; + await waitUntil(() => handle.status.peek() === "connected", 15_000); + + handle.close(); + } finally { + performance.now = real; + } +}, 30_000); + +test("a publish through one handle resolves locally for another", async () => { + stubTransports(); + + const publisher = new Shared({ url, linger }); + const watcher = new Shared({ url }); + await waitUntil(() => publisher.origin.peek() !== undefined); + + const origin = publisher.origin.peek(); + if (!origin) throw new Error("expected an origin"); + const broadcast = origin.publish(Path.from("mine")); + broadcast.createTrack("chat"); + + // Loopback: the shared origin serves the page's own publish with no round trip, so the + // request resolves synchronously instead of waiting on the relay to announce it back. + const request = watcher.origin.peek()?.request(Path.from("mine")); + expect(request?.active.peek()).toBeDefined(); + request?.close(); + + broadcast.close(); + publisher.close(); + watcher.close(); +}); diff --git a/js/net/src/connection/pool.ts b/js/net/src/connection/pool.ts new file mode 100644 index 0000000000..90a82865e0 --- /dev/null +++ b/js/net/src/connection/pool.ts @@ -0,0 +1,296 @@ +/** + * Shared managed connections: one origin and one reconnect loop per relay URL. + * + * @module + */ +import { Effect, type Getter, Signal } from "@moq/signals"; +import * as Announce from "../announced.ts"; +import * as Origin from "../origin.ts"; +import * as Path from "../path.ts"; +import type { Established } from "./established.ts"; +import { Reload, type ReloadStatus } from "./reload.ts"; +import type { Probe, Stats } from "./stats.ts"; +import type { Transport } from "./transport.ts"; + +/** How long an unreferenced shared connection lingers before it actually closes. */ +const LINGER_MS = 2000; + +/** Options for {@link Shared}. */ +export interface SharedProps { + /** The relay to connect to; pass a `Signal` to switch relays live. */ + url?: URL | Signal; + + /** Whether to connect at all (default: true); pass a `Signal` to toggle live. */ + enabled?: boolean | Signal; + + /** + * How long the underlying connection outlives its last handle, in milliseconds + * (default: 2000). + * + * The window is what makes moving an element around the DOM free: the connection and + * everything it discovered are still warm when the new owner asks for them. Applied by + * whoever dials first, so a later handle sharing the connection inherits it. + */ + linger?: DOMHighResTimeStamp; +} + +/** + * A handle on a connection shared by relay URL: every `Shared` pointing at the same URL is + * backed by one origin and one reconnect loop, so a page full of components dials once. + * + * The shared {@link origin} is wired to both directions. Everything the relay announces + * lands in it, a publish into it is announced to the relay, and a page that publishes and + * watches the same path resolves it locally with no round trip. + * + * {@link close} releases this handle; the connection survives its last handle by a short + * linger window (see {@link SharedProps.linger}), so a component torn down and rebuilt + * reuses the warm connection instead of redialing. + * + * The loop reconnects for as long as a handle holds it, so an outage of any length recovers + * on its own. An auth rejection is the one failure it stops on, and it retires the shared + * connection so the next handle dials fresh. + * + * For a connection with options sharing can't honor (a certificate pin, a supplied + * transport, origins of your own), construct a {@link Reload} directly instead. + * + * @public + */ +export class Shared { + /** Relay URL to connect to; updating it switches to that URL's shared connection. */ + url: Signal; + + /** Whether to hold a connection at all; clearing it releases this handle's share. */ + enabled: Signal; + + /** Current status of the shared connection. */ + readonly status: Getter; + + /** + * The wire transport the current session runs over, or undefined while disconnected. + * + * The session itself is deliberately not exposed: it is shared, so no handle may close + * or reconfigure it, and everything else it offers is reachable through {@link origin}. + */ + readonly transport: Getter; + + /** The current connection's PROBE estimates, or undefined while disconnected. */ + readonly probe: Getter; + + /** + * The shared origin for the current URL, or undefined while disabled or URL-less. + * + * Publish into it or consume from it; it is the same origin every other handle on this + * URL uses, and it spans the connection's reconnects. Borrowed, not owned: the type has + * no close, since closing it would tear the origin down under every other handle. + */ + readonly origin: Getter; + + readonly #status = new Signal("disconnected"); + readonly #established = new Signal(undefined); + readonly #probe = new Signal(undefined); + readonly #origin = new Signal(undefined); + #signals = new Effect(); + + /** + * Take a handle on the shared connection for {@link SharedProps.url}. + * + * Dials immediately when a URL is given and `enabled` is not false; otherwise waits for + * the signals to say go. The handle owns nothing but its own share: {@link close} + * releases it, and the underlying connection and origin live for as long as any handle + * (plus the linger window) wants them. + */ + constructor(props?: SharedProps) { + this.url = Signal.from(props?.url); + this.enabled = Signal.from(props?.enabled ?? true); + this.status = this.#status; + this.probe = this.#probe; + this.origin = this.#origin; + this.transport = this.#signals.computed((effect) => effect.get(this.#established)?.transport); + + const linger = props?.linger; + + // Key on the serialized URL: URL objects use identity equality, and an equivalent + // instance must not release and redial. + const href = this.#signals.computed((effect) => effect.get(this.url)?.href); + + this.#signals.run((effect) => { + if (!effect.get(this.enabled)) return; + const key = effect.get(href); + if (!key) return; + + const lease = acquire(key, linger); + effect.cleanup(lease.release); + + effect.set(this.#origin, lease.origin, undefined); + + // Proxy the shared loop's outputs, so this handle reads like its own connection. + effect.run((nested) => nested.set(this.#status, nested.get(lease.connection.status), "disconnected")); + effect.run((nested) => nested.set(this.#established, nested.get(lease.connection.established), undefined)); + effect.run((nested) => nested.set(this.#probe, nested.get(lease.connection.probe), undefined)); + }); + } + + /** + * Subscribe to broadcast announcements under an optional prefix, spanning reconnects + * and URL switches: a switch retracts everything from the old relay's origin, then the + * new one's arrivals stream in. + */ + announced(prefix: Path.Valid = Path.empty()): Announce.Consumer { + const producer = new Announce.Producer(prefix); + const consumer = producer.consume(); + + // Closing the consumer closes the shared state, so stop appending after that. + let closed = false; + void consumer.closed.then(() => { + closed = true; + }); + + // A child of this handle's scope rather than a standalone Effect it merely cleans up + // after: the disposer run() hands back also drops itself from the parent, so opening + // and closing announcement streams repeatedly does not pile up dead pumps that live + // until the whole handle closes. + const stop = this.#signals.run((effect) => { + const origin = effect.get(this.#origin); + if (!origin) return; + + const upstream = origin.announced(prefix); + effect.cleanup(() => upstream.close()); + + // Track what this origin announced so a URL switch retracts it. + const active = new Set(); + + effect.spawn(async () => { + try { + for (;;) { + const entry = await Promise.race([effect.cancel, upstream.next()]); + if (!entry) break; + if (entry.active) active.add(entry.path); + else active.delete(entry.path); + producer.append(entry); + } + } finally { + if (!closed) { + for (const path of active) { + producer.append({ path, active: false }); + } + } + } + }); + }); + + void consumer.closed.then(stop); + + return consumer; + } + + /** + * A reactive handle to one broadcast on the shared origin; see `Announce.Broadcast`. + * Close the handle when done. + */ + announcedBroadcast(path: Path.Valid): Announce.Broadcast { + // The signal is handed out directly: Producer implements the non-owning Table, so + // the handle can read the origin but never close it. + return new Announce.Broadcast({ origin: this.#origin, path }); + } + + /** Snapshot the live connection's transport counters, or undefined while disconnected. */ + async stats(): Promise { + return this.#established.peek()?.stats(); + } + + /** + * Release this handle. The shared connection closes once its last handle is gone and + * the linger window passes; other handles on the URL are unaffected. Idempotent. + */ + close(): void { + this.#signals.close(); + } +} + +/** One shared connection and the handles keeping it alive. */ +interface Entry { + origin: Origin.Producer; + connection: Reload; + refs: number; + linger: DOMHighResTimeStamp; + timer?: ReturnType; +} + +/** The process-wide pool backing {@link Shared}. */ +const pool = new Map(); + +/** Take a reference on the shared entry for `key`, creating it on first use. */ +function acquire(key: string, linger?: DOMHighResTimeStamp): Entry & { release: () => void } { + let entry = pool.get(key); + if (!entry) { + const origin = new Origin.Producer(); + const connection = new Reload({ + url: new URL(key), + enabled: true, + publish: origin.consume(), + subscribe: origin, + // Nobody observes a shared loop's `closed`, so giving up would strand every handle + // on this URL offline until the page reloads. Retry for as long as the entry lives + // instead; an auth rejection is still terminal, and evicts below. + delay: { timeout: 0 }, + }); + + const created: Entry = { origin, connection, refs: 0, linger: linger ?? LINGER_MS }; + + // The loop only stops on a peer saying these credentials will never work. Drop the + // entry so a later handle dials fresh rather than joining a loop that has stopped; + // handles already on it keep it until they release, since a redial would be refused + // the same way. + void connection.closed.catch(() => { + if (pool.get(key) === created) pool.delete(key); + }); + + entry = created; + pool.set(key, created); + } + + const taken = entry; + taken.refs += 1; + if (taken.timer !== undefined) { + clearTimeout(taken.timer); + taken.timer = undefined; + } + + let released = false; + return { + ...taken, + release: () => { + if (released) return; + released = true; + + taken.refs -= 1; + if (taken.refs > 0) return; + + taken.timer = setTimeout(() => { + if (pool.get(key) === taken) pool.delete(key); + taken.connection.close(); + taken.origin.close(); + }, taken.linger); + + // Don't hold a Node process open for a connection nobody is using. + (taken.timer as { unref?: () => void }).unref?.(); + }, + }; +} + +/** + * Close every shared connection immediately, so the next handle dials fresh. + * + * Exists for tests, which otherwise share connections across cases. + * + * @internal + */ +export function resetShared(): void { + const entries = [...pool.values()]; + pool.clear(); + for (const entry of entries) { + if (entry.timer !== undefined) clearTimeout(entry.timer); + entry.connection.close(); + entry.origin.close(); + } +} diff --git a/js/net/src/connection/reload.test.ts b/js/net/src/connection/reload.test.ts index 36c90fd92c..fbcb4f54c8 100644 --- a/js/net/src/connection/reload.test.ts +++ b/js/net/src/connection/reload.test.ts @@ -1,9 +1,10 @@ import { expect, test } from "bun:test"; import { Effect } from "@moq/signals"; -import { Producer as BroadcastProducer } from "../broadcast.ts"; +import type { Producer as BroadcastProducer } from "../broadcast.ts"; import { RemoteError, SessionCode } from "../error.ts"; import * as Lite from "../lite/index.ts"; import { createMockTransportPair } from "../mock.ts"; +import { Producer as OriginProducer } from "../origin.ts"; import * as Path from "../path.ts"; import { accept } from "./index.ts"; import { Reload, type ReloadProps } from "./reload.ts"; @@ -128,6 +129,38 @@ test("a peer that severs immediately keeps escalating the backoff", async () => } }); +test("an explicitly undefined delay field falls back to its default", async () => { + const original = globalThis.WebTransport; + const url = new URL("https://example.com/"); + let dials = 0; + const stub = function StubWebTransport() { + dials += 1; + const pair = createMockTransportPair(Lite.ALPN_06_WIP); + void accept(pair.server, url).then((server) => server.close()); + return pair.client; + }; + globalThis.WebTransport = stub as unknown as typeof WebTransport; + + // What a caller building options from optional values produces. Spreading this over the + // defaults would take the undefined as the answer, and a NaN backoff redials as fast as + // the event loop allows. + const reload = new Reload({ + enabled: true, + url, + websocket: { enabled: false }, + delay: { initial: undefined, multiplier: undefined, max: undefined, timeout: 0 }, + }); + try { + await waitUntil(() => dials > 0); + await new Promise((resolve) => setTimeout(resolve, 100)); + // The default initial delay is 1000ms, so the first retry is still pending. + expect(dials).toBe(1); + } finally { + reload.close(); + globalThis.WebTransport = original; + } +}); + // Polls until `pred` holds, so a regression fails the test instead of hanging it. async function waitUntil(pred: () => boolean): Promise { for (let i = 0; i < 500; i++) { @@ -147,11 +180,10 @@ test("announcedBroadcast follows the reconnect loop", async () => { const published: BroadcastProducer[] = []; const stub = function StubWebTransport() { const pair = createMockTransportPair(Lite.ALPN_06_WIP); - void accept(pair.server, url).then((server) => { + const origin = new OriginProducer(); + void accept(pair.server, url, { publish: origin.consume() }).then((server) => { sessions.push(server); - const broadcast = new BroadcastProducer(); - published.push(broadcast); - server.publish(Path.from("late"), broadcast); + published.push(origin.publish(Path.from("late"))); }); return pair.client; }; @@ -185,6 +217,49 @@ test("announcedBroadcast follows the reconnect loop", async () => { } }); +test("a reload that gives up stops claiming it will answer requests", async () => { + const original = globalThis.WebTransport; + const url = new URL("https://example.com/"); + const stub = function StubWebTransport() { + const pair = createMockTransportPair(Lite.ALPN_06_WIP); + void accept(pair.server, url).then(() => { + pair.server.close({ closeCode: SessionCode.Unauthorized, reason: "unauthorized" }); + }); + return pair.client; + }; + globalThis.WebTransport = stub as unknown as typeof WebTransport; + + const origin = new OriginProducer(); + const reload = new Reload({ + enabled: true, + url, + websocket: { enabled: false }, + delay: { initial: 1, multiplier: 2, max: 1, timeout: 0 }, + subscribe: origin, + }); + + // A reconnecting connection holds requests pending, which is the point: no session is + // attached yet and one is coming. + const request = origin.consume().request(Path.from("wanted")); + expect(request.unroutable.peek()).toBe(false); + + try { + // Terminal: these credentials will never work, so nothing is coming after all and a + // request must stop waiting on it rather than hanging on a connection that is done. + await reload.closed.then( + () => undefined, + () => undefined, + ); + await waitUntil(() => request.unroutable.peek() === true); + expect(request.unroutable.peek()).toBe(true); + } finally { + request.close(); + reload.close(); + origin.close(); + globalThis.WebTransport = original; + } +}); + test("a session rejected as unauthorized surfaces the code and stops retrying", async () => { const original = globalThis.WebTransport; const url = new URL("https://example.com/"); @@ -236,3 +311,59 @@ test("a session rejected as unauthorized surfaces the code and stops retrying", globalThis.WebTransport = original; } }); + +test("origins span reconnects: local re-announces, remote re-populates", async () => { + const original = globalThis.WebTransport; + const url = new URL("https://example.com/origins"); + + // What the client publishes (persistent) and what it discovers (per session). + const publishOrigin = new OriginProducer(); + const subscribeOrigin = new OriginProducer(); + publishOrigin.publish(Path.from("mine")); + + // Each connect attempt gets a fresh server session that publishes "remote" and records + // what the client announced to it. + const servers: { session: { close: () => void }; saw: OriginProducer }[] = []; + const stub = function StubWebTransport() { + const pair = createMockTransportPair(Lite.ALPN_05); + const saw = new OriginProducer(); + const serverOrigin = new OriginProducer(); + void accept(pair.server, url, { publish: serverOrigin.consume(), subscribe: saw }).then((session) => { + serverOrigin.publish(Path.from("remote")); + servers.push({ session, saw }); + }); + return pair.client; + }; + globalThis.WebTransport = stub as unknown as typeof WebTransport; + + const reload = new Reload({ + enabled: true, + url, + websocket: { enabled: false }, + delay: { initial: 10, multiplier: 1, max: 10 }, + publish: publishOrigin.consume(), + subscribe: subscribeOrigin, + }); + const reader = subscribeOrigin.consume(); + + try { + // First session: the server's broadcast lands in the client origin, and the client's + // publish lands in the server's. + await waitUntil(() => reader.routes(Path.from("remote"))); + await waitUntil(() => servers[0]?.saw.routes(Path.from("mine"))); + + // Kill the session: the remote entry retracts, the local publish stays put. + servers[0]?.session.close(); + await waitUntil(() => !reader.routes(Path.from("remote"))); + + // The reconnect re-announces the (untouched) publish and re-populates the table. + await waitUntil(() => servers.length > 1); + await waitUntil(() => reader.routes(Path.from("remote"))); + await waitUntil(() => servers[1]?.saw.routes(Path.from("mine"))); + } finally { + reload.close(); + publishOrigin.close(); + subscribeOrigin.close(); + globalThis.WebTransport = original; + } +}); diff --git a/js/net/src/connection/reload.ts b/js/net/src/connection/reload.ts index 02b1da6a30..e3fb4f1e0c 100644 --- a/js/net/src/connection/reload.ts +++ b/js/net/src/connection/reload.ts @@ -1,6 +1,7 @@ -import { Effect, type Getter, Signal } from "@moq/signals"; +import { type Dispose, Effect, type Getter, Signal } from "@moq/signals"; import * as Announce from "../announced.ts"; import { error, RemoteError, SessionCode } from "../error.ts"; +import type { Consumer as OriginConsumer, Producer as OriginProducer } from "../origin.ts"; import type * as Path from "../path.ts"; import { empty as emptyPath } from "../path.ts"; import { type ConnectProps, connect, type WebSocketOptions, type WebTransportProps } from "./connect.ts"; @@ -15,13 +16,13 @@ import type { Probe, Stats } from "./stats.ts"; */ export type ReloadDelay = { /** The delay in milliseconds before reconnecting (default: 1000). */ - initial: DOMHighResTimeStamp; + initial?: DOMHighResTimeStamp; /** The multiplier for the delay (default: 2). */ - multiplier: number; + multiplier?: number; /** The maximum delay in milliseconds (default: 5000). */ - max: DOMHighResTimeStamp; + max?: DOMHighResTimeStamp; /** * Maximum total time in milliseconds to spend retrying before giving up (default: @@ -50,17 +51,23 @@ export type ReloadProps = Omit & { /** The URL of the relay server. */ url?: URL | Signal; - /** Backoff settings for the reconnect loop. */ + /** Backoff settings for the reconnect loop; every field falls back to its default. */ delay?: ReloadDelay; }; /** - * How long to keep retrying before giving up, when {@link ReloadDelay.timeout} is unset. + * The backoff applied to whichever {@link ReloadDelay} fields a caller leaves out. * - * Short on purpose: a failure that clears within it was transient, and one that doesn't should - * surface as an error rather than leave the page silently reconnecting for minutes. + * The timeout is short on purpose: a failure that clears within it was transient, and one that + * doesn't should surface as an error rather than leave the page silently reconnecting for + * minutes. A loop nobody watches wants `timeout: 0` instead, since there is no one to react. */ -const DEFAULT_TIMEOUT = 10000; +const DEFAULT_DELAY: Required = { + initial: 1000, + multiplier: 2, + max: 5000, + timeout: 10000, +}; /** Current state of a {@link Reload} connection. */ export type ReloadStatus = "connecting" | "connected" | "disconnected"; @@ -99,7 +106,26 @@ export class Reload { */ discovery?: boolean; - /** Backoff settings for the reconnect loop. */ + /** + * The origin whose broadcasts are served, spanning reconnects (not reactive). + * + * Each session announces the origin's table when it attaches, so a broadcast published + * while offline surfaces on the next connection and a reconnect re-announces everything + * still published. See the `publish` connect option. + */ + publish?: OriginConsumer; + + /** + * The origin fed with the peer's announced broadcasts, spanning reconnects (not + * reactive). + * + * The entries a session fed retract when it dies, and the next session re-populates the + * table, so a consumer watching the origin sees offline/online transitions across a + * reconnect. See the `subscribe` connect option. + */ + subscribe?: OriginProducer; + + /** Backoff settings for the reconnect loop; an unset field uses its default. */ delay: ReloadDelay; /** The reactive effect scope driving the connect loop; closed by {@link Reload.close}. */ @@ -115,6 +141,10 @@ export class Reload { #closedResolve!: () => void; #closedReject!: (err: Error) => void; + // Releases the subscribe origin's expectation. Idempotent, so the terminal paths and the + // close cleanup can both call it. + #expected?: Dispose; + // The current wait between attempts, doubling per failure, and when the retry window expires. // Both are undefined between sequences, so a later edit to `delay` applies to the next one. #delay: DOMHighResTimeStamp | undefined; @@ -132,10 +162,22 @@ export class Reload { constructor(props?: ReloadProps) { this.url = Signal.from(props?.url); this.enabled = Signal.from(props?.enabled ?? false); - this.delay = props?.delay ?? { initial: 1000, multiplier: 2, max: 5000 }; + this.delay = props?.delay ?? {}; this.webtransport = props?.webtransport; this.websocket = props?.websocket; this.discovery = props?.discovery; + this.publish = props?.publish; + this.subscribe = props?.subscribe; + + // Requests on the subscribe origin stay pending across a reconnect, and before the + // first session establishes, rather than reading as unroutable the moment no session + // is attached. Released once nothing is coming any more, which is either a close or a + // terminal failure: a reconnect loop that has given up must stop claiming it will + // answer, or every request on the origin waits forever on a connection that is done. + if (this.subscribe) { + this.#expected = this.subscribe.expect(); + this.#signals.cleanup(this.#expected); + } this.closed = new Promise((resolve, reject) => { this.#closedResolve = resolve; @@ -193,6 +235,8 @@ export class Reload { websocket: this.websocket, webtransport: this.webtransport, discovery: this.discovery, + publish: this.publish, + subscribe: this.subscribe, signal, }); @@ -229,6 +273,17 @@ export class Reload { * `cause` the error that killed it, if it died with one. */ #retry(effect: Effect, connected: DOMHighResTimeStamp | undefined, cause?: unknown): void { + // Resolved per sequence rather than at construction, so an edit to `delay` (including + // one that drops a field back to its default) applies to the next retry. Field by + // field rather than by spread: a caller building `{ initial: maybeInitial }` from an + // optional value passes an explicit undefined, which a spread would take as the + // answer, turning the backoff into NaN or the window into forever. + const delay = this.delay ?? {}; + const initial = delay.initial ?? DEFAULT_DELAY.initial; + const multiplier = delay.multiplier ?? DEFAULT_DELAY.multiplier; + const max = delay.max ?? DEFAULT_DELAY.max; + const timeout = delay.timeout ?? DEFAULT_DELAY.timeout; + // Any session is dead now: report disconnected during the backoff rather than // when the retry reruns the effect. this.established.set(undefined); @@ -238,7 +293,7 @@ export class Reload { // start a fresh retry window: a one-off drop should reconnect promptly. Anything // shorter is a peer that accepts and immediately severs, which has to keep // escalating or we hammer it forever at the initial delay. - if (connected !== undefined && performance.now() - connected >= this.delay.initial) { + if (connected !== undefined && performance.now() - connected >= initial) { this.#delay = undefined; this.#deadline = undefined; } @@ -249,18 +304,19 @@ export class Reload { // moq-native's reconnect loop, which stops on the same close. if (cause instanceof RemoteError && cause.code === SessionCode.Unauthorized) { console.warn("session rejected as unauthorized, not retrying"); + this.#expected?.(); this.#closedReject(cause); return; } const now = performance.now(); - const timeout = this.delay.timeout ?? DEFAULT_TIMEOUT; - this.#delay ??= this.delay.initial; + this.#delay ??= initial; this.#deadline ??= timeout > 0 ? now + timeout : Number.POSITIVE_INFINITY; if (now >= this.#deadline) { console.warn("reconnect timed out"); // A graceful close has no error, so report the timeout itself. + this.#expected?.(); this.#closedReject(cause === undefined ? new Error("reconnect timed out") : error(cause)); return; } @@ -268,7 +324,7 @@ export class Reload { // Equal jitter, so a fleet of tabs knocked offline together doesn't reconnect on the same // tick, and never past the deadline the retry window promised. const wait = Math.min(this.#delay * (0.5 + Math.random() / 2), this.#deadline - now); - this.#delay = Math.min(this.#delay * this.delay.multiplier, this.delay.max); + this.#delay = Math.min(this.#delay * multiplier, max); const tick = this.#tick.peek() + 1; effect.timer(() => this.#tick.update((prev) => Math.max(prev, tick)), wait); @@ -284,6 +340,10 @@ export class Reload { * Stays empty while the relay lacks {@link Established.discovery}. */ announced(prefix: Path.Valid = emptyPath()): Announce.Consumer { + // With a subscribe origin the table already spans reconnects (the forwarder retracts + // a dead session's entries), so its stream is the same thing with less machinery. + if (this.subscribe) return this.subscribe.announced(prefix); + const producer = new Announce.Producer(prefix); const consumer = producer.consume(); @@ -349,6 +409,8 @@ export class Reload { * Close the handle when done; {@link Reload.close} only drops it to `undefined`. */ announcedBroadcast(path: Path.Valid): Announce.Broadcast { + // Same delegation as announced(): the origin's table is the reconnect-spanning view. + if (this.subscribe) return new Announce.Broadcast({ origin: this.subscribe, path }); return new Announce.Broadcast({ connection: this.established, path }); } diff --git a/js/net/src/ietf/connection.ts b/js/net/src/ietf/connection.ts index e87c925b62..a6af076eb8 100644 --- a/js/net/src/ietf/connection.ts +++ b/js/net/src/ietf/connection.ts @@ -5,6 +5,7 @@ import type { Established } from "../connection/established.ts"; import { type Probe, type Stats, transportStats } from "../connection/stats.ts"; import { type Transport, transportOf } from "../connection/transport.ts"; import { error, fromClose } from "../error.ts"; +import type { Consumer as OriginConsumer } from "../origin.ts"; import * as Path from "../path.ts"; import { type Reader, Readers, type Stream } from "../stream.ts"; import { ControlStreamAdapter, NativeSession, type Session } from "./adapter.ts"; @@ -73,6 +74,7 @@ export class Connection implements Established { version, client, discovery = true, + publish, }: { url: URL; quic: WebTransport; @@ -82,6 +84,8 @@ export class Connection implements Established { /** Whether this peer initiated the session, selecting the even request-ID space. */ client: boolean; discovery?: boolean; + /** The origin whose broadcasts are served to the peer. Omit to publish nothing. */ + publish?: OriginConsumer; }) { this.url = url; this.discovery = discovery; @@ -104,7 +108,7 @@ export class Connection implements Established { }); } - this.#publisher = new Publisher(this.#quic, this.#session); + this.#publisher = new Publisher(this.#quic, this.#session, publish); this.#subscriber = new Subscriber(this.#session); void this.#run(); @@ -123,7 +127,6 @@ export class Connection implements Established { this.#closed = true; - this.#publisher.close(); this.#session.close(); try { @@ -145,15 +148,6 @@ export class Connection implements Established { } } - /** - * Publishes a broadcast to the connection. - * @param name - The broadcast path to publish - * @param broadcast - The broadcast to publish - */ - publish(path: Path.Valid, producer: broadcast.Producer) { - this.#publisher.publish(path, producer); - } - /** * Gets an announced reader for the specified prefix. * @param prefix - The prefix for announcements diff --git a/js/net/src/ietf/publisher.ts b/js/net/src/ietf/publisher.ts index 9cfe5e4883..c50584ae38 100644 --- a/js/net/src/ietf/publisher.ts +++ b/js/net/src/ietf/publisher.ts @@ -1,7 +1,8 @@ -import { type Dispose, Signal } from "@moq/signals"; +import { type Dispose, type Getter, Signal } from "@moq/signals"; import type * as broadcast from "../broadcast.ts"; import { error, reason } from "../error.ts"; import type * as group from "../group.ts"; +import type { Consumer as OriginConsumer } from "../origin.ts"; import * as Path from "../path.ts"; import { type Stream, Writer } from "../stream.ts"; import type { Timescale } from "../time.ts"; @@ -46,39 +47,25 @@ export class Publisher { #quic: WebTransport; #session: Session; - // Our published broadcasts. - // It's a signal so we can live update any subscribe_namespace streams. - #broadcasts = new Signal | undefined>(new Map()); + // The published broadcasts, borrowed from the origin this session serves. The origin + // outlives the session, so this is read-only here: subscribe_namespace streams watch it + // for changes, and closing the session leaves the broadcasts alone. The namespaces are + // only advertised in response to a SUBSCRIBE_NAMESPACE (see {@link runSubscribeNamespace}), + // mirroring the moq-lite publisher. + #broadcasts: Getter | undefined>; /** * Creates a new Publisher instance. * @param quic - The WebTransport session (for uni streams) * @param session - The session abstraction for bidi streams and request IDs + * @param publish - The origin whose broadcasts this session serves; omit to publish nothing * * @internal */ - constructor(quic: WebTransport, session: Session) { + constructor(quic: WebTransport, session: Session, publish?: OriginConsumer) { this.#quic = quic; this.#session = session; - } - - /** - * Publishes a broadcast with any associated tracks. - * The namespace is only advertised in response to a SUBSCRIBE_NAMESPACE - * (see {@link runSubscribeNamespace}), mirroring the moq-lite publisher. - */ - publish(path: Path.Valid, broadcast: broadcast.Producer) { - this.#broadcasts.mutate((broadcasts) => { - if (!broadcasts) throw new Error("closed"); - broadcasts.set(path, broadcast); - }); - - // Remove the broadcast from the lookup when it's closed. - void broadcast.closed.then(() => { - this.#broadcasts.mutate((broadcasts) => { - broadcasts?.delete(path); - }); - }); + this.#broadcasts = publish?.broadcasts ?? new Signal(new Map()); } /** @@ -297,14 +284,16 @@ export class Publisher { } }; - // Advertise the currently published broadcasts under the prefix. - let active = new Set(); - for (const name of this.#broadcasts.peek()?.keys() ?? []) { + // Advertise the currently published broadcasts under the prefix. Keyed by suffix, + // valued by the routing front, so a republish diffs as withdraw-then-advertise + // rather than nothing. + let active = new Map(); + for (const [name, front] of this.#broadcasts.peek() ?? []) { const suffix = Path.stripPrefix(prefix, name); if (suffix === null) continue; - active.add(suffix); + active.set(suffix, front); } - for (const suffix of active) { + for (const suffix of active.keys()) { await advertise(suffix); } @@ -312,7 +301,7 @@ export class Publisher { for (;;) { // TODO Make a better helper within Signals. let dispose!: Dispose; - const changed = new Promise | undefined>((resolve) => { + const changed = new Promise | undefined>((resolve) => { dispose = this.#broadcasts.changed(resolve); }); @@ -321,18 +310,19 @@ export class Publisher { dispose(); if (!broadcasts) break; - const newActive = new Set(); - for (const name of broadcasts.keys()) { + const newActive = new Map(); + for (const [name, front] of broadcasts) { const suffix = Path.stripPrefix(prefix, name); if (suffix === null) continue; - newActive.add(suffix); + newActive.set(suffix, front); } - for (const added of newActive.difference(active)) { - await advertise(added); + // Withdraw first so a republish reads as withdraw-then-advertise (a restart). + for (const [removed, front] of active) { + if (newActive.get(removed) !== front) await withdraw(removed); } - for (const removed of active.difference(newActive)) { - await withdraw(removed); + for (const [added, front] of newActive) { + if (active.get(added) !== front) await advertise(added); } active = newActive; @@ -444,18 +434,4 @@ export class Publisher { } stream.close(); } - - /** - * Closes every published broadcast and stops accepting new ones. - * - * @internal - */ - close() { - this.#broadcasts.update((broadcasts) => { - for (const broadcast of broadcasts?.values() ?? []) { - broadcast.close(); - } - return undefined; - }); - } } diff --git a/js/net/src/index.ts b/js/net/src/index.ts index 52db3ccffe..a6244ac879 100644 --- a/js/net/src/index.ts +++ b/js/net/src/index.ts @@ -17,6 +17,8 @@ export * as Connection from "./connection/index.ts"; export { RemoteError, SessionCode, StreamCode } from "./error.ts"; /** Group role handles and frame helpers. */ export * as Group from "./group.ts"; +/** Broadcast routing tables, independent of any connection. */ +export * as Origin from "./origin.ts"; /** Broadcast path utilities with delimiter-aware prefix matching. */ export * as Path from "./path.ts"; /** Branded time types (nanoseconds, microseconds, milliseconds, seconds) with conversions. */ diff --git a/js/net/src/integration.test.ts b/js/net/src/integration.test.ts index 6ffd726d5d..fa247e4603 100644 --- a/js/net/src/integration.test.ts +++ b/js/net/src/integration.test.ts @@ -1,11 +1,14 @@ import { expect, test } from "bun:test"; import type { Getter } from "@moq/signals"; -import { Producer as BroadcastProducer } from "./broadcast.ts"; -import { accept, connect } from "./connection/index.ts"; +import * as Announce from "./announced.ts"; +import type { Consumer as BroadcastConsumer, Producer as BroadcastProducer } from "./broadcast.ts"; +import { accept, connect, Reload } from "./connection/index.ts"; import { RemoteError } from "./error.ts"; import * as Ietf from "./ietf/index.ts"; import * as Lite from "./lite/index.ts"; import { createMockTransportPair } from "./mock.ts"; +import type { Consumer as OriginConsumer } from "./origin.ts"; +import { Producer as OriginProducer } from "./origin.ts"; import * as Path from "./path.ts"; import { Timescale, Timestamp } from "./time.ts"; import type { Producer as TrackProducer } from "./track.ts"; @@ -15,19 +18,31 @@ const url = new URL("https://localhost:4443/test"); const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +/** + * The table's route for `path` as a handle of the caller's own. + * + * A request is the only way to consume by path, and it resolves against the table + * synchronously, so a routed path needs no waiting. Cloned because a request only borrows. + */ +function routed(origin: OriginConsumer, path: Path.Valid): BroadcastConsumer | undefined { + const request = origin.request(path); + const front = request.active.peek()?.clone(); + request.close(); + return front; +} + async function runPublishSubscribeFlow(protocol: string, version?: number) { const pair = createMockTransportPair(protocol); + const origin = new OriginProducer(); const [client, server] = await Promise.all([ connect(url, { transport: pair.client }), - accept(pair.server, url, version !== undefined ? { version } : undefined), + accept(pair.server, url, { version, publish: origin.consume() }), ]); // Server publishes a broadcast - const broadcast = new BroadcastProducer(); - server.publish(Path.from("test"), broadcast); - const prefixedBroadcast = new BroadcastProducer(); - server.publish(Path.from("root/child"), prefixedBroadcast); + const broadcast = origin.publish(Path.from("test")); + const prefixedBroadcast = origin.publish(Path.from("root/child")); // Serve every requested "video" track. On lite-05+ a subscribe is preceded by // a TRACK info lookup, which the publisher answers by requesting the track too, @@ -100,10 +115,13 @@ test("integration: lite draft-05", async () => { test("integration: lite subscription options and updates reach the publisher", async () => { const pair = createMockTransportPair(Lite.ALPN_05); - const [client, server] = await Promise.all([connect(url, { transport: pair.client }), accept(pair.server, url)]); + const origin = new OriginProducer(); + const [client, server] = await Promise.all([ + connect(url, { transport: pair.client }), + accept(pair.server, url, { publish: origin.consume() }), + ]); - const broadcast = new BroadcastProducer(); - server.publish(Path.from("test"), broadcast); + const broadcast = origin.publish(Path.from("test")); let resolveProducer: ((producer: TrackProducer) => void) | undefined; const accepted = new Promise((resolve) => { @@ -162,12 +180,15 @@ test("integration: lite applies initial and updated group bounds", async () => { const UPDATE_TIMEOUT_MS = 1000; const pair = createMockTransportPair(Lite.ALPN_05); - const [client, server] = await Promise.all([connect(url, { transport: pair.client }), accept(pair.server, url)]); + const origin = new OriginProducer(); + const [client, server] = await Promise.all([ + connect(url, { transport: pair.client }), + accept(pair.server, url, { publish: origin.consume() }), + ]); - const broadcast = new BroadcastProducer(); + const broadcast = origin.publish(Path.from("test")); const producer = broadcast.createTrack("video"); for (let sequence = 0; sequence < GROUP_COUNT; sequence++) producer.appendGroup().close(); - server.publish(Path.from("test"), broadcast); const remote = client.consume(Path.from("test")); const subscriber = remote @@ -203,11 +224,14 @@ test("integration: lite draft-06", async () => { test("integration: lite draft-06 announce lifecycle", async () => { const pair = createMockTransportPair(Lite.ALPN_06_WIP); - const [client, server] = await Promise.all([connect(url, { transport: pair.client }), accept(pair.server, url)]); + const origin = new OriginProducer(); + const [client, server] = await Promise.all([ + connect(url, { transport: pair.client }), + accept(pair.server, url, { publish: origin.consume() }), + ]); // Announced before the client asks, so it can ride the initial set. - const first = new BroadcastProducer(); - server.publish(Path.from("first"), first); + const first = origin.publish(Path.from("first")); const announced = client.announced(); let entry = await announced.next(); @@ -216,8 +240,7 @@ test("integration: lite draft-06 announce lifecycle", async () => { expect(entry.active).toBe(true); // A live announce. - const second = new BroadcastProducer(); - server.publish(Path.from("second"), second); + const second = origin.publish(Path.from("second")); entry = await announced.next(); if (!entry) throw new Error("expected announce"); expect(entry.path).toBe("second" as Path.Valid); @@ -231,8 +254,7 @@ test("integration: lite draft-06 announce lifecycle", async () => { expect(entry.active).toBe(false); // Re-announce the same path: a fresh announce assigning a fresh id. - const secondAgain = new BroadcastProducer(); - server.publish(Path.from("second"), secondAgain); + const secondAgain = origin.publish(Path.from("second")); entry = await announced.next(); if (!entry) throw new Error("expected re-announce"); expect(entry.path).toBe("second" as Path.Valid); @@ -250,12 +272,15 @@ test("integration: lite draft-05 datagram delivery", async () => { const enc = new TextEncoder(); const dec = new TextDecoder(); const pair = createMockTransportPair(Lite.ALPN_05); + const origin = new OriginProducer(); - const [client, server] = await Promise.all([connect(url, { transport: pair.client }), accept(pair.server, url)]); + const [client, server] = await Promise.all([ + connect(url, { transport: pair.client }), + accept(pair.server, url, { publish: origin.consume() }), + ]); // A static track fans datagrams out to whoever subscribes. - const broadcast = new BroadcastProducer(); - server.publish(Path.from("test"), broadcast); + const broadcast = origin.publish(Path.from("test")); const producer = broadcast.createTrack("video", { timescale: Timescale.MILLI }); const remote = client.consume(Path.from("test")); @@ -290,11 +315,14 @@ test("integration: lite draft-05 datagrams not sent on a non-datagram transport" // maxDatagramSize 0 simulates a qmux/WebSocket session: the publisher must fall back to // not sending datagrams (there is no group fallback), while groups still flow. const pair = createMockTransportPair(Lite.ALPN_05, { datagrams: false }); + const origin = new OriginProducer(); - const [client, server] = await Promise.all([connect(url, { transport: pair.client }), accept(pair.server, url)]); + const [client, server] = await Promise.all([ + connect(url, { transport: pair.client }), + accept(pair.server, url, { publish: origin.consume() }), + ]); - const broadcast = new BroadcastProducer(); - server.publish(Path.from("test"), broadcast); + const broadcast = origin.publish(Path.from("test")); const producer = broadcast.createTrack("video", { timescale: Timescale.MILLI }); const remote = client.consume(Path.from("test")); @@ -333,11 +361,14 @@ test("integration: lite draft-05 datagrams sent with standards-track createWrita const enc = new TextEncoder(); const dec = new TextDecoder(); const pair = createMockTransportPair(Lite.ALPN_05, { datagramWritable: "createWritable" }); + const origin = new OriginProducer(); - const [client, server] = await Promise.all([connect(url, { transport: pair.client }), accept(pair.server, url)]); + const [client, server] = await Promise.all([ + connect(url, { transport: pair.client }), + accept(pair.server, url, { publish: origin.consume() }), + ]); - const broadcast = new BroadcastProducer(); - server.publish(Path.from("test"), broadcast); + const broadcast = origin.publish(Path.from("test")); const producer = broadcast.createTrack("video", { timescale: Timescale.MILLI }); const remote = client.consume(Path.from("test")); @@ -368,11 +399,14 @@ test("integration: lite draft-05 datagrams sent with standards-track createWrita test("integration: lite draft-05 missing datagram writer does not close streams", async () => { const enc = new TextEncoder(); const pair = createMockTransportPair(Lite.ALPN_05, { datagramWritable: "none" }); + const origin = new OriginProducer(); - const [client, server] = await Promise.all([connect(url, { transport: pair.client }), accept(pair.server, url)]); + const [client, server] = await Promise.all([ + connect(url, { transport: pair.client }), + accept(pair.server, url, { publish: origin.consume() }), + ]); - const broadcast = new BroadcastProducer(); - server.publish(Path.from("test"), broadcast); + const broadcast = origin.publish(Path.from("test")); const producer = broadcast.createTrack("video", { timescale: Timescale.MILLI }); const remote = client.consume(Path.from("test")); @@ -392,11 +426,14 @@ test("integration: lite draft-05 missing datagram writer does not close streams" test("integration: lite draft-05 missing datagram reader does not close streams", async () => { const enc = new TextEncoder(); const pair = createMockTransportPair(Lite.ALPN_05, { datagramReadable: false }); + const origin = new OriginProducer(); - const [client, server] = await Promise.all([connect(url, { transport: pair.client }), accept(pair.server, url)]); + const [client, server] = await Promise.all([ + connect(url, { transport: pair.client }), + accept(pair.server, url, { publish: origin.consume() }), + ]); - const broadcast = new BroadcastProducer(); - server.publish(Path.from("test"), broadcast); + const broadcast = origin.publish(Path.from("test")); const producer = broadcast.createTrack("video", { timescale: Timescale.MILLI }); const remote = client.consume(Path.from("test")); @@ -426,12 +463,15 @@ class Reset extends Error { test("integration: a group reset carries the peer's code to the subscriber", async () => { const pair = createMockTransportPair(Lite.ALPN_06_WIP); + const origin = new OriginProducer(); - const [client, server] = await Promise.all([connect(url, { transport: pair.client }), accept(pair.server, url)]); + const [client, server] = await Promise.all([ + connect(url, { transport: pair.client }), + accept(pair.server, url, { publish: origin.consume() }), + ]); - const broadcast = new BroadcastProducer(); + const broadcast = origin.publish(Path.from("test")); const producer = broadcast.createTrack("video", { timescale: Timescale.MILLI }); - server.publish(Path.from("test"), broadcast); const remote = client.consume(Path.from("test")); const track = remote.track("video").subscribe(); @@ -465,12 +505,15 @@ test("integration: lite draft-05 fetches a cached group", async () => { const enc = new TextEncoder(); const dec = new TextDecoder(); const pair = createMockTransportPair(Lite.ALPN_05); + const origin = new OriginProducer(); - const [client, server] = await Promise.all([connect(url, { transport: pair.client }), accept(pair.server, url)]); + const [client, server] = await Promise.all([ + connect(url, { transport: pair.client }), + accept(pair.server, url, { publish: origin.consume() }), + ]); - const broadcast = new BroadcastProducer(); + const broadcast = origin.publish(Path.from("test")); const producer = broadcast.createTrack("video"); - server.publish(Path.from("test"), broadcast); const group0 = producer.appendGroup(); group0.writeFrame({ payload: enc.encode("alpha"), timestamp: Timestamp.fromMillis(10) }); @@ -505,12 +548,15 @@ test("integration: lite draft-05 coalesces concurrent fetches of one group", asy const enc = new TextEncoder(); const dec = new TextDecoder(); const pair = createMockTransportPair(Lite.ALPN_05); + const origin = new OriginProducer(); - const [client, server] = await Promise.all([connect(url, { transport: pair.client }), accept(pair.server, url)]); + const [client, server] = await Promise.all([ + connect(url, { transport: pair.client }), + accept(pair.server, url, { publish: origin.consume() }), + ]); - const broadcast = new BroadcastProducer(); + const broadcast = origin.publish(Path.from("test")); const producer = broadcast.createTrack("video"); - server.publish(Path.from("test"), broadcast); const group0 = producer.appendGroup(); group0.writeFrame({ payload: enc.encode("alpha"), timestamp: Timestamp.fromMillis(10) }); @@ -546,12 +592,15 @@ test("integration: lite draft-05 fetches an in-progress group", async () => { const enc = new TextEncoder(); const dec = new TextDecoder(); const pair = createMockTransportPair(Lite.ALPN_05); + const origin = new OriginProducer(); - const [client, server] = await Promise.all([connect(url, { transport: pair.client }), accept(pair.server, url)]); + const [client, server] = await Promise.all([ + connect(url, { transport: pair.client }), + accept(pair.server, url, { publish: origin.consume() }), + ]); - const broadcast = new BroadcastProducer(); + const broadcast = origin.publish(Path.from("test")); const producer = broadcast.createTrack("video"); - server.publish(Path.from("test"), broadcast); // Open the group and write one frame, but leave it open (in-progress). const group0 = producer.appendGroup(); @@ -580,8 +629,12 @@ test("integration: lite draft-05 fetches an in-progress group", async () => { test("integration: ietf fetch group is unsupported", async () => { const pair = createMockTransportPair(Ietf.ALPN.DRAFT_18); + const origin = new OriginProducer(); - const [client, server] = await Promise.all([connect(url, { transport: pair.client }), accept(pair.server, url)]); + const [client, server] = await Promise.all([ + connect(url, { transport: pair.client }), + accept(pair.server, url, { publish: origin.consume() }), + ]); const remote = client.consume(Path.from("test")); await expect(remote.track("video").fetchGroup(0)).rejects.toThrow("fetch group is not supported for moq-transport"); @@ -619,9 +672,10 @@ test("integration: ietf draft-19", async () => { // broadcast, so it stays live until every handle closes and a closed path re-consumes fresh. async function runConsumeDedup(protocol: string, version?: number) { const pair = createMockTransportPair(protocol); + const origin = new OriginProducer(); const [client, server] = await Promise.all([ connect(url, { transport: pair.client }), - accept(pair.server, url, version !== undefined ? { version } : undefined), + accept(pair.server, url, { version, publish: origin.consume() }), ]); // Two handles to the same path share one broadcast: closing the first leaves it live... @@ -662,13 +716,13 @@ async function waitUntil(predicate: () => boolean): Promise { // serving it (the muted-watch-tile case in #2355) instead of sending groups to a reader that left. async function runSubscriberTeardown(protocol: string, version?: number) { const pair = createMockTransportPair(protocol); + const origin = new OriginProducer(); const [client, server] = await Promise.all([ connect(url, { transport: pair.client }), - accept(pair.server, url, version !== undefined ? { version } : undefined), + accept(pair.server, url, { version, publish: origin.consume() }), ]); - const broadcast = new BroadcastProducer(); - server.publish(Path.from("test"), broadcast); + const broadcast = origin.publish(Path.from("test")); const video = broadcast.createTrack("video"); video.writeString("hello"); @@ -702,13 +756,13 @@ test("integration: ietf subscriber teardown on last unsubscribe", async () => { // Uses a dynamic serve (draft-14 doesn't complete SUBSCRIBE_OK for a statically inserted track). test("integration: ietf draft-14 subscriber teardown on last unsubscribe", async () => { const pair = createMockTransportPair(""); + const origin = new OriginProducer(); const [client, server] = await Promise.all([ connect(url, { transport: pair.client }), - accept(pair.server, url, { version: Ietf.Version.DRAFT_14 }), + accept(pair.server, url, { version: Ietf.Version.DRAFT_14, publish: origin.consume() }), ]); - const broadcast = new BroadcastProducer(); - server.publish(Path.from("test"), broadcast); + const broadcast = origin.publish(Path.from("test")); // Serve dynamically, keeping the served producer so we can watch its demand. let served: TrackProducer | undefined; @@ -746,10 +800,13 @@ test("integration: ietf draft-14 subscriber teardown on last unsubscribe", async // fetch must cancel the FETCH stream rather than wait for a stream end that never comes. test("integration: lite fetch teardown when the reader abandons an open group", async () => { const pair = createMockTransportPair(Lite.ALPN_06_WIP); - const [client, server] = await Promise.all([connect(url, { transport: pair.client }), accept(pair.server, url)]); + const origin = new OriginProducer(); + const [client, server] = await Promise.all([ + connect(url, { transport: pair.client }), + accept(pair.server, url, { publish: origin.consume() }), + ]); - const broadcast = new BroadcastProducer(); - server.publish(Path.from("test"), broadcast); + const broadcast = origin.publish(Path.from("test")); const video = broadcast.createTrack("video"); const group = video.appendGroup(); // deliberately left open: an indefinite group. group.writeString("hello"); @@ -783,10 +840,13 @@ test("integration: lite draft-01 subscriber teardown on last unsubscribe", async // for the other, and only the last close tears it down. test("integration: lite fan-out keeps the upstream until the last subscriber leaves", async () => { const pair = createMockTransportPair(Lite.ALPN_06_WIP); - const [client, server] = await Promise.all([connect(url, { transport: pair.client }), accept(pair.server, url)]); + const origin = new OriginProducer(); + const [client, server] = await Promise.all([ + connect(url, { transport: pair.client }), + accept(pair.server, url, { publish: origin.consume() }), + ]); - const broadcast = new BroadcastProducer(); - server.publish(Path.from("test"), broadcast); + const broadcast = origin.publish(Path.from("test")); const video = broadcast.createTrack("video"); video.writeString("hello"); @@ -817,10 +877,13 @@ test("integration: lite fan-out keeps the upstream until the last subscriber lea // scenario in the issue), never wedging the shared cache or leaking a subscription. test("integration: lite re-subscribe re-opens the upstream after each teardown", async () => { const pair = createMockTransportPair(Lite.ALPN_06_WIP); - const [client, server] = await Promise.all([connect(url, { transport: pair.client }), accept(pair.server, url)]); + const origin = new OriginProducer(); + const [client, server] = await Promise.all([ + connect(url, { transport: pair.client }), + accept(pair.server, url, { publish: origin.consume() }), + ]); - const broadcast = new BroadcastProducer(); - server.publish(Path.from("test"), broadcast); + const broadcast = origin.publish(Path.from("test")); const video = broadcast.createTrack("video"); const remote = client.consume(Path.from("test")); @@ -845,10 +908,13 @@ test("integration: lite re-subscribe re-opens the upstream after each teardown", // for the other, and only the last abandon cancels it. test("integration: lite coalesced fetch stays until every reader abandons the open group", async () => { const pair = createMockTransportPair(Lite.ALPN_06_WIP); - const [client, server] = await Promise.all([connect(url, { transport: pair.client }), accept(pair.server, url)]); + const origin = new OriginProducer(); + const [client, server] = await Promise.all([ + connect(url, { transport: pair.client }), + accept(pair.server, url, { publish: origin.consume() }), + ]); - const broadcast = new BroadcastProducer(); - server.publish(Path.from("test"), broadcast); + const broadcast = origin.publish(Path.from("test")); const video = broadcast.createTrack("video"); const group = video.appendGroup(); // open group.writeString("hello"); @@ -881,10 +947,13 @@ test("integration: lite coalesced fetch stays until every reader abandons the op // normal completion), exercising the per-frame loop many times. test("integration: lite fetch delivers every frame of a finite multi-frame group", async () => { const pair = createMockTransportPair(Lite.ALPN_06_WIP); - const [client, server] = await Promise.all([connect(url, { transport: pair.client }), accept(pair.server, url)]); + const origin = new OriginProducer(); + const [client, server] = await Promise.all([ + connect(url, { transport: pair.client }), + accept(pair.server, url, { publish: origin.consume() }), + ]); - const broadcast = new BroadcastProducer(); - server.publish(Path.from("test"), broadcast); + const broadcast = origin.publish(Path.from("test")); const video = broadcast.createTrack("video"); const group = video.appendGroup(); const count = 50; @@ -917,13 +986,13 @@ test("integration: ietf consume dedup", async () => { // races an inbound announce, without warming the session by reading that announce first. async function runSubscribeWithoutWarmup(version: number) { const pair = createMockTransportPair(""); + const origin = new OriginProducer(); const [client, server] = await Promise.all([ connect(url, { transport: pair.client }), - accept(pair.server, url, { version }), + accept(pair.server, url, { version, publish: origin.consume() }), ]); - const broadcast = new BroadcastProducer(); - server.publish(Path.from("test"), broadcast); + const broadcast = origin.publish(Path.from("test")); const serving = (async () => { const req = await broadcast.requested(); if (req) req.accept().writeString("hello"); @@ -960,10 +1029,11 @@ test("integration: ietf draft-16 subscribe without announce warmup", async () => test("integration: subscribe to non-existent broadcast", async () => { const pair = createMockTransportPair(""); + const origin = new OriginProducer(); const [client, server] = await Promise.all([ connect(url, { transport: pair.client }), - accept(pair.server, url, { version: Ietf.Version.DRAFT_14 }), + accept(pair.server, url, { version: Ietf.Version.DRAFT_14, publish: origin.consume() }), ]); // Client tries to consume a broadcast that nobody is publishing @@ -992,7 +1062,11 @@ async function waitFor(signal: Getter, pred: (value: T) => boolean): Promi test("integration: announcedBroadcast waits for a late publisher", async () => { const pair = createMockTransportPair(Lite.ALPN_06_WIP); - const [client, server] = await Promise.all([connect(url, { transport: pair.client }), accept(pair.server, url)]); + const origin = new OriginProducer(); + const [client, server] = await Promise.all([ + connect(url, { transport: pair.client }), + accept(pair.server, url, { publish: origin.consume() }), + ]); // Serves every requested track with `payload`, until the broadcast closes. const serve = async (broadcast: BroadcastProducer, payload: string) => { @@ -1010,9 +1084,8 @@ test("integration: announcedBroadcast waits for a late publisher", async () => { expect(watched.active.peek()).toBeUndefined(); // The publisher arrives afterwards. - const first = new BroadcastProducer(); + const first = origin.publish(Path.from("late")); const servingFirst = serve(first, "hello"); - server.publish(Path.from("late"), first); const active = await waitFor(watched.active, (b) => b !== undefined); if (!active) throw new Error("expected an active broadcast"); @@ -1024,9 +1097,8 @@ test("integration: announcedBroadcast waits for a late publisher", async () => { await waitFor(watched.active, (b) => b === undefined); // And comes back under the same name: a fresh consumer, not the dead one. - const second = new BroadcastProducer(); + const second = origin.publish(Path.from("late")); const servingSecond = serve(second, "world"); - server.publish(Path.from("late"), second); const republished = await waitFor(watched.active, (b) => b !== undefined); if (!republished) throw new Error("expected a republished broadcast"); @@ -1046,12 +1118,13 @@ test("integration: announcedBroadcast waits for a late publisher", async () => { test("integration: announcedBroadcast consumes blind without discovery", async () => { const pair = createMockTransportPair(Lite.ALPN_06_WIP); + const origin = new OriginProducer(); const [client, server] = await Promise.all([ connect(url, { transport: pair.client, discovery: false }), - accept(pair.server, url), + accept(pair.server, url, { publish: origin.consume() }), ]); - const broadcast = new BroadcastProducer(); + const broadcast = origin.publish(Path.from("test")); const serving = (async () => { for (;;) { const req = await broadcast.requested(); @@ -1059,7 +1132,6 @@ test("integration: announcedBroadcast consumes blind without discovery", async ( req.accept().writeString("blind"); } })(); - server.publish(Path.from("test"), broadcast); // No announcement ever arrives, so waiting for one would hang. Subscribe anyway. const watched = client.announcedBroadcast(Path.from("test")); @@ -1076,7 +1148,11 @@ test("integration: announcedBroadcast consumes blind without discovery", async ( test("integration: a republish is not served from the previous generation's cache", async () => { const pair = createMockTransportPair(Lite.ALPN_06_WIP); - const [client, server] = await Promise.all([connect(url, { transport: pair.client }), accept(pair.server, url)]); + const origin = new OriginProducer(); + const [client, server] = await Promise.all([ + connect(url, { transport: pair.client }), + accept(pair.server, url, { publish: origin.consume() }), + ]); const serve = async (broadcast: BroadcastProducer, payload: string) => { for (;;) { @@ -1086,9 +1162,8 @@ test("integration: a republish is not served from the previous generation's cach } }; - const first = new BroadcastProducer(); + const first = origin.publish(Path.from("shared")); const servingFirst = serve(first, "old"); - server.publish(Path.from("shared"), first); const watched = client.announcedBroadcast(Path.from("shared")); const active = await waitFor(watched.active, (b) => b !== undefined); @@ -1106,9 +1181,8 @@ test("integration: a republish is not served from the previous generation's cach // The republish must subscribe fresh. Cloning the cached entry would resolve the previous // generation's tracks, which the wire has already reset. - const second = new BroadcastProducer(); + const second = origin.publish(Path.from("shared")); const servingSecond = serve(second, "new"); - server.publish(Path.from("shared"), second); const republished = await waitFor(watched.active, (b) => b !== undefined); if (!republished) throw new Error("expected a republished broadcast"); @@ -1124,9 +1198,10 @@ test("integration: a republish is not served from the previous generation's cach test("integration: a blind handle picks up a publisher that arrives late", async () => { const pair = createMockTransportPair(Lite.ALPN_06_WIP); + const origin = new OriginProducer(); const [client, server] = await Promise.all([ connect(url, { transport: pair.client, discovery: false }), - accept(pair.server, url), + accept(pair.server, url, { publish: origin.consume() }), ]); // Without discovery there is no announcement to wait for, so the handle consumes blind. @@ -1140,7 +1215,7 @@ test("integration: a blind handle picks up a publisher that arrives late", async expect(watched.active.peek()).toBe(blind); // So a subscribe made after the publisher finally shows up still works, on the same handle. - const producer = new BroadcastProducer(); + const producer = origin.publish(Path.from("later")); const serving = (async () => { for (;;) { const req = await producer.requested(); @@ -1148,7 +1223,6 @@ test("integration: a blind handle picks up a publisher that arrives late", async req.accept().writeString("late"); } })(); - server.publish(Path.from("later"), producer); expect(await blind.subscribe("video").readString()).toBe("late"); @@ -1161,9 +1235,10 @@ test("integration: a blind handle picks up a publisher that arrives late", async test("integration: a blind handle goes offline when the session dies", async () => { const pair = createMockTransportPair(Lite.ALPN_06_WIP); + const origin = new OriginProducer(); const [client, server] = await Promise.all([ connect(url, { transport: pair.client, discovery: false }), - accept(pair.server, url), + accept(pair.server, url, { publish: origin.consume() }), ]); const watched = client.announcedBroadcast(Path.from("whatever")); @@ -1182,9 +1257,10 @@ test("integration: a blind handle goes offline when the session dies", async () // are not: each subscriber resolves announcements its own way. These mirror the lite cases. test("integration: ietf blind handle picks up a publisher that arrives late", async () => { const pair = createMockTransportPair(""); + const origin = new OriginProducer(); const [client, server] = await Promise.all([ connect(url, { transport: pair.client, discovery: false }), - accept(pair.server, url, { version: Ietf.Version.DRAFT_14 }), + accept(pair.server, url, { version: Ietf.Version.DRAFT_14, publish: origin.consume() }), ]); const watched = client.announcedBroadcast(Path.from("later")); @@ -1195,7 +1271,7 @@ test("integration: ietf blind handle picks up a publisher that arrives late", as await expect(blind.subscribe("video").readString()).rejects.toThrow(); expect(watched.active.peek()).toBe(blind); - const producer = new BroadcastProducer(); + const producer = origin.publish(Path.from("later")); const serving = (async () => { for (;;) { const req = await producer.requested(); @@ -1203,7 +1279,6 @@ test("integration: ietf blind handle picks up a publisher that arrives late", as req.accept().writeString("ietf-late"); } })(); - server.publish(Path.from("later"), producer); expect(await blind.subscribe("video").readString()).toBe("ietf-late"); @@ -1213,3 +1288,386 @@ test("integration: ietf blind handle picks up a publisher that arrives late", as client.close(); server.close(); }); + +// --------------------------------------------------------------------------- +// Origin-fed sessions: the `subscribe` option end to end. +// --------------------------------------------------------------------------- + +/** Poll until `pred` holds, so a regression fails the test instead of hanging it. */ +async function until(pred: () => boolean): Promise { + for (let i = 0; i < 500; i++) { + if (pred()) return; + await sleep(1); + } + throw new Error("timed out waiting for condition"); +} + +async function runOriginFlow(protocol: string, version?: number) { + const pair = createMockTransportPair(protocol); + const serverOrigin = new OriginProducer(); + const clientOrigin = new OriginProducer(); + + const [client, server] = await Promise.all([ + connect(url, { transport: pair.client, subscribe: clientOrigin }), + accept(pair.server, url, { version, publish: serverOrigin.consume() }), + ]); + + // The server publishes into its origin; the wire announces it. + const broadcast = serverOrigin.publish(Path.from("test")); + const serving = (async () => { + for (;;) { + const req = await broadcast.requested(); + if (!req) break; + if (req.name !== "video") { + req.reject(new Error(`unexpected track: ${req.name}`)); + continue; + } + req.accept().writeString("hello"); + } + })(); + + // The announcement lands in the client's origin. + const reader = clientOrigin.consume(); + const announced = reader.announced(); + expect(await announced.next()).toEqual({ path: Path.from("test"), active: true }); + + // Consuming through the origin reaches the wire. + const remote = routed(reader, Path.from("test")); + if (!remote) throw new Error("expected the origin to route the broadcast"); + const track = remote.track("video").subscribe(); + expect(await track.readString()).toBe("hello"); + + // Unpublishing retracts the entry over the wire and out of the origin. + broadcast.close(); + expect(await announced.next()).toEqual({ path: Path.from("test"), active: false }); + await until(() => !reader.routes(Path.from("test"))); + + await serving; + track.close(); + remote.close(); + announced.close(); + client.close(); + server.close(); + serverOrigin.close(); + clientOrigin.close(); +} + +test("origin: discovers, consumes, and retracts over lite", async () => { + await runOriginFlow(Lite.ALPN_05); +}); + +test("origin: discovers, consumes, and retracts over ietf", async () => { + await runOriginFlow("", Ietf.Version.DRAFT_14); +}); + +test("origin: remote entries retract when the session dies, local ones survive", async () => { + const pair = createMockTransportPair(Lite.ALPN_05); + const serverOrigin = new OriginProducer(); + const clientOrigin = new OriginProducer(); + + const [client, server] = await Promise.all([ + connect(url, { transport: pair.client, subscribe: clientOrigin }), + accept(pair.server, url, { publish: serverOrigin.consume() }), + ]); + + serverOrigin.publish(Path.from("remote")); + const mine = clientOrigin.publish(Path.from("mine")); + + const reader = clientOrigin.consume(); + await until(() => reader.routes(Path.from("remote"))); + + client.close(); + server.close(); + + // The session that fed the entry is gone, so the entry goes with it. + await until(() => !reader.routes(Path.from("remote"))); + + // The local publish is not the session's to take. + const local = routed(reader, Path.from("mine")); + expect(local).toBeDefined(); + local?.close(); + + mine.close(); + serverOrigin.close(); + clientOrigin.close(); +}); + +test("origin: one origin on both directions consumes locally and never echoes", async () => { + const pair = createMockTransportPair(Lite.ALPN_05); + + // The client routes both directions through one origin, the FFI default shape. + const shared = new OriginProducer(); + // The server feeds what the client announces into its own origin, so an echo would land here. + const serverSees = new OriginProducer(); + + const [client, server] = await Promise.all([ + connect(url, { transport: pair.client, publish: shared.consume(), subscribe: shared }), + accept(pair.server, url, { publish: serverSees.consume(), subscribe: serverSees }), + ]); + + // The server announces a broadcast; it lands in the shared origin as a remote entry. + { + const remote = serverSees.publish(Path.from("from-server")); + const reader = shared.consume(); + await until(() => reader.routes(Path.from("from-server"))); + remote.close(); + } + + // The client publishes; consuming its own path through the shared origin is local, no wire. + const mine = shared.publish(Path.from("from-client")); + mine.createTrack("chat"); + const reader = shared.consume(); + const loopback = routed(reader, Path.from("from-client")); + if (!loopback) throw new Error("expected a local route"); + const track = loopback.subscribe("chat"); + expect(track).toBeDefined(); + track.close(); + loopback.close(); + + // The server sees the client's broadcast once, as its own remote entry. + const serverReader = serverSees.consume(); + await until(() => serverReader.routes(Path.from("from-client"))); + + // The critical part: the client must NOT re-announce "from-server" back. If it did, the + // server's forwarder would insert it as a remote entry in serverSees. Give the wire a + // moment, then check the only remote entry the server has is the client's own broadcast. + await sleep(50); + expect(serverReader.routes(Path.from("from-server"))).toBe(false); + + mine.close(); + client.close(); + server.close(); + shared.close(); + serverSees.close(); +}); + +test("origin: a request resolves blind on a relay without discovery", async () => { + const pair = createMockTransportPair(Lite.ALPN_05); + const serverOrigin = new OriginProducer(); + const clientOrigin = new OriginProducer(); + + const [client, server] = await Promise.all([ + // The client believes the relay lacks discovery, so no announce stream opens. + connect(url, { transport: pair.client, subscribe: clientOrigin, discovery: false }), + accept(pair.server, url, { publish: serverOrigin.consume() }), + ]); + + const broadcast = serverOrigin.publish(Path.from("blind")); + const serving = (async () => { + for (;;) { + const req = await broadcast.requested(); + if (!req) break; + req.accept().writeString("found you"); + } + })(); + + const reader = clientOrigin.consume(); + expect(reader.discovery.peek()).toBe(false); + + // Nothing announced, so the table stays empty; a request is the only way through. + expect(reader.routes(Path.from("blind"))).toBe(false); + + const request = reader.request(Path.from("blind")); + await until(() => request.active.peek() !== undefined); + + const front = request.active.peek(); + const track = front?.track("chat").subscribe(); + if (!track) throw new Error("expected a track"); + expect(await track.readString()).toBe("found you"); + + track.close(); + request.close(); + broadcast.close(); + await serving; + client.close(); + server.close(); + serverOrigin.close(); + clientOrigin.close(); +}); + +test("origin: a request is re-answered by the next session", async () => { + const original = globalThis.WebTransport; + const reconnectUrl = new URL("https://example.com/re-request"); + + const servers: { session: { close: () => void }; origin: OriginProducer }[] = []; + const stub = function StubWebTransport() { + const pair = createMockTransportPair(Lite.ALPN_05); + const serverOrigin = new OriginProducer(); + void accept(pair.server, reconnectUrl, { publish: serverOrigin.consume() }).then((session) => { + servers.push({ session, origin: serverOrigin }); + }); + return pair.client; + }; + globalThis.WebTransport = stub as unknown as typeof WebTransport; + + const clientOrigin = new OriginProducer(); + const reload = new Reload({ + enabled: true, + url: reconnectUrl, + websocket: { enabled: false }, + delay: { initial: 10, multiplier: 1, max: 10 }, + subscribe: clientOrigin, + }); + + const request = clientOrigin.request(Path.from("standing")); + + try { + await until(() => request.active.peek() !== undefined); + const first = request.active.peek(); + + // The answering session dies: the answer is withdrawn, not the request. + servers[0]?.session.close(); + await until(() => request.active.peek() === undefined); + + // The next session answers the same standing request. + await until(() => request.active.peek() !== undefined); + expect(request.active.peek()).not.toBe(first); + } finally { + request.close(); + reload.close(); + clientOrigin.close(); + globalThis.WebTransport = original; + } +}); + +test("origin: a reactive handle follows announcements, republishes, and reconnect gaps", async () => { + const pair = createMockTransportPair(Lite.ALPN_05); + const serverOrigin = new OriginProducer(); + const clientOrigin = new OriginProducer(); + + const [client, server] = await Promise.all([ + connect(url, { transport: pair.client, subscribe: clientOrigin }), + accept(pair.server, url, { publish: serverOrigin.consume() }), + ]); + + const watch = new Announce.Broadcast({ origin: clientOrigin, path: Path.from("show") }); + + // Nothing published yet: the handle waits instead of subscribing blind. + for (let i = 0; i < 5; i++) await sleep(1); + expect(watch.active.peek()).toBeUndefined(); + + // The publish resolves it through the wire. + const first = serverOrigin.publish(Path.from("show")); + await until(() => watch.active.peek() !== undefined); + const held = watch.active.peek(); + + // A republish must swap the handle to the new broadcast, not cling to the dead one. + const second = serverOrigin.publish(Path.from("show")); + await until(() => watch.active.peek() !== undefined && watch.active.peek() !== held); + + // Unpublishing takes it offline. + second.close(); + first.close(); + await until(() => watch.active.peek() === undefined); + + watch.close(); + client.close(); + server.close(); + serverOrigin.close(); + clientOrigin.close(); +}); + +test("origin: overlapping sessions carrying one path fail over", async () => { + // Two live relays announce the same broadcast into one origin, the redundant-relay + // shape (and the GOAWAY drain shape, where old and new sessions briefly overlap). + const clientOrigin = new OriginProducer(); + + const setup = async () => { + const pair = createMockTransportPair(Lite.ALPN_05); + const serverOrigin = new OriginProducer(); + const [client, server] = await Promise.all([ + connect(url, { transport: pair.client, subscribe: clientOrigin }), + accept(pair.server, url, { publish: serverOrigin.consume() }), + ]); + const broadcast = serverOrigin.publish(Path.from("redundant")); + const serving = (async () => { + for (;;) { + const req = await broadcast.requested(); + if (!req) break; + req.accept().writeString("still here"); + } + })(); + return { client, server, serverOrigin, broadcast, serving }; + }; + + const first = await setup(); + const second = await setup(); + + const reader = clientOrigin.consume(); + await until(() => reader.routes(Path.from("redundant"))); + + // The newer session dies; the older one still carries the path and must keep serving. + second.client.close(); + second.server.close(); + await sleep(50); + + const remote = routed(reader, Path.from("redundant")); + if (!remote) throw new Error("route black-holed despite a live session"); + const track = remote.track("chat").subscribe(); + expect(await track.readString()).toBe("still here"); + + track.close(); + remote.close(); + first.broadcast.close(); + await first.serving; + first.client.close(); + first.server.close(); + first.serverOrigin.close(); + second.serverOrigin.close(); + second.broadcast.close(); + clientOrigin.close(); +}); + +test("origin: a standby session re-answers a request when the answerer dies", async () => { + const clientOrigin = new OriginProducer(); + + const setup = async (payload: string) => { + const pair = createMockTransportPair(Lite.ALPN_05); + const serverOrigin = new OriginProducer(); + const [client, server] = await Promise.all([ + // No discovery: requests are the only way through. + connect(url, { transport: pair.client, subscribe: clientOrigin, discovery: false }), + accept(pair.server, url, { publish: serverOrigin.consume() }), + ]); + const broadcast = serverOrigin.publish(Path.from("blind")); + const serving = (async () => { + for (;;) { + const req = await broadcast.requested(); + if (!req) break; + req.accept().writeString(payload); + } + })(); + return { client, server, serverOrigin, broadcast, serving }; + }; + + // The first session answers the standing request; the second attaches as a standby. + const request = clientOrigin.request(Path.from("blind")); + const answerer = await setup("from answerer"); + await until(() => request.active.peek() !== undefined); + const standby = await setup("from standby"); + + // The answering session dies while the standby stays connected: the withdrawal must + // wake the standby's serving loop, not wait for a brand-new session. The handoff can + // complete within one scheduler tick, so assert the front changed rather than racing + // to observe the vacant slot. + const before = request.active.peek(); + answerer.client.close(); + answerer.server.close(); + await until(() => request.active.peek() !== undefined && request.active.peek() !== before); + + const front = request.active.peek(); + const track = front?.track("chat").subscribe(); + if (!track) throw new Error("expected a track from the standby"); + expect(await track.readString()).toBe("from standby"); + + track.close(); + request.close(); + standby.broadcast.close(); + await standby.serving; + standby.client.close(); + standby.server.close(); + standby.serverOrigin.close(); + answerer.serverOrigin.close(); + answerer.broadcast.close(); + clientOrigin.close(); +}); diff --git a/js/net/src/lite/connection.ts b/js/net/src/lite/connection.ts index 82f91e9f90..5ef0445e6a 100644 --- a/js/net/src/lite/connection.ts +++ b/js/net/src/lite/connection.ts @@ -5,6 +5,7 @@ import type { Established } from "../connection/established.ts"; import { type Probe, type Stats, transportStats } from "../connection/stats.ts"; import { type Transport, transportOf } from "../connection/transport.ts"; import { error, fromClose } from "../error.ts"; +import type { Consumer as OriginConsumer } from "../origin.ts"; import * as Path from "../path.ts"; import { type Reader, Readers, Stream, Writer } from "../stream.ts"; import { AnnounceRequest } from "./announce.ts"; @@ -37,6 +38,8 @@ export interface ConnectionProps { session?: Stream; /** Whether the relay supports broadcast discovery. Defaults to true. */ discovery?: boolean; + /** The origin whose broadcasts are served to the peer. Omit to publish nothing. */ + publish?: OriginConsumer; } /** @@ -109,7 +112,7 @@ export class Connection implements Established { * * @internal */ - constructor({ url, quic, version, session, discovery = true }: ConnectionProps) { + constructor({ url, quic, version, session, discovery = true, publish }: ConnectionProps) { this.url = url; this.#quic = quic; this.#session = session; @@ -121,7 +124,7 @@ export class Connection implements Established { this.probe = this.#probe; this.origin = randomOrigin(); - this.#publisher = new Publisher(this.#quic, this.#version, this.origin); + this.#publisher = new Publisher(this.#quic, this.#version, this.origin, publish); this.#subscriber = new Subscriber(this.#quic, this.#version, this.origin, this.#probe, this.#peerSetup); void this.#run(); @@ -166,10 +169,6 @@ export class Connection implements Established { } } - publish(path: Path.Valid, producer: broadcast.Producer) { - this.#publisher.publish(path, producer); - } - announced(prefix = Path.empty()): announce.Consumer { return this.#subscriber.announced(prefix); } @@ -207,9 +206,10 @@ export class Connection implements Established { // The browser uses WebTransport, which carries the request URI, so we advertise no // path and leave routing to the URL. We advertise probe = Report (we measure and // report bitrate over the PROBE stream, but don't actively pad the connection). - // Role stays Both: publish/consume are called after this point, so there is nothing - // to narrow yet. The origin declares our session identity so the peer can filter - // reflected announcements (lite-06 removed ANNOUNCE_REQUEST's exclude_hop for it). + // Role stays Both: the publish origin starts empty and fills later, and consume is + // called after this point, so there is nothing to narrow yet. The origin id declares + // our session identity so the peer can filter reflected announcements (lite-06 + // removed ANNOUNCE_REQUEST's exclude_hop for it). async #sendSetup(): Promise { const writer = await Writer.open(this.#quic); try { diff --git a/js/net/src/lite/publisher.test.ts b/js/net/src/lite/publisher.test.ts index b208391c20..80712407f7 100644 --- a/js/net/src/lite/publisher.test.ts +++ b/js/net/src/lite/publisher.test.ts @@ -1,7 +1,7 @@ import { expect, test } from "bun:test"; -import { Producer as BroadcastProducer } from "../broadcast.ts"; import { Producer as GroupProducer } from "../group.ts"; import { createMockTransportPair } from "../mock.ts"; +import { Producer as OriginProducer } from "../origin.ts"; import * as Path from "../path.ts"; import { Reader, Stream } from "../stream.ts"; import { Fetch } from "./fetch.ts"; @@ -16,11 +16,11 @@ import { ALPN_05, ALPN_06_WIP, Version } from "./version.ts"; // SUBSCRIBE_END boundary the publisher put on the wire. async function subscribeEnd(sequences: number[]): Promise { const pair = createMockTransportPair(ALPN_05); - const publisher = new Publisher(pair.server, Version.DRAFT_05, randomOrigin()); + const origin = new OriginProducer(); + const publisher = new Publisher(pair.server, Version.DRAFT_05, randomOrigin(), origin.consume()); - const broadcast = new BroadcastProducer(); + const broadcast = origin.publish(Path.from("test")); const track = broadcast.createTrack("video"); - publisher.publish(Path.from("test"), broadcast); const client = await Stream.open(pair.client); const server = await Stream.accept(pair.server); @@ -79,11 +79,11 @@ async function groupSendOrders(options: { priority: number; sequences: number[]; const { priority, sequences, update, ordered } = options; const groups = sequences.map((sequence) => new GroupProducer(sequence)); const pair = createMockTransportPair(ALPN_05); - const publisher = new Publisher(pair.server, Version.DRAFT_05, randomOrigin()); + const origin = new OriginProducer(); + const publisher = new Publisher(pair.server, Version.DRAFT_05, randomOrigin(), origin.consume()); - const broadcast = new BroadcastProducer(); + const broadcast = origin.publish(Path.from("test")); const track = broadcast.createTrack("video"); - publisher.publish(Path.from("test"), broadcast); const client = await Stream.open(pair.client); const server = await Stream.accept(pair.server); @@ -171,11 +171,11 @@ test("lite draft-05: a subscribe update re-ranks the whole subscription", async // is already on the wire. Otherwise (say) an active-speaker change waits for the next group. test("lite draft-05: a subscribe update re-ranks a group already on the wire", async () => { const pair = createMockTransportPair(ALPN_05); - const publisher = new Publisher(pair.server, Version.DRAFT_05, randomOrigin()); + const origin = new OriginProducer(); + const publisher = new Publisher(pair.server, Version.DRAFT_05, randomOrigin(), origin.consume()); - const broadcast = new BroadcastProducer(); + const broadcast = origin.publish(Path.from("test")); const track = broadcast.createTrack("video"); - publisher.publish(Path.from("test"), broadcast); const client = await Stream.open(pair.client); const server = await Stream.accept(pair.server); @@ -219,11 +219,11 @@ test("lite draft-05: a subscribe update re-ranks a group already on the wire", a // later changes, so an update landing in that window would otherwise be lost until the next one. test("lite draft-05: a subscribe update during the stream open still ranks the group", async () => { const pair = createMockTransportPair(ALPN_05); - const publisher = new Publisher(pair.server, Version.DRAFT_05, randomOrigin()); + const origin = new OriginProducer(); + const publisher = new Publisher(pair.server, Version.DRAFT_05, randomOrigin(), origin.consume()); - const broadcast = new BroadcastProducer(); + const broadcast = origin.publish(Path.from("test")); const track = broadcast.createTrack("video"); - publisher.publish(Path.from("test"), broadcast); // Hold the group's stream open call until the test releases it. let release: () => void = () => {}; @@ -274,11 +274,11 @@ test("lite draft-05: a subscribe update during the stream open still ranks the g test("lite draft-05: many concurrent groups share one subscription listener", async () => { const count = 120; const pair = createMockTransportPair(ALPN_05); - const publisher = new Publisher(pair.server, Version.DRAFT_05, randomOrigin()); + const origin = new OriginProducer(); + const publisher = new Publisher(pair.server, Version.DRAFT_05, randomOrigin(), origin.consume()); - const broadcast = new BroadcastProducer(); + const broadcast = origin.publish(Path.from("test")); const track = broadcast.createTrack("video"); - publisher.publish(Path.from("test"), broadcast); const client = await Stream.open(pair.client); const server = await Stream.accept(pair.server); @@ -323,11 +323,11 @@ test("lite draft-05: many concurrent groups share one subscription listener", as // request; without this the response competes with the group streams at the default order. test("lite draft-05: the fetch response ranks the publisher's own writes", async () => { const pair = createMockTransportPair(ALPN_05); - const publisher = new Publisher(pair.server, Version.DRAFT_05, randomOrigin()); + const origin = new OriginProducer(); + const publisher = new Publisher(pair.server, Version.DRAFT_05, randomOrigin(), origin.consume()); - const broadcast = new BroadcastProducer(); + const broadcast = origin.publish(Path.from("test")); const track = broadcast.createTrack("video"); - publisher.publish(Path.from("test"), broadcast); const group = new GroupProducer(7); group.writeString("hello"); @@ -414,11 +414,11 @@ async function servedSubscription( const version = options.version ?? Version.DRAFT_05; const frames = options.frames ?? ["hello"]; const pair = createMockTransportPair(version === Version.DRAFT_06 ? ALPN_06_WIP : ALPN_05); - const publisher = new Publisher(pair.server, version, randomOrigin()); + const origin = new OriginProducer(); + const publisher = new Publisher(pair.server, version, randomOrigin(), origin.consume()); - const broadcast = new BroadcastProducer(); + const broadcast = origin.publish(Path.from("test")); const track = broadcast.createTrack("video"); - publisher.publish(Path.from("test"), broadcast); const client = await Stream.open(pair.client); @@ -665,11 +665,11 @@ async function serve( bounds: { startGroup?: number; startFrame?: number; endGroup?: number; endFrame?: number }, ): Promise<{ start?: number; end?: number; served: Served[] }> { const pair = createMockTransportPair(ALPN_06_WIP); - const publisher = new Publisher(pair.server, Version.DRAFT_06, randomOrigin()); + const origin = new OriginProducer(); + const publisher = new Publisher(pair.server, Version.DRAFT_06, randomOrigin(), origin.consume()); - const broadcast = new BroadcastProducer(); + const broadcast = origin.publish(Path.from("test")); const track = broadcast.createTrack("video"); - publisher.publish(Path.from("test"), broadcast); const client = await Stream.open(pair.client); const server = await Stream.accept(pair.server); @@ -847,10 +847,10 @@ async function saturatedGroup() { return groupStream; }; - const publisher = new Publisher(pair.server, Version.DRAFT_05, randomOrigin()); - const broadcast = new BroadcastProducer(); + const origin = new OriginProducer(); + const publisher = new Publisher(pair.server, Version.DRAFT_05, randomOrigin(), origin.consume()); + const broadcast = origin.publish(Path.from("test")); const track = broadcast.createTrack("video"); - publisher.publish(Path.from("test"), broadcast); const client = await Stream.open(pair.client); const server = await Stream.accept(pair.server); diff --git a/js/net/src/lite/publisher.ts b/js/net/src/lite/publisher.ts index 74ada9e1fc..7c36f01971 100644 --- a/js/net/src/lite/publisher.ts +++ b/js/net/src/lite/publisher.ts @@ -1,7 +1,8 @@ -import { type Dispose, Signal } from "@moq/signals"; +import { type Dispose, type Getter, Signal } from "@moq/signals"; import type * as broadcast from "../broadcast.ts"; import { error, reason } from "../error.ts"; import type * as group from "../group.ts"; +import type { Consumer as OriginConsumer } from "../origin.ts"; import * as Path from "../path.ts"; import { type Stream, Writer } from "../stream.ts"; import { Timescale } from "../time.ts"; @@ -150,9 +151,10 @@ export class Publisher { // subscriptions share it, since a second getWriter on the same stream would throw. #datagramWriter?: WritableStreamDefaultWriter; - // Our published broadcasts. - // It's a signal so we can live update any announce streams. - #broadcasts = new Signal | undefined>(new Map()); + // The published broadcasts, borrowed from the origin this session serves. The origin + // outlives the session, so this is read-only here: announce streams watch it for + // changes, and closing the session leaves the broadcasts alone. + #broadcasts: Getter | undefined>; // TRACK_INFO is immutable per track, so resolve it from the application once // (via a throwaway subscribe whose info() resolves when the app calls accept) @@ -165,13 +167,15 @@ export class Publisher { * @param quic - The WebTransport session to use * @param version - Negotiated protocol version * @param origin - Origin id shared with the Subscriber + * @param publish - The origin whose broadcasts this session serves; omit to publish nothing * * @internal */ - constructor(quic: WebTransport, version: Version, origin: Origin) { + constructor(quic: WebTransport, version: Version, origin: Origin, publish?: OriginConsumer) { this.#quic = quic; this.version = version; this.origin = origin; + this.#broadcasts = publish?.broadcasts ?? new Signal(new Map()); // Grab the datagram writer up front when the transport carries datagrams (no group // fallback, so it stays undefined otherwise). One writer for all subscriptions. @@ -180,24 +184,6 @@ export class Publisher { } } - /** - * Publishes a broadcast with any associated tracks. - * @param name - The broadcast to publish - */ - publish(path: Path.Valid, broadcast: broadcast.Producer) { - this.#broadcasts.mutate((broadcasts) => { - if (!broadcasts) throw new Error("closed"); - broadcasts.set(path, broadcast); - }); - - // Remove the broadcast from the lookup when it's closed. - void broadcast.closed.then(() => { - this.#broadcasts.mutate((broadcasts) => { - broadcasts?.delete(path); - }); - }); - } - /** * Handles an announce interest message. * @param msg - The announce interest message @@ -208,17 +194,19 @@ export class Publisher { async runAnnounce(msg: AnnounceRequest, stream: Stream) { console.debug(`announce: prefix=${msg.prefix}`); - // Send initial announcements - let active = new Set(); + // Send initial announcements. Keyed by suffix, valued by the routing front, so a + // republish (a new broadcast taking the path) diffs as ended-then-active rather + // than nothing; the subscriber treats that as a restart and re-consumes. + let active = new Map(); const broadcasts = this.#broadcasts.peek(); if (!broadcasts) return; // closed - for (const name of broadcasts.keys()) { + for (const [name, front] of broadcasts) { const suffix = Path.stripPrefix(msg.prefix, name); if (suffix === null) continue; console.debug(`announce: broadcast=${name} active=true`); - active.add(suffix); + active.set(suffix, front); } // Lite06+: announce ids. Every active we send implicitly assigns the next @@ -229,14 +217,14 @@ export class Publisher { switch (this.version) { case Version.DRAFT_01: case Version.DRAFT_02: { - const init = new AnnounceInit([...active]); + const init = new AnnounceInit([...active.keys()]); await init.encode(stream.writer, this.version); break; } default: { if (!hasAnnounceOk(this.version)) { // Draft03/04: send individual Announce messages, stamping our origin as a hop. - for (const suffix of active) { + for (const suffix of active.keys()) { await encodeAnnounceBroadcast( stream.writer, { status: "active", suffix, hops: [this.origin] }, @@ -250,7 +238,7 @@ export class Publisher { // that follow; the subscriber stamps our origin onto each hop chain, so we omit it. const ok = new AnnounceOk(this.origin, active.size); await ok.encode(stream.writer, this.version); - for (const suffix of active) { + for (const suffix of active.keys()) { if (hasAnnounceId(this.version)) { announceIds.set(suffix, nextAnnounceId++); } @@ -264,7 +252,7 @@ export class Publisher { for (;;) { // TODO Make a better helper within Signals. let dispose!: Dispose; - const changed = new Promise | undefined>((resolve) => { + const changed = new Promise | undefined>((resolve) => { dispose = this.#broadcasts.changed(resolve); }); @@ -273,29 +261,20 @@ export class Publisher { dispose(); if (!broadcasts) break; - // Create a new set of active broadcasts. + // Create a new map of active broadcasts. // This is SLOW, but it's not worth optimizing because we often have just 1 broadcast anyway. - const newActive = new Set(); - for (const name of broadcasts.keys()) { + const newActive = new Map(); + for (const [name, front] of broadcasts) { const suffix = Path.stripPrefix(msg.prefix, name); if (suffix === null) continue; // Not our prefix. - newActive.add(suffix); + newActive.set(suffix, front); } - // Announce any new broadcasts. Lite05+ reports our origin once via AnnounceOk, so - // the subscriber stamps it onto each hop chain; older versions stamp it here. - for (const added of newActive.difference(active)) { - console.debug(`announce: broadcast=${added} active=true`); - const hops = hasAnnounceOk(this.version) ? [] : [this.origin]; - if (hasAnnounceId(this.version)) { - announceIds.set(added, nextAnnounceId++); - } - await encodeAnnounceBroadcast(stream.writer, { status: "active", suffix: added, hops }, this.version); - } - - // Announce any removed broadcasts. Lite06+ retracts by announce id; + // Retract removed and superseded broadcasts first, so a republish reads as + // ended-then-active (a restart) on the wire. Lite06+ retracts by announce id; // older versions repeat the path (ended announces don't need hops). - for (const removed of active.difference(newActive)) { + for (const [removed, front] of active) { + if (newActive.get(removed) === front) continue; console.debug(`announce: broadcast=${removed} active=false`); if (hasAnnounceId(this.version)) { const id = announceIds.get(removed); @@ -307,8 +286,18 @@ export class Publisher { } } - // NOTE: This is kind of a hack that won't work with a rapid UNANNOUNCE/ANNOUNCE cycle. - // However, our client doesn't do that anyway. + // Announce new and superseding broadcasts. Lite05+ reports our origin once via + // AnnounceOk, so the subscriber stamps it onto each hop chain; older versions + // stamp it here. + for (const [added, front] of newActive) { + if (active.get(added) === front) continue; + console.debug(`announce: broadcast=${added} active=true`); + const hops = hasAnnounceOk(this.version) ? [] : [this.origin]; + if (hasAnnounceId(this.version)) { + announceIds.set(added, nextAnnounceId++); + } + await encodeAnnounceBroadcast(stream.writer, { status: "active", suffix: added, hops }, this.version); + } active = newActive; } @@ -903,12 +892,8 @@ export class Publisher { } close() { - this.#broadcasts.update((broadcasts) => { - for (const broadcast of broadcasts?.values() ?? []) { - broadcast.close(); - } - return undefined; - }); + // The broadcasts belong to the origin, which outlives this session; closing here + // only drops the borrow. The peer sees the unannounce when the streams die. // Release the datagram writer's lock so the stream can be torn down. this.#datagramWriter?.releaseLock(); diff --git a/js/net/src/origin.test.ts b/js/net/src/origin.test.ts new file mode 100644 index 0000000000..3cf241507a --- /dev/null +++ b/js/net/src/origin.test.ts @@ -0,0 +1,780 @@ +import { expect, test } from "bun:test"; +import { getter } from "@moq/signals"; +import { type Consumer as BroadcastConsumer, Producer as BroadcastProducer } from "./broadcast.ts"; +import type { Consumer } from "./origin.ts"; +import { Producer } from "./origin.ts"; +import * as Path from "./path.ts"; + +async function settle() { + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +/** + * The table's route for `path` as a handle of the caller's own, or undefined when nothing + * routes it. + * + * A request is the only way to consume by path, and it resolves synchronously against the + * table, so this is the whole of the one-shot lookup the origin used to expose. Cloned + * because a request only borrows the table's front. + */ +function routed(consumer: Consumer, path: Path.Valid): BroadcastConsumer | undefined { + const request = consumer.request(path); + const front = request.active.peek()?.clone(); + request.close(); + return front; +} + +test("a published broadcast resolves by path", async () => { + const origin = new Producer(); + const consumer = origin.consume(); + + const path = Path.from("room"); + expect(consumer.routes(path)).toBe(false); + + const broadcast = origin.publish(path); + broadcast.createTrack("video"); + + const handle = routed(consumer, path); + expect(handle).toBeDefined(); + + // The handle reaches the published tracks. + const track = handle?.subscribe("video"); + expect(track).toBeDefined(); + track?.close(); + + handle?.close(); + broadcast.close(); + origin.close(); +}); + +test("closing the producer unpublishes the path", async () => { + const origin = new Producer(); + const consumer = origin.consume(); + const path = Path.from("room"); + + const broadcast = origin.publish(path); + expect(consumer.routes(path)).toBe(true); + + broadcast.close(); + await settle(); + expect(consumer.routes(path)).toBe(false); + + origin.close(); +}); + +test("a stale broadcast closing does not unpublish a republished path", async () => { + const origin = new Producer(); + const consumer = origin.consume(); + const path = Path.from("room"); + + const first = origin.publish(path); + const second = origin.publish(path); + + // The republish already superseded it, so this close must not remove the live one. + first.close(); + await settle(); + + const handle = routed(consumer, path); + expect(handle).toBeDefined(); + handle?.close(); + + second.close(); + await settle(); + expect(consumer.routes(path)).toBe(false); + + origin.close(); +}); + +test("a republish closes the superseded broadcast", async () => { + const origin = new Producer(); + const path = Path.from("room"); + + const first = origin.publish(path); + origin.publish(path); + + await settle(); + // The origin held the only handle on the first broadcast, so superseding it closed it. + expect(first.closed.peek()).not.toBeUndefined(); + + origin.close(); +}); + +test("a consumer clone keeps a superseded broadcast alive", async () => { + const origin = new Producer(); + const consumer = origin.consume(); + const path = Path.from("room"); + + const first = origin.publish(path); + const mine = routed(consumer, path); + expect(mine).toBeDefined(); + + origin.publish(path); + await settle(); + + // The application's clone holds the old broadcast open even though it is unpublished. + expect(first.closed.peek()).toBeUndefined(); + + mine?.close(); + await settle(); + expect(first.closed.peek()).not.toBeUndefined(); + + origin.close(); +}); + +test("closing the origin closes every routed broadcast", async () => { + const origin = new Producer(); + const consumer = origin.consume(); + + const a = origin.publish(Path.from("a")); + const b = origin.publish(Path.from("b")); + + const abort = new Error("shutdown"); + origin.close(abort); + + expect(origin.closed.peek()).toBe(abort); + expect(consumer.closed.peek()).toBe(abort); + expect(a.closed.peek()).toBe(abort); + expect(b.closed.peek()).toBe(abort); + + expect(consumer.routes(Path.from("a"))).toBe(false); + expect(() => origin.publish(Path.from("late"))).toThrow(); + + // Idempotent: the first close wins. + origin.close(); + expect(origin.closed.peek()).toBe(abort); +}); + +test("the table is reactive", async () => { + const origin = new Producer(); + const consumer = origin.consume(); + const path = Path.from("room"); + + const changed = consumer.broadcasts.changed(); + const broadcast = origin.publish(path); + + const table = await changed; + expect(table?.has(path)).toBe(true); + + broadcast.close(); + origin.close(); +}); + +test("announced streams the table with prefix-relative paths", async () => { + const origin = new Producer(); + const consumer = origin.consume(); + + const a = origin.publish(Path.from("room/a")); + + const announced = consumer.announced(Path.from("room")); + + // The initial state arrives first. + expect(await announced.next()).toEqual({ path: Path.from("a"), active: true }); + + // Additions under the prefix stream in; paths outside it are invisible. + const b = origin.publish(Path.from("room/b")); + origin.publish(Path.from("lobby/c")); + expect(await announced.next()).toEqual({ path: Path.from("b"), active: true }); + + // Removals retract. + b.close(); + expect(await announced.next()).toEqual({ path: Path.from("b"), active: false }); + + // The stream ends when the origin closes. + origin.close(); + expect(await announced.next()).toBeUndefined(); + + a.close(); +}); + +test("a remote entry resolves by path and retracts on dispose", async () => { + const origin = new Producer(); + const consumer = origin.consume(); + const path = Path.from("relayed"); + + // Stand in for a session's discovered broadcast. + const upstream = new BroadcastProducer(); + const dispose = origin.insertRemote(path, upstream.consume()); + + const handle = routed(consumer, path); + expect(handle).toBeDefined(); + handle?.close(); + + // Announced streams include remote entries. + const announced = consumer.announced(); + expect(await announced.next()).toEqual({ path, active: true }); + + dispose(); + expect(await announced.next()).toEqual({ path, active: false }); + expect(consumer.routes(path)).toBe(false); + + announced.close(); + upstream.close(); + origin.close(); +}); + +test("a local publish shadows a remote entry", async () => { + const origin = new Producer(); + const consumer = origin.consume(); + const path = Path.from("room"); + + const upstream = new BroadcastProducer(); + upstream.createTrack("remote-track"); + const dispose = origin.insertRemote(path, upstream.consume()); + + const local = origin.publish(path); + local.createTrack("local-track"); + + // Local wins: the handle reaches the local track, not the remote one. + const handle = routed(consumer, path); + const track = handle?.subscribe("local-track"); + expect(track).toBeDefined(); + track?.close(); + handle?.close(); + + // One path, one announcement, even though both tables route it. + const announced = consumer.announced(); + expect(await announced.next()).toEqual({ path, active: true }); + + // Dropping the local publish falls back to the remote entry without a retraction. + local.close(); + const back = routed(consumer, path); + expect(back).toBeDefined(); + back?.close(); + + announced.close(); + dispose(); + upstream.close(); + origin.close(); +}); + +test("the publisher-facing table excludes remote entries", async () => { + const origin = new Producer(); + const consumer = origin.consume(); + + origin.publish(Path.from("mine")); + const upstream = new BroadcastProducer(); + origin.insertRemote(Path.from("theirs"), upstream.consume()); + + // What a session announces to a peer: local only, so a shared origin cannot echo. + const table = consumer.broadcasts.peek(); + expect(table?.has(Path.from("mine"))).toBe(true); + expect(table?.has(Path.from("theirs"))).toBe(false); + + upstream.close(); + origin.close(); +}); + +test("inserting into a closed origin releases the front", async () => { + const origin = new Producer(); + origin.close(); + + const upstream = new BroadcastProducer(); + const front = upstream.consume(); + const dispose = origin.insertRemote(Path.from("late"), front); + dispose(); + + // The origin dropped the only handle, closing the broadcast. + await settle(); + expect(upstream.closed.peek()).not.toBeUndefined(); +}); + +test("a request resolves once a front answers, and survives its withdrawal", async () => { + const origin = new Producer(); + const consumer = origin.consume(); + const path = Path.from("wanted"); + + const request = consumer.request(path); + expect(request.active.peek()).toBeUndefined(); + + // A session answers (simulated): the slot's front resolves the request. + const upstream = new BroadcastProducer(); + const slot = origin.requests.peek()?.get(path); + expect(slot).toBeDefined(); + expect(origin.answer(path, upstream.consume())).toBeDefined(); + expect(request.active.peek()).toBeDefined(); + + // A second request for the same path shares the answer, each through a handle of its + // own, so one of them closing cannot take the other's subscription down. + const again = consumer.request(path); + expect(again.active.peek()).toBeDefined(); + expect(again.active.peek()).not.toBe(request.active.peek()); + again.close(); + expect(request.active.peek()).toBeDefined(); + + // The last close withdraws the request and releases the front, a microtask later so an + // effect rerun can re-acquire the slot without dropping the answer. + request.close(); + await settle(); + expect(origin.requests.peek()?.has(path)).toBe(false); + expect(upstream.closed.peek()).not.toBeUndefined(); + + origin.close(); +}); + +test("a request closed and retaken in the same tick keeps its answer", async () => { + const origin = new Producer(); + const consumer = origin.consume(); + const path = Path.from("stable"); + + const first = consumer.request(path); + const upstream = new BroadcastProducer(); + const withdraw = origin.answer(path, upstream.consume()); + expect(withdraw).toBeDefined(); + const front = first.active.peek(); + expect(front).toBeDefined(); + + // The pattern an effect produces when its rerun was triggered by the answer resolving: + // cleanup closes the old request, the rerun takes a new one, all in one tick. The + // answer must survive, or the subscription flaps and is re-dialed forever. + first.close(); + const second = consumer.request(path); + await settle(); + await settle(); + + // A handle of the new request's own, but the same answer underneath: the slot kept it, + // so the subscription was never re-dialed. + expect(second.active.peek()).toBeDefined(); + expect(upstream.closed.peek()).toBeUndefined(); + + second.close(); + origin.close(); +}); + +test("disposing the newest remote route promotes the fallback", async () => { + const origin = new Producer(); + const consumer = origin.consume(); + const path = Path.from("redundant"); + + // Two sessions announced the same path; the older one is still alive when the newer + // one goes away, so the route must fail over rather than black-hole. + const older = new BroadcastProducer(); + older.createTrack("chat"); + const disposeOlder = origin.insertRemote(path, older.consume()); + + const newer = new BroadcastProducer(); + const disposeNewer = origin.insertRemote(path, newer.consume()); + + const announced = consumer.announced(); + expect(await announced.next()).toEqual({ path, active: true }); + + // The newer session dies: consumers see a retract then the promoted fallback. + disposeNewer(); + expect(await announced.next()).toEqual({ path, active: false }); + expect(await announced.next()).toEqual({ path, active: true }); + + const handle = routed(consumer, path); + const track = handle?.subscribe("chat"); + expect(track).toBeDefined(); + track?.close(); + handle?.close(); + + // The older source was never the origin's to close. + expect(older.closed.peek()).toBeUndefined(); + + disposeOlder(); + await settle(); + expect(consumer.routes(path)).toBe(false); + + announced.close(); + origin.close(); +}); + +test("withdrawing an answer wakes the requests table", async () => { + const origin = new Producer(); + const consumer = origin.consume(); + const path = Path.from("handoff"); + + const request = consumer.request(path); + + const first = new BroadcastProducer(); + const withdraw = origin.answer(path, first.consume()); + expect(withdraw).toBeDefined(); + + // A second answer while one stands must lose and stay eligible. + const second = new BroadcastProducer(); + expect(origin.answer(path, second.consume())).toBeUndefined(); + + // Withdrawing pokes the requests map, which is what a standby serving loop sleeps on. + const woken = origin.requests.changed(); + withdraw?.(); + await woken; + expect(request.active.peek()).toBeUndefined(); + + // The slot is vacant again, so a standby answers. + const third = new BroadcastProducer(); + expect(origin.answer(path, third.consume())).toBeDefined(); + expect(request.active.peek()).toBeDefined(); + + request.close(); + origin.close(); +}); + +test("requests never appear in announced or the table", async () => { + const origin = new Producer(); + const consumer = origin.consume(); + const path = Path.from("assumed"); + + const request = consumer.request(path); + const upstream = new BroadcastProducer(); + origin.answer(path, upstream.consume()); + + // An answered request is assumed present, not known live, so it is not availability: it + // stays out of the table, and out of the announcements the table drives. + expect(request.active.peek()).toBeDefined(); + expect(origin.routes(path)).toBe(false); + expect(consumer.broadcasts.peek()?.has(path)).toBe(false); + + const announced = consumer.announced(); + origin.publish(Path.from("real")); + expect(await announced.next()).toEqual({ path: Path.from("real"), active: true }); + + announced.close(); + request.close(); + origin.close(); +}); + +test("a republish retracts then re-announces the path", async () => { + const origin = new Producer(); + const consumer = origin.consume(); + const path = Path.from("room"); + + origin.publish(path); + const announced = consumer.announced(); + expect(await announced.next()).toEqual({ path, active: true }); + + // A new broadcast takes the path: consumers must let go of the superseded one. + origin.publish(path); + expect(await announced.next()).toEqual({ path, active: false }); + expect(await announced.next()).toEqual({ path, active: true }); + + announced.close(); + origin.close(); +}); + +test("the one-shot lookup is off the published surface", () => { + const origin = new Producer(); + const consumer = origin.consume(); + + // `get` was a snapshot that raced a republish, and `request` replaced it. Neither handle + // may still carry it: an @internal tag would keep it in the emitted declarations, so the + // method has to be gone rather than merely undocumented. + expect("get" in consumer).toBe(false); + expect("get" in origin).toBe(false); + + origin.close(); +}); + +test("a routed path needs no blind answer", async () => { + const origin = new Producer(); + const consumer = origin.consume(); + const path = Path.from("announced"); + + // What a serving session scans: a request on a path the table routes resolves to that + // route, so answering it blind would park a handle nothing reads. + const upstream = new BroadcastProducer(); + const dispose = origin.insertRemote(path, upstream.consume()); + + const request = consumer.request(path); + expect(origin.routes(path)).toBe(true); + expect(request.active.peek()).toBeDefined(); + + // The route going away is what makes the request need an answer, so the serving loop has + // to wake on the table, not just on the requests map. + const woken = origin.changed(); + dispose(); + await woken; + expect(origin.routes(path)).toBe(false); + expect(request.active.peek()).toBeUndefined(); + + request.close(); + upstream.close(); + origin.close(); +}); + +test("discovery reflects the attached sessions", async () => { + const origin = new Producer(); + const consumer = origin.consume(); + + expect(consumer.discovery.peek()).toBeUndefined(); + + const blind = origin.attach(false); + expect(consumer.discovery.peek()).toBe(false); + + // Mixed: the table cannot be complete while one session announces nothing, so a consumer + // gated on this has to keep asking rather than trusting the announcements. + const seeing = origin.attach(true); + expect(consumer.discovery.peek()).toBe(false); + + blind(); + expect(consumer.discovery.peek()).toBe(true); + seeing(); + expect(consumer.discovery.peek()).toBeUndefined(); + + origin.close(); +}); + +test("the exposed getters are wirable as component inputs", () => { + const origin = new Producer(); + const path = Path.from("wired"); + + // getter() rejects a readable it did not create, so a hand-rolled object here would + // throw the moment a consumer wired discovery or a request into a component. + expect(() => getter(origin.discovery)).not.toThrow(); + + const request = origin.request(path); + expect(() => getter(request.active)).not.toThrow(); + + request.close(); + origin.close(); +}); + +test("closing what a request resolved leaves the path published for everyone else", async () => { + const origin = new Producer(); + const path = Path.from("mine"); + + const producer = origin.publish(path); + const first = origin.request(path); + const second = origin.request(path); + + const mine = first.active.peek(); + expect(mine).toBeDefined(); + // A handle of the request's own, not the table's front. + expect(mine).not.toBe(second.active.peek()); + + // The ordinary thing a caller does with a consumer they were handed. It must not reach + // through to the table's handle and take the broadcast down with it. + mine?.close(); + await settle(); + + expect(producer.closed.peek()).toBeUndefined(); + expect(second.active.peek()?.closed.peek()).toBeUndefined(); + + // A later request still resolves it too. + const third = origin.request(path); + expect(third.active.peek()).toBeDefined(); + expect(third.active.peek()?.closed.peek()).toBeUndefined(); + + first.close(); + second.close(); + third.close(); + producer.close(); + origin.close(); +}); + +test("closing a request releases the handle it was holding", async () => { + const origin = new Producer(); + const path = Path.from("mine"); + + const producer = origin.publish(path); + const request = origin.request(path); + expect(request.active.peek()).toBeDefined(); + + request.close(); + await settle(); + + // The handle is released and the view is gone, while the published broadcast, whose + // handle belongs to the table, carries on. + expect(request.active.peek()).toBeUndefined(); + expect(producer.closed.peek()).toBeUndefined(); + expect(origin.consume().routes(path)).toBe(true); + + producer.close(); + origin.close(); +}); + +test("a retracted route is retired even for a request nobody reads again", async () => { + const origin = new Producer(); + const consumer = origin.consume(); + const path = Path.from("redundant"); + + const older = new BroadcastProducer(); + const newer = new BroadcastProducer(); + const disposeOlder = origin.insertRemote(path, older.consume()); + const disposeNewer = origin.insertRemote(path, newer.consume()); + + const request = consumer.request(path); + // One read, then the holder goes quiet: a peek-only holder must not pin the route. + expect(request.active.peek()).toBeDefined(); + + // The newest route retracts and the older one is promoted. Nothing reads `active`. + disposeNewer(); + await settle(); + + expect(newer.closed.peek()).not.toBeUndefined(); + + request.close(); + disposeOlder(); + older.close(); + origin.close(); +}); + +test("a request only wakes for its own path", async () => { + const origin = new Producer(); + const consumer = origin.consume(); + const watched = Path.from("watched"); + const other = Path.from("other"); + + const request = consumer.request(watched); + let wakeups = 0; + const dispose = request.active.subscribe(() => { + wakeups += 1; + }); + + // Churn an unrelated path. Deriving each request over the whole table would wake this + // one every time, which is what makes a busy origin cost O(requests) per publish. + for (let i = 0; i < 5; i++) { + const noise = origin.publish(other); + await settle(); + noise.close(); + await settle(); + } + expect(wakeups).toBe(0); + + // Its own path still reaches it. + const mine = origin.publish(watched); + await settle(); + expect(wakeups).toBe(1); + expect(request.active.peek()).toBeDefined(); + + dispose(); + request.close(); + mine.close(); + origin.close(); +}); + +test("a request is unroutable only when nothing can answer it", async () => { + const origin = new Producer(); + const consumer = origin.consume(); + const path = Path.from("nowhere"); + + // Nothing published, nothing attached, nothing coming: waiting here is futile. + const request = consumer.request(path); + expect(request.active.peek()).toBeUndefined(); + expect(request.unroutable.peek()).toBe(true); + + // A session attaches: now the path is merely unanswered. + const detach = origin.attach(false); + await settle(); + expect(request.unroutable.peek()).toBe(false); + + // It goes back to unroutable when the session dies with nothing to replace it. + detach(); + await settle(); + expect(request.unroutable.peek()).toBe(true); + + request.close(); + origin.close(); +}); + +test("a reconnecting connection keeps requests pending across the gap", async () => { + const origin = new Producer(); + const consumer = origin.consume(); + const path = Path.from("later"); + + // What a reconnecting connection holds: no session yet, but one is coming. This is the + // page-load window, and it must not read as a missing broadcast. + const release = origin.expect(); + + const request = consumer.request(path); + expect(request.unroutable.peek()).toBe(false); + + // A session comes and goes; the expectation still covers the gap. + const detach = origin.attach(true); + await settle(); + detach(); + await settle(); + expect(request.unroutable.peek()).toBe(false); + + // Only giving up for good makes it unroutable. + release(); + await settle(); + expect(request.unroutable.peek()).toBe(true); + + request.close(); + origin.close(); +}); + +test("a routed path is never unroutable", async () => { + const origin = new Producer(); + const consumer = origin.consume(); + const path = Path.from("here"); + + const broadcast = origin.publish(path); + const request = consumer.request(path); + + // Routed with nothing attached at all: the route is the answer, so no answerer is needed. + expect(request.active.peek()).toBeDefined(); + expect(request.unroutable.peek()).toBe(false); + + // Unpublishing with nothing able to answer flips it. + broadcast.close(); + await settle(); + expect(request.active.peek()).toBeUndefined(); + expect(request.unroutable.peek()).toBe(true); + + request.close(); + origin.close(); +}); + +test("a request on a closed origin is unroutable", () => { + const origin = new Producer(); + const consumer = origin.consume(); + origin.close(); + + const request = consumer.request(Path.from("gone")); + expect(request.active.peek()).toBeUndefined(); + expect(request.unroutable.peek()).toBe(true); + request.close(); +}); + +test("a seeded route still notifies when it retracts", async () => { + const origin = new Producer(); + const path = Path.from("seeded"); + + // Routed before the request exists, so the request is seeded rather than notified into + // its first value. A silent seed leaves the pre-seed value as the baseline the next + // change is compared against, which makes this retraction look like no change at all. + const upstream = new BroadcastProducer(); + const dispose = origin.insertRemote(path, upstream.consume()); + + const request = origin.consume().request(path); + expect(request.active.peek()).toBeDefined(); + + let wakeups = 0; + const stop = request.active.subscribe(() => { + wakeups += 1; + }); + + dispose(); + await settle(); + + expect(wakeups).toBeGreaterThan(0); + expect(request.active.peek()).toBeUndefined(); + + stop(); + request.close(); + upstream.close(); + origin.close(); +}); + +test("closing the origin makes an existing request unroutable", async () => { + const origin = new Producer(); + const path = Path.from("doomed"); + + const detach = origin.attach(true); + const request = origin.consume().request(path); + expect(request.unroutable.peek()).toBe(false); + + // The session is still attached, but a closed origin can never answer through it. + origin.close(); + await settle(); + expect(request.unroutable.peek()).toBe(true); + + // The attached session releasing afterwards must not drive the count below zero and + // resurrect the idea that something can answer. + detach(); + await settle(); + expect(request.unroutable.peek()).toBe(true); + + request.close(); +}); diff --git a/js/net/src/origin.ts b/js/net/src/origin.ts new file mode 100644 index 0000000000..a561425a47 --- /dev/null +++ b/js/net/src/origin.ts @@ -0,0 +1,682 @@ +/** + * A broadcast routing table, independent of any connection. + * + * Publish broadcasts into an origin and hand the origin to one or more connections to + * serve them; the broadcasts outlive any single session. Hand the same (or another) + * origin to a connection's `subscribe` option and the peer's announced broadcasts appear + * in the table too, consumable by path. Mirrors the `origin` module in `rs/moq-net`. + * + * @module + */ +import { Derived, type Dispose, type GetPromise, type Getter, getter, Once, Signal } from "@moq/signals"; +import * as announce from "./announced.ts"; +import * as broadcast from "./broadcast.ts"; +import * as Path from "./path.ts"; + +/** + * One requested path: the notify node for everything watching it. + * + * `route` is the only reactive part, and the only thing a {@link Request} subscribes to, so + * a publish or retraction anywhere else in the table cannot wake it. The origin's tables stay + * the storage; this is a per-path view onto them, refreshed by whichever mutator touched the + * path. The alternative, deriving each request over the whole `local`/`remote` maps, wakes + * every open request on every unrelated change. + * + * `answer` outlives a session: the answering session clears it when it dies and the next one + * answers again, which is what makes a request span reconnects. + * + * @internal + */ +export interface RequestSlot { + count: number; + answer?: broadcast.Consumer; + readonly route: Signal; +} + +/** Reactive backing state shared by origin producers and consumers. */ +class OriginState { + // Both tables hold consumer fronts, so the application producing into the origin and + // the connections serving or feeding it stay decoupled. Undefined once the origin + // closes, so late writes fail loudly. + // + // Local is what this endpoint publishes; sessions announce and serve it. Remote is + // what sessions feeding the origin discovered; an entry dies with the session that + // inserted it. They are separate maps so a session can never announce a remote entry + // back to a peer, which is what makes an origin shared by both directions echo-free. + // + // Remote keeps every session's front per path, newest first: [0] is the route consumers + // resolve, and removing it promotes the next, so a session dying does not black-hole a + // path another live session still carries (which would stay dark forever, since that + // session already announced it and will not again). + local = new Signal | undefined>(new Map()); + remote = new Signal | undefined>(new Map()); + + // Paths consumers asked for without waiting for an announcement; attached sessions + // answer them with blind subscriptions. Never announced: an answered request is assumed + // present, not known live, so it must not read as an availability claim. + requests = new Signal | undefined>(new Map()); + + // How many sessions are attached, and how many of those support broadcast discovery. + // What backs the public `discovery` getter. + sessions = new Signal({ total: 0, discovery: 0 }); + + // How many things are prepared to answer a request: attached sessions, plus reconnecting + // connections that have no session right now but will. Zero means an unrouted path is + // unroutable rather than merely unanswered, which is the whole difference between "wait, + // this is coming" and "nothing here can ever serve you". + answerers = new Signal(0); + + closed = new Once(); + + /** + * Recompute what `path` resolves to, waking only the requests watching that path. + * + * A no-op for a path nobody requested, so the common case (publishing into a table + * nobody is asking about) costs a map lookup. Call after any write that could change + * the answer for a single path. + */ + refresh(path: Path.Valid): void { + const slot = this.requests.peek()?.get(path); + if (!slot) return; + slot.route.set(this.route(path, slot.answer)); + } + + /** What `path` resolves to: the table's route when it has one, else the blind answer. */ + route(path: Path.Valid, answer?: broadcast.Consumer): broadcast.Consumer | undefined { + return this.local.peek()?.get(path) ?? this.remote.peek()?.get(path)?.[0] ?? answer; + } +} + +/** + * A non-owning handle on an origin: publish into it and read it, without its lifecycle. + * + * What a shared connection lends out. {@link Producer} implements it, so code that is + * handed an origin rather than owning one should accept this type: closing the origin + * stays the owner's alone, and a borrower cannot express it. + * + * @public + */ +export interface Table { + /** Settles once the origin closes; see {@link Producer.closed}. */ + readonly closed: GetPromise; + + /** Whether every attached session announces into the table; see {@link Consumer.discovery}. */ + readonly discovery: Getter; + + /** Publish a broadcast at `path`, returning its producer; see {@link Producer.publish}. */ + publish(path: Path.Valid): broadcast.Producer; + + /** Resolve `path`, without waiting for an announcement; see {@link Consumer.request}. */ + request(path: Path.Valid): Request; + + /** The available broadcasts under `prefix`, as a live stream; see {@link Consumer.announced}. */ + announced(prefix?: Path.Valid): announce.Consumer; +} + +/** + * The write side of an origin: publish broadcasts by path. + * + * Independent of any connection. A connection given this origin (via its `publish` option) + * announces and serves the table's broadcasts for as long as the session lasts; the + * broadcasts themselves live until their producer closes or {@link close} tears the origin + * down. A reconnecting session re-announces the table on each attach, so publishes made + * while offline surface on the next connection. + * + * @public + */ +export class Producer implements Table { + #state = new OriginState(); + + // The reader backing the passthroughs, so holding a Producer never requires the + // consume().x() stutter for everyday reads. One instance, so `discovery` keeps its + // identity across reads. + #reader = makeConsumer(this.#state); + + /** + * Settles once the origin closes: `null` on a clean close, or the abort {@link Error}. + * Peek it synchronously (`undefined` while open), observe it reactively, or `await` it. + */ + get closed(): GetPromise { + return this.#state.closed; + } + + /** + * Publish a broadcast at `path`, returning its producer. + * + * Close the producer to unpublish. Publishing a path again supersedes the previous + * broadcast: the origin drops its handle on the old one, which closes it unless the + * application still holds a consumer clone. A local publish also shadows any remote + * broadcast at the same path. + */ + publish(path: Path.Valid): broadcast.Producer { + const producer = new broadcast.Producer(); + const front = producer.consume(); + + this.#state.local.mutate((broadcasts) => { + if (!broadcasts) throw new Error("origin is closed"); + broadcasts.get(path)?.close(); + broadcasts.set(path, front); + }); + this.#state.refresh(path); + + // Unpublish when the broadcast closes, unless a republish already replaced it: a + // stale broadcast closing must not unpublish the live one. + void front.closed.then(() => { + this.#state.local.mutate((broadcasts) => { + if (broadcasts?.get(path) === front) broadcasts.delete(path); + }); + this.#state.refresh(path); + }); + + return producer; + } + + /** + * Insert a broadcast discovered by a session, taking ownership of `front`. + * + * The newest insertion becomes the route consumers resolve; earlier ones are kept as + * fallbacks and promoted when it goes away, so overlapping sessions carrying the same + * path fail over instead of black-holing it. The returned dispose retracts this front + * (whichever position it holds) and releases it; call it when the announcement ends or + * the session dies. Inserting into a closed origin releases the front immediately and + * retracts nothing. + * + * @internal + */ + insertRemote(path: Path.Valid, front: broadcast.Consumer): Dispose { + let closed = false; + this.#state.remote.mutate((broadcasts) => { + if (!broadcasts) { + closed = true; + return; + } + const fronts = broadcasts.get(path); + if (fronts) fronts.unshift(front); + else broadcasts.set(path, [front]); + }); + if (closed) { + front.close(); + return () => {}; + } + this.#state.refresh(path); + + return () => { + this.#state.remote.mutate((broadcasts) => { + const fronts = broadcasts?.get(path); + if (!fronts) return; + const index = fronts.indexOf(front); + if (index < 0) return; + fronts.splice(index, 1); + if (fronts.length === 0) broadcasts?.delete(path); + }); + this.#state.refresh(path); + front.close(); + }; + } + + /** + * Register an attached session, counting it toward the `discovery` state. Returns the + * detach; call it exactly once when the session dies. + * + * @internal + */ + attach(discovery: boolean): Dispose { + this.#sessions(1, discovery); + const release = this.expect(); + let detached = false; + return () => { + if (detached) return; + detached = true; + this.#sessions(-1, discovery); + release(); + }; + } + + #sessions(delta: number, discovery: boolean): void { + this.#state.sessions.update(({ total, discovery: d }) => ({ + total: total + delta, + discovery: d + (discovery ? delta : 0), + })); + } + + /** + * Declare that something will answer requests on this origin, even with no session + * attached right now. + * + * A reconnecting connection holds one for its whole life, so a request made during a + * reconnect (or before the first session establishes) stays pending instead of reading as + * unroutable. Without it, {@link Request.unroutable} would fire on every page load, in the + * window between wiring the origin up and the handshake completing. Call the returned + * dispose when the connection is done for good. + * + * @internal + */ + expect(): Dispose { + this.#state.answerers.update((count) => count + 1); + let released = false; + return () => { + if (released) return; + released = true; + // Clamped because closing the origin zeroes the count, and the sessions attached at + // the time still release afterwards. + this.#state.answerers.update((count) => Math.max(0, count - 1)); + }; + } + + /** + * The open requests, watched by attached sessions to answer them; see + * {@link Consumer.request}. Undefined once the origin closes. + * + * @internal + */ + get requests(): Getter | undefined> { + return this.#state.requests; + } + + /** + * Resolves once anything a serving session scans changes: the open requests, or either + * side of the routing table. + * + * @internal + */ + changed(): Promise { + return Signal.race(this.#state.requests, this.#state.local, this.#state.remote); + } + + /** + * Provide `front` as the answer for the open request on `path`, taking ownership of it. + * + * Returns undefined (releasing the front) when the request is gone or already answered; + * first session in wins, and a loser must stay eligible to answer later. The returned + * withdraw releases the front and, if it was the standing answer, vacates the slot and + * wakes the other serving loops so a standby session answers immediately; call it when + * the session dies. + * + * @internal + */ + answer(path: Path.Valid, front: broadcast.Consumer): Dispose | undefined { + const slot = this.#state.requests.peek()?.get(path); + if (!slot || slot.answer !== undefined) { + front.close(); + return undefined; + } + slot.answer = front; + this.#state.refresh(path); + + return () => { + if (slot.answer === front) { + slot.answer = undefined; + this.#state.refresh(path); + // The route signal only reaches this path's requesters; poke the map so every + // serving loop re-scans and one of them re-answers. + this.#state.requests.mutate(() => {}); + } + front.close(); + }; + } + + /** A read handle for this origin, the side a connection's `publish` option borrows. */ + consume(): Consumer { + return makeConsumer(this.#state); + } + + /** Whether every attached session announces into the table; see {@link Consumer.discovery}. */ + get discovery(): Getter { + return this.#reader.discovery; + } + + /** Resolve `path`, without waiting for an announcement; see {@link Consumer.request}. */ + request(path: Path.Valid): Request { + return this.#reader.request(path); + } + + /** Whether the table routes `path` itself; see {@link Consumer.routes}. @internal */ + routes(path: Path.Valid): boolean { + return this.#reader.routes(path); + } + + /** The available broadcasts under `prefix`, as a live stream; see {@link Consumer.announced}. */ + announced(prefix?: Path.Valid): announce.Consumer { + return this.#reader.announced(prefix); + } + + /** Close the origin, every broadcast it still routes, and its announcement streams. Idempotent. */ + close(abort?: Error) { + if (this.#state.closed.peek() !== undefined) return; + this.#state.closed.set(abort ?? null); + this.#state.local.update((broadcasts) => { + for (const front of broadcasts?.values() ?? []) { + front.close(abort); + } + return undefined; + }); + this.#state.remote.update((broadcasts) => { + // Remote broadcasts are somebody else's; only release our handles on them. + for (const fronts of broadcasts?.values() ?? []) { + for (const front of fronts) front.close(); + } + return undefined; + }); + // Nothing will answer a request on a closed origin, whatever is still attached, so + // existing requests report unroutable rather than waiting on a corpse. + this.#state.answerers.set(0); + this.#state.requests.update((requests) => { + for (const slot of requests?.values() ?? []) { + slot.answer?.close(); + slot.answer = undefined; + slot.route.set(undefined); + } + return undefined; + }); + } +} + +// Constructs a Consumer from within this module without exposing a public constructor +// that would leak the unexported OriginState. Assigned in the class's static block. +let makeConsumer: (state: OriginState) => Consumer; + +// Same for Request: a public constructor would let a caller forge a handle that no origin +// ever registered, whose lifecycle guarantees are then false. `@internal` alone would not +// stop it, since the declaration emit keeps the constructor. +let makeRequest: ( + path: Path.Valid, + active: Getter, + unroutable: Getter, + dispose: Dispose, +) => Request; + +/** + * An open request for a path nothing announced; see {@link Consumer.request}. + * + * @public + */ +export class Request { + /** The requested path. */ + readonly path: Path.Valid; + + /** + * The resolved broadcast, or undefined while nothing provides the path. + * + * The table's route when it has one: a local publish (no round trip) or an announced + * broadcast, swapping when a republish takes the path. Otherwise a session's blind + * answer, which is assumed present rather than known live: a missing broadcast + * surfaces as a reset on the first track subscription, not here. Drops back to + * undefined when the providing route dies and resolves again when another appears. + * + * Yours for as long as the request is open: it is a handle of this request's own, so + * closing it ends your view of the path rather than the route everyone else reads. + * {@link close} releases whatever is current. + */ + readonly active: Getter; + + /** + * Whether nothing can serve this path, as opposed to not having served it yet. + * + * True when the origin routes nothing here and nothing is prepared to answer: no session + * attached and no connection reconnecting toward one. False whenever {@link active} is + * set, and false while a connection is still coming up, so the ordinary page-load window + * before the first handshake reads as pending rather than as a missing broadcast. Waiting + * on this is futile by definition; wait for an announcement instead, via the origin's + * `announced`. + */ + readonly unroutable: Getter; + + #dispose: Dispose; + #closed = false; + + private constructor( + path: Path.Valid, + active: Getter, + unroutable: Getter, + dispose: Dispose, + ) { + this.path = path; + this.active = active; + this.unroutable = unroutable; + this.#dispose = dispose; + } + + static { + makeRequest = (path, active, unroutable, dispose) => new Request(path, active, unroutable, dispose); + } + + /** Withdraw the request. The path stays routed for any other open request. Idempotent. */ + close(): void { + if (this.#closed) return; + this.#closed = true; + this.#dispose(); + } +} + +/** + * The read side of an origin: resolve broadcasts by path and watch what is available. + * + * Obtain one from {@link Producer.consume}. Pass it to a connection's `publish` option to + * serve the origin's local broadcasts to that peer; read it directly to consume anything + * the origin routes, locally published or discovered by a session. + * + * @public + */ +export class Consumer { + #state: OriginState; + + private constructor(state: OriginState) { + this.#state = state; + // True only when every attached session announces. One session that cannot means the + // table is an incomplete picture, so a consumer gated on it has to keep its blind + // fallback: the paths only that session carries never reach the table at all. + this.#discovery = new Derived([state.sessions], ({ total, discovery }) => + total === 0 ? undefined : discovery === total, + ); + } + + static { + makeConsumer = (state) => new Consumer(state); + } + + /** Settles once the origin closes; see {@link Producer.closed}. */ + get closed(): GetPromise { + return this.#state.closed; + } + + /** + * Whether the announcement table sees everything the attached sessions can serve. + * + * Undefined while no session is attached (nothing is known yet), true when every attached + * session announces into the table, and false as soon as one does not, where + * {@link announced} cannot be complete and consumers should {@link request} paths instead + * of waiting. One blind session among several is still false: the paths only it carries + * never reach the table, so a consumer that trusted the gate would never see them. + */ + get discovery(): Getter { + return this.#discovery; + } + + // Derived per access rather than cached: a lightweight mapped view over the session + // counts, avoiding a Computed's lifecycle. + readonly #discovery: Getter; + + /** + * Whether the table routes `path` itself, by a local publish or a session's announcement. + * + * Availability, not a handle: {@link request} is the only way to consume by path. A + * request on a routed path resolves to that route and never to a blind answer, which is + * why a serving session leaves it alone. + * + * @internal + */ + routes(path: Path.Valid): boolean { + if (this.#state.local.peek()?.has(path)) return true; + return (this.#state.remote.peek()?.get(path)?.length ?? 0) > 0; + } + + /** + * Resolve `path`, without waiting for an announcement. + * + * The one way to consume by path. {@link Request.active} follows whatever the table + * routes (a local publish, or any feeding session's announcement, swapping on a + * republish); when nothing does, the request stands and whichever attached session + * answers first provides a blind subscription instead, re-answered across reconnects. + * Close the request when done. On a closed origin it never resolves. + * + * With several sessions on one origin the first to answer wins, and it may be one that + * does not carry the path. Nothing corrects that: a missing broadcast surfaces as a reset + * on the first track and deliberately leaves the handle open, since the wire cannot tell + * "not here" from "not yet" and a blind handle is expected to survive until a publisher + * arrives. It matters only on an origin mixing sessions that announce with sessions that + * cannot, where a path only the silent session carries may sit behind another session's + * answer. Prefer {@link unroutable} and announcements over blind requests when the origin + * feeds from more than one connection. + */ + request(path: Path.Valid): Request { + const requests = this.#state.requests.peek(); + if (!requests) { + // Closed origin: a request that can never resolve, and says so. + return makeRequest(path, new Signal(undefined), getter(true), () => {}); + } + + let slot = requests.get(path); + if (!slot) { + // Seeded through the constructor, so a path the table already routes resolves on the + // first read. It must not go through a silent set: that still captures the pre-seed + // value as the baseline the next change is compared against, and never flushes to + // clear it, so a seeded route retracting to undefined would look like no change and + // notify nobody. + const created: RequestSlot = { count: 0, route: new Signal(this.#state.route(path)) }; + slot = created; + this.#state.requests.mutate((map) => { + map?.set(path, created); + }); + } + slot.count += 1; + + // Hand out a handle of the request's own rather than the table's. Closing a consumer + // closes the broadcast once it was the last one, and the table often holds the only + // other handle, so lending its front out means an ordinary close() by one requester + // can unpublish the path for everybody else. + const taken = slot; + + // Memoized on the route's identity: the same front resolving again returns the handle + // we already made, and only a real swap clones a new one (cloning before closing the + // old, so a broadcast that both routes share never briefly loses its last handle). + let released = false; + let source: broadcast.Consumer | undefined; + let handle: broadcast.Consumer | undefined; + const own = (front: broadcast.Consumer | undefined): broadcast.Consumer | undefined => { + if (released) return undefined; + if (front !== source) { + const previous = handle; + source = front; + handle = front?.clone(); + previous?.close(); + } + return handle; + }; + + const route = taken.route; + const active = new Derived([route], own); + + // Swapping on the read is what keeps a routed path resolving synchronously, but a + // holder that only ever peeked would then pin a route that has already been retracted + // until it happened to read again. Following the route as well retires it promptly, + // and the memo makes the two paths agree: whichever runs first does the swap. + const unsubscribe = route.subscribe(own); + + // Only meaningful while nothing is routed, so it reads the route rather than `active`: + // the two cannot disagree, since a routed path always has an answerer-independent + // answer. + const unroutable = new Derived([route, this.#state.answerers], (front, answerers) => !front && answerers === 0); + + return makeRequest(path, active, unroutable, () => { + // Releases this request's handle; the route itself belongs to the table. + released = true; + unsubscribe(); + handle?.close(); + handle = undefined; + source = undefined; + + taken.count -= 1; + if (taken.count > 0) return; + + // Defer the teardown a microtask: an effect whose rerun was triggered by the + // answer resolving closes its old request and takes a new one in the same tick, + // and tearing down in between would drop the answer it is about to read. + queueMicrotask(() => { + if (taken.count > 0) return; + this.#state.requests.mutate((map) => { + if (map?.get(path) === taken) map.delete(path); + }); + taken.answer?.close(); + taken.answer = undefined; + taken.route.set(undefined); + }); + }); + } + + /** + * The available broadcasts under `prefix`, as a live stream: everything currently + * routed arrives first as `active`, then additions and removals as they happen. Paths + * are relative to `prefix`. The stream ends when the origin closes or the consumer is + * closed. + */ + announced(prefix: Path.Valid = Path.empty()): announce.Consumer { + const producer = new announce.Producer(prefix); + void this.#runAnnounced(producer, prefix); + return producer.consume(); + } + + async #runAnnounced(producer: announce.Producer, prefix: Path.Valid): Promise { + // Keyed by suffix, valued by the routing front. Diffing identity rather than mere + // presence means a republish (a new broadcast taking the path) emits a retraction + // then a fresh announcement, so a consumer re-consumes instead of clinging to the + // superseded broadcast. + let active = new Map(); + + try { + for (;;) { + const local = this.#state.local.peek(); + const remote = this.#state.remote.peek(); + if (local === undefined && remote === undefined) break; + + const next = new Map(); + // Remote first, so a local publish at the same path overwrites it: the + // announcement points at whatever consume() would resolve. + for (const [path, fronts] of remote ?? []) { + const suffix = Path.stripPrefix(prefix, path); + if (suffix !== null && fronts[0]) next.set(suffix, fronts[0]); + } + for (const [path, front] of local ?? []) { + const suffix = Path.stripPrefix(prefix, path); + if (suffix !== null) next.set(suffix, front); + } + + for (const [path, front] of active) { + if (next.get(path) !== front) producer.append({ path, active: false }); + } + for (const [path, front] of next) { + if (active.get(path) !== front) producer.append({ path, active: true }); + } + active = next; + + await Signal.race(this.#state.local, this.#state.remote, producer.closed); + if (producer.closed.peek() !== undefined) return; + } + } catch { + // The reader closed between the check and an append; nothing left to do. + } + producer.close(); + } + + /** + * The local table, borrowed by the wire publishers to answer announces and subscribes. + * + * Deliberately excludes remote entries: a session never re-announces what a peer told + * it, so an origin wired to both directions of a connection cannot echo. Borrowed, not + * owned: do not close the fronts. Undefined once the origin closes. + * + * @internal + */ + get broadcasts(): Getter | undefined> { + return this.#state.local; + } +} diff --git a/js/publish/src/broadcast.test.ts b/js/publish/src/broadcast.test.ts index f837dc3e69..79c413f612 100644 --- a/js/publish/src/broadcast.test.ts +++ b/js/publish/src/broadcast.test.ts @@ -1,14 +1,10 @@ import { expect, test } from "bun:test"; import * as Catalog from "@moq/hang/catalog"; import * as Json from "@moq/json"; -import { type Connection, Path, Track } from "@moq/net"; +import { Origin, Path, Track } from "@moq/net"; import { Effect } from "@moq/signals"; import { Broadcast } from "./broadcast.ts"; -// The broadcast only opens its network producer once it has a connection, so tests that drive the -// request loop hand it a stub whose publish() is a no-op; the internal producer is exposed via `net`. -const stubConnection = () => ({ publish() {} }) as unknown as Connection.Established; - // Effects and signal writes coalesce onto microtasks, so a chain of registration -> config -> catalog // needs a few flushes to settle. const flush = () => new Promise((resolve) => queueMicrotask(resolve)); @@ -108,7 +104,7 @@ test("rendition.close() unregisters the name and drops it from the catalog", asy }); test("serving a subscription hands the producer to the rendition and clears it when the track closes", async () => { - const broadcast = new Broadcast({ enabled: true, connection: stubConnection(), name: Path.from("test.hang") }); + const broadcast = new Broadcast({ enabled: true, origin: new Origin.Producer(), name: Path.from("test.hang") }); await settle(); const net = broadcast.net.peek(); @@ -133,7 +129,7 @@ test("serving a subscription hands the producer to the rendition and clears it w }); test("serves the catalog through the request loop and releases the scope when the subscriber leaves", async () => { - const broadcast = new Broadcast({ enabled: true, connection: stubConnection(), name: Path.from("test.hang") }); + const broadcast = new Broadcast({ enabled: true, origin: new Origin.Producer(), name: Path.from("test.hang") }); broadcast.video("video").config.set(videoConfig); await settle(); diff --git a/js/publish/src/broadcast.ts b/js/publish/src/broadcast.ts index 3a526bbd50..cfff9d2b78 100644 --- a/js/publish/src/broadcast.ts +++ b/js/publish/src/broadcast.ts @@ -8,7 +8,9 @@ import { type Kind, Rendition } from "./rendition"; // Signals the broadcast reads. Whoever owns the backing Signal (the element, or another component // whose output is wired in, e.g. a Video.Capture's `display`) does the writing. export type BroadcastInput = { - connection: Getter; + // The origin to publish into. Independent of any connection: whichever sessions serve the + // origin announce the broadcast, and it survives their reconnects. + origin: Getter; // Whether to publish the broadcast. Defaults to false so nothing is announced until ready. enabled: Getter; @@ -57,10 +59,11 @@ export class Broadcast { // root sections (e.g. `scte35`) by locking it too. readonly catalog = new CatalogProducer(); - // The underlying network broadcast, (re)created on each (re)connection and `undefined` while - // offline. Exposed so an application can serve its own tracks alongside the built-in + // The underlying network broadcast, recreated when the name or enabled state changes and + // `undefined` in between. It lives in the origin rather than any session, so it spans + // reconnects. Exposed so an application can serve its own tracks alongside the built-in // catalog/audio/video, e.g. `net.createTrack("meta.json")` plus a matching `catalog` section. - // Reacquire it via an effect, since reconnecting swaps in a fresh producer. + // Reacquire it via an effect, since a rename swaps in a fresh producer. readonly net = new Signal(undefined); // The registered renditions keyed by full track name. A plain object so deep-equality detects a @@ -75,7 +78,7 @@ export class Broadcast { constructor(props?: Inputs) { this.in = { - connection: getter(props?.connection), + origin: getter(props?.origin), enabled: getter(props?.enabled ?? false), name: getter(props?.name ?? Moq.Path.empty()), display: getter(props?.display), @@ -193,9 +196,9 @@ export class Broadcast { } #run(effect: Effect) { - const values = effect.getAll([this.in.enabled, this.in.connection]); + const values = effect.getAll([this.in.enabled, this.in.origin]); if (!values) return; - const [_enabled, connection] = values; + const [_enabled, origin] = values; const name = effect.get(this.in.name); if (Catalog.detectFormat(name) === undefined) { @@ -204,10 +207,12 @@ export class Broadcast { ); } - const broadcast = new Moq.Broadcast.Producer(); + // Publishing into the origin outlives any single session: a reconnect re-announces the + // broadcast and new subscriptions land on the same producer. + const broadcast = origin.publish(name); effect.cleanup(() => broadcast.close()); - // Close every active rendition track when the broadcast tears down (reconnect/offline), so an + // Close every active rendition track when the broadcast tears down (disable/rename), so an // encoder stops encoding into a dead producer. The Rendition handles themselves stay registered. effect.cleanup(() => { for (const track of this.#tracks.values()) { @@ -216,14 +221,12 @@ export class Broadcast { } }); - // Publish it before serving so an application reacting to `net` can insert its own tracks. + // Expose it before serving so an application reacting to `net` can insert its own tracks. this.net.set(broadcast); effect.cleanup(() => { if (this.net.peek() === broadcast) this.net.set(undefined); }); - connection.publish(name, broadcast); - effect.spawn(this.#runBroadcast.bind(this, broadcast, effect)); } diff --git a/js/publish/src/element.ts b/js/publish/src/element.ts index 2596564b9a..baba274615 100644 --- a/js/publish/src/element.ts +++ b/js/publish/src/element.ts @@ -86,7 +86,12 @@ export default class MoqPublish extends HTMLElement { announce: new Signal("source"), }; - connection: Moq.Connection.Reload; + /** + * The relay connection, shared with every other element on the page pointing at the + * same URL; see `Moq.Connection.Shared`. The broadcast publishes into its `origin`, so + * a `` on the same page and URL resolves it locally with no round trip. + */ + connection: Moq.Connection.Shared; /** The video capture, shared by every video rendition. Also reachable as `video.capture`. */ capture: Video.Capture; broadcast: Broadcast; @@ -146,7 +151,7 @@ export default class MoqPublish extends HTMLElement { cleanup.register(this, this.signals); - this.connection = new Moq.Connection.Reload({ + this.connection = new Moq.Connection.Shared({ enabled: this.#enabled, }); this.signals.cleanup(() => this.connection.close()); @@ -181,9 +186,8 @@ export default class MoqPublish extends HTMLElement { // transport has no event for it, so sample on our own schedule and skip a tick // while the previous snapshot is outstanding. this.signals.run((effect) => { - const connection = effect.get(this.connection.established); effect.set(this.#bandwidth, undefined); - if (!connection) return; + if (effect.get(this.connection.status) !== "connected") return; let pending = false; const sample = async () => { @@ -193,7 +197,7 @@ export default class MoqPublish extends HTMLElement { // A snapshot that lands after this run was torn down describes a // connection we no longer have, so drop it rather than capping the // encoder at a dead peer's estimate. - const stats = await Promise.race([effect.cancel, connection.stats()]); + const stats = await Promise.race([effect.cancel, this.connection.stats()]); if (stats) this.#bandwidth.set(stats.estimatedSendRate); } finally { pending = false; @@ -215,7 +219,7 @@ export default class MoqPublish extends HTMLElement { this.signals.cleanup(() => audioCapture.close()); this.broadcast = new Broadcast({ - connection: this.connection.established, + origin: this.connection.origin, enabled: this.#publishEnabled, name: this.#name, display: this.capture.out.display, diff --git a/js/signals/src/index.ts b/js/signals/src/index.ts index 6a98fa4650..4da7c3ff2e 100644 --- a/js/signals/src/index.ts +++ b/js/signals/src/index.ts @@ -910,6 +910,84 @@ export class Computed implements Getter { } } +/** The values behind a tuple of readables, positionally. */ +export type GetterValues[]> = { [K in keyof S]: GetterType }; + +/** + * A read-only view over other readables, recomputed on every read. + * + * The lifecycle-free counterpart to {@link Computed}: it holds no {@link Effect} and needs no + * `close()`, its value is correct from the first `peek()` rather than `undefined` until a first + * run, and it reads synchronously. In exchange the sources are named up front instead of + * tracked automatically, and `fn` must be cheap and pure since it runs per read. + * + * Reach for it when a class wants to publish a small mapped view of its own state as part of + * its public surface. A hand-written object with the same three methods would work until a + * consumer passed it to {@link getter} or an {@link Inputs} field, which reject a readable + * this package did not create. + * + * It relays its sources rather than filtering them: every source notification becomes one + * here, even when the mapped value is unchanged. Deduplicating instead would have to compare + * against the value at subscribe time, which silently swallows two real edges: a source that + * already applied a change whose flush is still queued, and an in-place {@link Signal.mutate} + * of a value the mapping returns as-is. A redundant rerun is the cheaper failure. + * + * ```ts + * readonly online = new Derived([this.#peers], (peers) => peers.size > 0); + * ``` + */ +export class Derived[], T> implements Getter { + #sources: S; + #fn: (...values: GetterValues) => T; + + // Brand to identify this as a readable across package instances. + readonly [GETTER_BRAND] = true; + + /** Creates a view deriving its value from `sources` via `fn`, recomputed on every read. */ + constructor(sources: S, fn: (...values: GetterValues) => T) { + this.#sources = sources; + this.#fn = fn; + } + + /** Returns the current derived value without subscribing. */ + peek(): T { + return this.#fn(...(this.#sources.map((source) => source.peek()) as unknown as GetterValues)); + } + + /** Calls `fn` with the derived value every time any source notifies. */ + subscribe(fn: Subscriber): Dispose { + return this.#watch(fn); + } + + /** Resolves the next time the derived value changes, or calls `fn` once on the next change. */ + changed(): Promise; + changed(fn: Subscriber): Dispose; + changed(fn?: Subscriber): Promise | Dispose { + if (fn) return this.#once(fn); + + return new Promise((resolve) => { + this.#once(resolve); + }); + } + + // Calls `fn` with the derived value the first time it changes, then unsubscribes. + #once(fn: Subscriber): Dispose { + const dispose = this.subscribe((value) => { + dispose(); + fn(value); + }); + return dispose; + } + + // Subscribes to every source, recomputing on any notification. + #watch(fn: Subscriber): Dispose { + const disposes = this.#sources.map((source) => source.subscribe(() => fn(this.peek()))); + return () => { + for (const dispose of disposes) dispose(); + }; + } +} + // Deep equality for plain objects/arrays, === for class instances and primitives. // Class instances have identity semantics (e.g. two different Broadcast instances are never equal). function isEqual(a: unknown, b: unknown): boolean { diff --git a/js/signals/src/io.test.ts b/js/signals/src/io.test.ts index e4e5ddcaa1..342875bbf4 100644 --- a/js/signals/src/io.test.ts +++ b/js/signals/src/io.test.ts @@ -1,5 +1,8 @@ import { expect, test } from "bun:test"; -import { Computed, Effect, type Getter, getter, Once, readonlys, Signal } from "./index.ts"; +import { Computed, Derived, Effect, type Getter, getter, Once, readonlys, Signal } from "./index.ts"; + +// Lets the microtask flush and any timer-based follow-up run. +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); test("getter wraps a raw value in a fresh Signal", () => { const g = getter(5); @@ -108,6 +111,104 @@ test("getter still wraps plain objects that are not readables", () => { expect(g.peek()).toBe(value); }); +test("getter accepts a Derived, so a mapped view can be wired as an input", () => { + const source = new Signal({ total: 0 }); + const view = new Derived([source], ({ total }) => total > 0); + + // The point of the class over a hand-written object: getter() would reject that as foreign. + expect(getter(view)).toBe(view); +}); + +test("Derived reads through on every peek, with no first-run gap", () => { + const a = new Signal(1); + const b = new Signal(2); + const sum = new Derived([a, b], (x, y) => x + y); + + expect(sum.peek()).toBe(3); + a.set(10); + expect(sum.peek()).toBe(12); +}); + +test("Derived relays every source notification, redundant or not", async () => { + const source = new Signal({ total: 0, discovery: 0 }); + const view = new Derived([source], ({ total, discovery }) => (total === 0 ? undefined : discovery > 0)); + + const seen: (boolean | undefined)[] = []; + const dispose = view.subscribe((value) => seen.push(value)); + + source.set({ total: 1, discovery: 1 }); + await Promise.resolve(); + // A second session moves the counts but not the answer: relayed anyway, because the + // alternative drops real edges (see the two cases below). + source.set({ total: 2, discovery: 2 }); + await Promise.resolve(); + source.set({ total: 0, discovery: 0 }); + await Promise.resolve(); + + expect(seen).toEqual([true, true, undefined]); + dispose(); +}); + +test("Derived delivers a change whose flush was already queued when we subscribed", async () => { + const source = new Signal(0); + const other = source.subscribe(() => {}); // so set() has subscribers and queues a flush + const view = new Derived([source], (value) => value); + + // The value is already 1 here; only its notification is still queued. Comparing against + // peek() at subscribe time would treat this edge as already seen. + source.set(1); + + const raw: number[] = []; + const derived: number[] = []; + const cancelRaw = source.changed((value) => raw.push(value)); + const cancelDerived = view.changed((value) => derived.push(value)); + + await settle(); + expect(derived).toEqual(raw); + expect(derived).toEqual([1]); + cancelRaw(); + cancelDerived(); + other(); +}); + +test("Derived delivers an in-place mutation of a value it returns as-is", async () => { + const source = new Signal({ count: 0 }); + const view = new Derived([source], (value) => value); + + const seen: number[] = []; + const dispose = view.subscribe((value) => seen.push(value.count)); + + // mutate() force-notifies precisely because the object identity cannot change. + source.mutate((value) => { + value.count++; + }); + await settle(); + + expect(seen).toEqual([1]); + dispose(); +}); + +test("Derived changed() fires once and unsubscribes itself", async () => { + const a = new Signal(1); + const b = new Signal(1); + const max = new Derived([a, b], (x, y) => Math.max(x, y)); + + const seen: number[] = []; + const cancel = max.changed((value) => seen.push(value)); + + b.set(5); + await Promise.resolve(); + a.set(9); + await Promise.resolve(); + + expect(seen).toEqual([5]); + cancel(); + + const next = max.changed(); + a.set(11); + expect(await next).toBe(11); +}); + test("an out Getter feeds another component's in end to end", () => { // Mimic: produced.out.value -> consumed input via getter(). const produced = new Signal(0); diff --git a/js/tsconfig.json b/js/tsconfig.json index 0cb5984f65..51f9857fb3 100644 --- a/js/tsconfig.json +++ b/js/tsconfig.json @@ -6,6 +6,10 @@ "declaration": true, "declarationMap": true, + // `@internal` is how this workspace hides a symbol from the published surface, so the + // declarations have to actually drop it. Without this the tag is a comment: the symbol + // still ships, still type-checks for consumers, and reads as supported API. + "stripInternal": true, "isolatedModules": true, "sourceMap": true, "inlineSources": true, diff --git a/js/watch/src/broadcast.test.ts b/js/watch/src/broadcast.test.ts index 5087691ae2..537338b7ca 100644 --- a/js/watch/src/broadcast.test.ts +++ b/js/watch/src/broadcast.test.ts @@ -1,33 +1,29 @@ import { describe, expect, it } from "bun:test"; import type * as Catalog from "@moq/hang/catalog"; -import type * as Moq from "@moq/net"; -import { Path } from "@moq/net"; +import * as Moq from "@moq/net"; +import { Origin, Path } from "@moq/net"; import { Effect } from "@moq/signals"; import { Broadcast } from "./broadcast"; -// A consumer stub that only remembers which path it was consumed from. -type Consumed = { path: Moq.Path.Valid; close: () => void }; - -// The connection is only asked to consume a path; `reload: false` skips the announcement -// stream and `catalogFormat: "manual"` skips the catalog fetch. -function connection(): Moq.Connection.Established { - return { - consume: (path: Moq.Path.Valid): Consumed => ({ path, close: () => {} }), - } as unknown as Moq.Connection.Established; +// A real origin with local broadcasts at the given paths. Resolution is proven by +// discrimination: `relativeBroadcast` resolves blind against the table (reload: false), so +// a defined result means the reference resolved to a published path and nothing else. +function origin(paths: string[]): Origin.Producer { + const producer = new Origin.Producer(); + for (const path of paths) producer.publish(Path.from(path)); + return producer; } -function broadcast(name: string): Broadcast { - return new Broadcast({ - connection: connection(), +function broadcast(name: string, paths: string[] = [name]): { source: Broadcast; owner: Origin.Producer } { + const owner = origin(paths); + const source = new Broadcast({ + origin: owner, name: Path.from(name), enabled: true, reload: false, catalogFormat: "manual", }); -} - -function consumed(value: Moq.Broadcast.Consumer | undefined): string | undefined { - return (value as unknown as Consumed | undefined)?.path; + return { source, owner }; } function withoutWarnings(fn: () => T): T { @@ -41,47 +37,55 @@ function withoutWarnings(fn: () => T): T { } describe("relativeBroadcast", () => { - it("resolves a legal reference against the catalog broadcast", () => { - const source = broadcast("a/b"); + it("resolves a legal reference against the origin", () => { + const { source, owner } = broadcast("a/b", ["a/b", "a/source", "a/b/sub"]); const effect = new Effect(); try { - expect(consumed(source.relativeBroadcast(effect, "../source"))).toBe("a/source"); - expect(consumed(source.relativeBroadcast(effect, "sub"))).toBe("a/b/sub"); + expect(source.relativeBroadcast(effect, "../source")).toBeDefined(); + expect(source.relativeBroadcast(effect, "sub")).toBeDefined(); + // Nothing routes an unpublished sibling, so the reference stays pending. + expect(source.relativeBroadcast(effect, "../missing")).toBeUndefined(); } finally { effect.close(); source.close(); + owner.close(); } }); it("ignores a reference that escapes above the root", () => { - const source = broadcast("a/b"); + const { source, owner } = broadcast("a/b", ["a/b", "x", ""]); const effect = new Effect(); try { - // Clamping would subscribe to an unrelated `x` instead of dropping the rendition. + // Clamping would subscribe to an unrelated `x` instead of dropping the rendition; + // `x` is published, so a defined result here would prove the clamp bug. withoutWarnings(() => { expect(source.relativeBroadcast(effect, "../../../x")).toBeUndefined(); expect(source.relativeBroadcast(effect, "../../../..")).toBeUndefined(); }); // Popping to exactly the root stops at it, and the root still names a broadcast. - expect(consumed(source.relativeBroadcast(effect, "../.."))).toBe(Path.empty()); + expect(source.relativeBroadcast(effect, "../..")).toBeDefined(); } finally { effect.close(); source.close(); + owner.close(); } }); const rendition = (broadcast?: string): Catalog.VideoConfig => ({ codec: "avc1.42001f", container: { kind: "legacy" }, broadcast }) as Catalog.VideoConfig; - const manualCatalog = (catalog: Catalog.Root) => - new Broadcast({ - connection: connection(), + const manualCatalog = (catalog: Catalog.Root) => { + const owner = origin(["a/b"]); + const source = new Broadcast({ + origin: owner, name: Path.from("a/b"), enabled: true, reload: false, catalogFormat: "manual", catalog, }); + return { source, owner }; + }; const manual = (renditions: Record) => manualCatalog({ video: { renditions } } as Catalog.Root); @@ -90,7 +94,11 @@ describe("relativeBroadcast", () => { // The whole catalog goes, not just the offending rendition: the root is this // consumer's authorized subtree, so the reference names content it cannot reach, // and serving the rest would hide a publisher bug behind a track that never fills. - const source = manual({ good: rendition(), sibling: rendition("../source"), bad: rendition("../../../x") }); + const { source, owner } = manual({ + good: rendition(), + sibling: rendition("../source"), + bad: rendition("../../../x"), + }); const error = console.error; console.error = () => {}; @@ -101,6 +109,7 @@ describe("relativeBroadcast", () => { } finally { console.error = error; source.close(); + owner.close(); } }); @@ -113,7 +122,7 @@ describe("relativeBroadcast", () => { container: { kind: "legacy" }, broadcast: "../../../x", } as Catalog.TextConfig; - const source = manualCatalog({ + const { source, owner } = manualCatalog({ video: { renditions: { good: rendition() } }, text: { renditions: { captions } }, } as Catalog.Root); @@ -126,11 +135,16 @@ describe("relativeBroadcast", () => { } finally { console.error = error; source.close(); + owner.close(); } }); it("accepts a catalog whose references stay within the root", async () => { - const source = manual({ good: rendition(), sibling: rendition("../source"), root: rendition("../..") }); + const { source, owner } = manual({ + good: rendition(), + sibling: rendition("../source"), + root: rendition("../.."), + }); try { await Promise.resolve(); @@ -138,21 +152,63 @@ describe("relativeBroadcast", () => { expect(Object.keys(renditions).sort()).toEqual(["good", "root", "sibling"]); } finally { source.close(); + owner.close(); } }); it("uses the catalog's own broadcast when the reference is absent, empty, or self", async () => { - const source = broadcast("a/b"); + const { source, owner } = broadcast("a/b"); const effect = new Effect(); try { // The catalog broadcast is consumed by an effect, which settles a microtask later. await Promise.resolve(); - expect(consumed(source.relativeBroadcast(effect, undefined))).toBe("a/b"); - expect(consumed(source.relativeBroadcast(effect, ""))).toBe("a/b"); - expect(consumed(source.relativeBroadcast(effect, "../b"))).toBe("a/b"); + const own = source.out.active.peek(); + expect(own).toBeDefined(); + expect(source.relativeBroadcast(effect, undefined)).toBe(own); + expect(source.relativeBroadcast(effect, "")).toBe(own); + expect(source.relativeBroadcast(effect, "../b")).toBe(own); } finally { effect.close(); source.close(); + owner.close(); } }); }); + +describe("blind resolution", () => { + it("holds a resolved request steady instead of flapping", async () => { + // reload: false with nothing routed stands a request; when a session answers, the + // effect that read `request.active` reruns. That rerun must re-acquire the same + // answer, not close the request and re-dial forever. + const owner = new Origin.Producer(); + const source = new Broadcast({ + origin: owner, + name: Path.from("blind.hang"), + enabled: true, + reload: false, + catalogFormat: "manual", + }); + + const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); + await settle(); + + // Stand in for a session's serving loop answering the request. + const upstream = new Moq.Broadcast.Producer(); + const withdraw = owner.answer(Path.from("blind.hang"), upstream.consume()); + expect(withdraw).toBeDefined(); + + await settle(); + const active = source.out.active.peek(); + expect(active).toBeDefined(); + + // Several tick boundaries later the same front is still held and the answer was + // never withdrawn; a flap would close the upstream and vacate the request. + for (let i = 0; i < 5; i++) await settle(); + expect(source.out.active.peek()).toBe(active); + expect(upstream.closed.peek()).toBeUndefined(); + + source.close(); + owner.close(); + await settle(); + }); +}); diff --git a/js/watch/src/broadcast.ts b/js/watch/src/broadcast.ts index a3207878f2..d9bfa1b0ec 100644 --- a/js/watch/src/broadcast.ts +++ b/js/watch/src/broadcast.ts @@ -2,28 +2,11 @@ import * as Catalog from "@moq/hang/catalog"; import * as Json from "@moq/json"; import * as Msf from "@moq/msf"; import type * as Moq from "@moq/net"; -import { Path } from "@moq/net"; +import { Announce, Path } from "@moq/net"; import { Effect, type Getter, getter, type Inputs, type Readonlys, readonlys, Signal } from "@moq/signals"; import { toHang } from "./msf"; -// Connections already warned about missing broadcast-discovery support, so the -// announcement check logs at most once per connection. -const warnedNoDiscovery = new WeakSet(); - -// Whether to skip the announcement gate for a cross-broadcast reference: without discovery, -// waiting on an announcement would hang forever, so subscribe immediately and warn once per -// connection. The main broadcast doesn't need this; @moq/net's `announcedBroadcast` falls back -// on its own. -function skipDiscovery(conn: Moq.Connection.Established): boolean { - if (conn.discovery) return false; - if (!warnedNoDiscovery.has(conn)) { - warnedNoDiscovery.add(conn); - console.warn("relay does not support broadcast discovery; subscribing to siblings blind."); - } - return true; -} - /** * The name of the first rendition whose `broadcast` reference walks above the root, if any. * @@ -74,7 +57,9 @@ type Status = "offline" | "loading" | "live"; // Signals the component reads. Whoever owns the backing Signal (the caller, or // another component whose output is wired in) does the writing. export type BroadcastInput = { - connection: Getter; + // The origin to consume from. Independent of any connection: whichever sessions feed + // the origin resolve the broadcast, and the handle spans their reconnects. + origin: Getter; // Whether to start downloading the broadcast. // Defaults to false so you can make sure everything is ready before starting. @@ -132,7 +117,7 @@ export class Broadcast { constructor(props?: Inputs) { this.in = { - connection: getter(props?.connection), + origin: getter(props?.origin), name: getter(props?.name ?? Path.empty()), enabled: getter(props?.enabled ?? false), reload: getter(props?.reload ?? true), @@ -145,19 +130,18 @@ export class Broadcast { this.#signals.run(this.#runCatalog.bind(this)); } - // Maintain the set of announced paths used by `relativeBroadcast`, by draining a connection-scoped - // announcement stream. Only opened once a relative reference asks for it (see `#wantAnnounced`), - // and reopened per connection. + // Maintain the set of announced paths used by `relativeBroadcast`, by draining an origin-scoped + // announcement stream. Only opened once a relative reference asks for it (see `#wantAnnounced`). #runAnnounced(effect: Effect): void { this.#announced.set(undefined); if (!effect.get(this.#wantAnnounced)) return; if (!effect.get(this.in.reload)) return; - const conn = effect.get(this.in.connection); - if (!conn || skipDiscovery(conn)) return; + const origin = effect.get(this.in.origin); + if (!origin) return; - const announced = conn.announced(Path.empty()); + const announced = origin.announced(Path.empty()); effect.cleanup(() => announced.close()); this.#announced.set(new Set()); @@ -174,15 +158,10 @@ export class Broadcast { }); } - // Whether `path` is currently announced, for `relativeBroadcast`'s cross-broadcast refs. Returns - // true (subscribe immediately) when the gate can't apply: reload is off, or the relay doesn't - // support discovery. Opens the announcement stream on first use. + // Whether `path` is currently announced, for `relativeBroadcast`'s cross-broadcast refs. + // Opens the announcement stream on first use. The blind cases (reload off, no discovery) + // never reach here; see `relativeBroadcast`. #isPathAnnounced(effect: Effect, path: Moq.Path.Valid): boolean { - if (!effect.get(this.in.reload)) return true; - - const conn = effect.get(this.in.connection); - if (conn && skipDiscovery(conn)) return true; - this.#wantAnnounced.set(true); const active = effect.get(this.#announced); @@ -190,6 +169,19 @@ export class Broadcast { return active.has(path); } + // Resolve `path` without waiting for an announcement. The request is table-first, so a + // routed broadcast (a local publish, or anything announced) resolves synchronously and + // a blind session answer covers the rest, arriving on a later run. + #requestBroadcast( + effect: Effect, + origin: Moq.Origin.Table, + path: Moq.Path.Valid, + ): Moq.Broadcast.Consumer | undefined { + const request = origin.request(path); + effect.cleanup(() => request.close()); + return effect.get(request.active); + } + // Subscribe to the broadcast, waiting for its announcement so we never race a publisher that // comes online after us. @moq/net drives the re-consume on a same-name republish and the blind // fallback on a relay without discovery; mirror its handle into `active`. @@ -197,20 +189,18 @@ export class Broadcast { const enabled = effect.get(this.in.enabled); if (!enabled) return; - const conn = effect.get(this.in.connection); - if (!conn) return; + const origin = effect.get(this.in.origin); + if (!origin) return; const name = effect.get(this.in.name); // No announcement gate: subscribe immediately. if (!effect.get(this.in.reload)) { - const broadcast = conn.consume(name); - effect.cleanup(() => broadcast.close()); - effect.set(this.#out.active, broadcast, undefined); + effect.set(this.#out.active, this.#requestBroadcast(effect, origin, name), undefined); return; } - const announced = conn.announcedBroadcast(name); + const announced = new Announce.Broadcast({ origin: this.in.origin, path: name }); effect.cleanup(() => announced.close()); effect.run((nested) => { @@ -321,14 +311,18 @@ export class Broadcast { if (!effect.get(this.in.enabled)) return undefined; - const conn = effect.get(this.in.connection); - if (!conn) return undefined; + const origin = effect.get(this.in.origin); + if (!origin) return undefined; - if (!this.#isPathAnnounced(effect, resolved)) return undefined; + // Without an announcement gate (reload off, or no session supports discovery), + // resolve blind rather than waiting for an announcement that never comes. With the + // gate, only stand the request once the path is announced: the request then + // resolves from the table, never blind. + if (effect.get(this.in.reload) && effect.get(origin.discovery) !== false) { + if (!this.#isPathAnnounced(effect, resolved)) return undefined; + } - const broadcast = conn.consume(resolved); - effect.cleanup(() => broadcast.close()); - return broadcast; + return this.#requestBroadcast(effect, origin, resolved); } close() { diff --git a/js/watch/src/element.ts b/js/watch/src/element.ts index f0c1daa7f2..4772823c53 100644 --- a/js/watch/src/element.ts +++ b/js/watch/src/element.ts @@ -66,7 +66,11 @@ export default class MoqWatch extends HTMLElement { static observedAttributes = OBSERVED; // The connection to the moq-relay server. - connection: Moq.Connection.Reload; + /** + * The relay connection, shared with every other element on the page pointing at the + * same URL; see `Moq.Connection.Shared`. Its `origin` is where the broadcasts live. + */ + connection: Moq.Connection.Shared; // The broadcast being watched. broadcast: Broadcast; @@ -146,13 +150,13 @@ export default class MoqWatch extends HTMLElement { cleanup.register(this, this.signals); - this.connection = new Moq.Connection.Reload({ + this.connection = new Moq.Connection.Shared({ enabled: this.#enabled, }); this.signals.cleanup(() => this.connection.close()); this.broadcast = new Broadcast({ - connection: this.connection.established, + origin: this.connection.origin, enabled: this.#enabled, name: this.#name, reload: this.#reload, @@ -166,6 +170,7 @@ export default class MoqWatch extends HTMLElement { broadcast: this.broadcast, target: this.controls.target, supported: Video.Decoder.supported, + probe: this.connection.probe, }); const audioSource = new Audio.Source({ broadcast: this.broadcast, @@ -187,7 +192,7 @@ export default class MoqWatch extends HTMLElement { const videoJitter = new Signal(undefined); this.sync = new Sync({ latency: this.controls.latency, - connection: this.connection.established, + probe: this.connection.probe, video: videoJitter, audio: audioSource.out.jitter, }); diff --git a/js/watch/src/sync.ts b/js/watch/src/sync.ts index ab15759187..81ba664e06 100644 --- a/js/watch/src/sync.ts +++ b/js/watch/src/sync.ts @@ -35,8 +35,9 @@ export type SyncInput = { // Latency target: a scalar minimizes (collapsed range), an object opens a range. See {@link Latency}. latency: Getter; - // The connection used for "real-time" jitter: PROBE supplies RTT. - connection: Getter; + // The connection's PROBE estimates, whose RTT drives "real-time" jitter. Usually wired + // from a `Connection.Shared`'s or `Reload`'s `probe`. + probe: Getter; // Any additional delay required for audio or video (wired from the per-rendition source). audio: Getter; @@ -97,7 +98,7 @@ export class Sync { constructor(props?: Inputs) { this.in = { latency: getter(props?.latency ?? ("real-time" as Latency)), - connection: getter(props?.connection), + probe: getter(props?.probe), audio: getter(props?.audio), video: getter(props?.video), }; @@ -144,9 +145,8 @@ export class Sync { return; } - // "real-time" mode: compute jitter from RTT on the established connection. - const conn = effect.get(this.in.connection); - const rtt = conn && effect.get(conn.probe).rtt; + // "real-time" mode: compute jitter from the connection's RTT estimate. + const rtt = effect.get(this.in.probe)?.rtt; if (rtt !== undefined) { // Track minimum RTT as baseline, ignoring bufferbloat. this.#minRtt = this.#minRtt !== undefined ? Math.min(this.#minRtt, rtt) : rtt; diff --git a/js/watch/src/video/source.ts b/js/watch/src/video/source.ts index 7815e00ce9..f2034ab015 100644 --- a/js/watch/src/video/source.ts +++ b/js/watch/src/video/source.ts @@ -1,4 +1,5 @@ import type * as Catalog from "@moq/hang/catalog"; +import type * as Moq from "@moq/net"; import { Effect, type Getter, getter, type Inputs, type Readonlys, readonlys, Signal } from "@moq/signals"; import type { Broadcast } from "../broadcast"; @@ -36,6 +37,11 @@ export type SourceInput = { // A function that checks if a video configuration can be played. Renditions that fail the // probe are filtered out. Nothing is selected until one is provided. supported: Getter; + + // The connection's PROBE estimates, used to auto-select a rendition when the target has no + // explicit bitrate. Usually wired from a `Connection.Shared`'s or `Reload`'s `probe`. + // Optional: without it auto-selection falls back to the preference order alone. + probe: Getter; }; type SourceOutput = { @@ -225,6 +231,7 @@ export class Source { broadcast: getter(props?.broadcast), target: getter(props?.target), supported: getter(props?.supported), + probe: getter(props?.probe), }; this.#signals.run(this.#runCatalog.bind(this)); @@ -305,9 +312,7 @@ export class Source { // Auto-select: use recv bandwidth if no explicit bitrate target. let effectiveTarget = target; if (!target?.bitrate) { - const broadcast = effect.get(this.in.broadcast); - const connection = broadcast ? effect.get(broadcast.in.connection) : undefined; - const estimate = connection && effect.get(connection.probe).estimatedRecvRate; + const estimate = effect.get(this.in.probe)?.estimatedRecvRate; if (estimate != null) { // Apply a safety margin (80%) to avoid oscillation. const safeBitrate = Math.round(estimate * 0.8);