From b7b6a9e960c26b9f099c959feb9d4adc48542dba Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 6 Aug 2026 13:22:08 -0700 Subject: [PATCH 01/22] feat(js/net)!: route publish and consume through Origins, share one connection per relay Squash of the four-step origin reshape; the full narrative is in PR #2705. - Origin.Producer/Consumer: a broadcast routing table independent of any connection, mirroring rs/moq-net's origin module. origin.publish(path) creates and returns the producer; Established.publish is removed. Sessions borrow the table via the publish option on connect/accept/Reload and announce it while they last; closing a session unannounces but closes nothing, and a reconnect re-announces the untouched table. - The subscribe option feeds the table with the peer's announcements as lazily-subscribing fronts scoped to the discovering session. Local and remote entries are separate maps and a session only announces the local one, so one origin on both directions cannot echo, and consume(path) resolves local first: loopback with no round trip. - origin.request(path) mirrors Rust's dynamic origin (#1772): any attached session answers blind, answers die with their session and are re-answered by the next, never announced. Reactive origin.discovery drives the gated fallback. announce.Broadcast gained an origin mode; js/watch and moq-boy consume through origins; watch's duplicated no-discovery machinery is gone. - Both wire publishers diff announce sets by front identity, so a republish emits ended-then-active (the restart form subscribers already handle). - Connection.Shared: a reactive handle on a pooled {origin, reconnect loop} keyed by relay URL, with a short linger past the last handle. The watch and publish elements and the demo pages share one connection per relay. Closes #2628. Co-Authored-By: Claude Fable 5 --- demo/web/src/index.ts | 2 +- demo/web/src/stats.ts | 2 +- doc/lib/js/@moq/net.md | 16 + js/clock/src/main.ts | 7 +- js/moq-boy/src/element.tsx | 19 +- js/moq-boy/src/game.ts | 14 +- js/net/examples/publish.ts | 12 +- js/net/src/announced.ts | 94 ++++- js/net/src/connection/accept.ts | 75 ++-- js/net/src/connection/connect.ts | 64 +++- js/net/src/connection/established.ts | 10 +- js/net/src/connection/forward.ts | 116 ++++++ js/net/src/connection/index.ts | 4 +- js/net/src/connection/pool.test.ts | 164 +++++++++ js/net/src/connection/pool.ts | 261 ++++++++++++++ js/net/src/connection/reload.test.ts | 66 +++- js/net/src/connection/reload.ts | 30 ++ js/net/src/ietf/connection.ts | 16 +- js/net/src/ietf/publisher.ts | 78 ++-- js/net/src/index.ts | 2 + js/net/src/integration.test.ts | 509 ++++++++++++++++++++++----- js/net/src/lite/connection.ts | 18 +- js/net/src/lite/publisher.test.ts | 14 +- js/net/src/lite/publisher.ts | 97 +++-- js/net/src/origin.test.ts | 350 ++++++++++++++++++ js/net/src/origin.ts | 412 ++++++++++++++++++++++ js/publish/src/broadcast.test.ts | 10 +- js/publish/src/broadcast.ts | 27 +- js/publish/src/element.ts | 11 +- js/watch/src/broadcast.test.ts | 87 +++-- js/watch/src/broadcast.ts | 90 ++--- js/watch/src/element.ts | 11 +- js/watch/src/video/source.ts | 10 +- 33 files changed, 2297 insertions(+), 401 deletions(-) create mode 100644 js/net/src/connection/forward.ts create mode 100644 js/net/src/connection/pool.test.ts create mode 100644 js/net/src/connection/pool.ts create mode 100644 js/net/src/origin.test.ts create mode 100644 js/net/src/origin.ts diff --git a/demo/web/src/index.ts b/demo/web/src/index.ts index 4de5258def..947b4bb987 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) diff --git a/demo/web/src/stats.ts b/demo/web/src/stats.ts index 0ff7d5a5f2..f746d4f496 100644 --- a/demo/web/src/stats.ts +++ b/demo/web/src/stats.ts @@ -84,7 +84,7 @@ 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 ----------------------------------- diff --git a/doc/lib/js/@moq/net.md b/doc/lib/js/@moq/net.md index 72db0a77ce..4796515f96 100644 --- a/doc/lib/js/@moq/net.md +++ b/doc/lib/js/@moq/net.md @@ -44,6 +44,22 @@ 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, consumable by path and gone when the session dies. `origin.consume(path)` resolves local publishes first, so a page that publishes and watches the same broadcast reads its own copy with no round trip. For a path nothing announces (a relay without discovery, or subscribing before the publisher exists on purpose), `origin.request(path)` asks the attached sessions to resolve it blind; the request stands across reconnects. + ### Broadcasts A collection of related tracks. 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..6020f48379 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.consume(), 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()); @@ -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.ts b/js/net/src/announced.ts index b9eaf6d4b3..add37bd568 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 { Consumer as OriginConsumer } from "./origin.js"; import * as Path from "./path.js"; /** @@ -134,7 +135,7 @@ export class Consumer { const warnedNoDiscovery = new WeakSet(); /** - * What to watch, for {@link Broadcast}. + * What to watch, for {@link Broadcast}. Provide exactly one of `connection` or `origin`. * * @public */ @@ -143,7 +144,17 @@ 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; + connection?: GetterInit; + + /** + * The origin to watch instead of a session; wins when both are given. + * + * 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. On an origin whose sessions lack + * discovery it falls back to a standing request, so `active` means assumed present. + */ + origin?: GetterInit; /** The broadcast path to watch. */ path: Path.Valid; @@ -201,16 +212,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 +303,71 @@ 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; + + const discovery = effect.get(origin.discovery); + // Nothing is attached yet, so nothing can resolve; wait rather than request from nobody. + if (discovery === undefined) return; + + if (!discovery) { + // No announcement will ever arrive. Loopback still works: prefer the routed + // broadcast (a local publish), else stand a request for whichever session answers. + const routed = origin.consume(this.path); + if (routed) { + effect.cleanup(() => routed.close()); + effect.set(this.#active, routed, undefined); + return; + } + + const request = origin.request(this.path); + effect.cleanup(() => request.close()); + effect.run((nested) => { + nested.set(this.#active, nested.get(request.active), undefined); + }); + return; + } + + const announced = origin.announced(this.path); + effect.cleanup(() => announced.close()); + + let current: broadcast.Consumer | undefined; + const offline = () => { + const mine = current; + current?.close(); + current = undefined; + // Only clear what this run put there; see the session path above. + if (this.#active.peek() === mine) this.#active.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(); + current = origin.consume(this.path); + this.#active.set(current); + } else { + offline(); + } + } + + // The origin closed, or this run was torn down. Either way nothing routes the path. + offline(); + }); + } + /** 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.ts b/js/net/src/connection/forward.ts new file mode 100644 index 0000000000..3b720d97c1 --- /dev/null +++ b/js/net/src/connection/forward.ts @@ -0,0 +1,116 @@ +/** + * Feeds a session's announced broadcasts into an origin; the `subscribe` connect option. + * + * @module + */ +import type { Dispose } from "@moq/signals"; +import type * as broadcast from "../broadcast.ts"; +import type { Producer as OriginProducer } 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 { + const detach = origin.attach(conn.discovery); + void conn.closed.then(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 () => { + 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 { + // The session died mid-stream; the cleanup below retracts everything it fed. + } finally { + for (const dispose of inserted.values()) dispose(); + inserted.clear(); + announced.close(); + } + })(); +} + +/** + * 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. + */ +async function serveRequests(conn: Established, origin: OriginProducer): Promise { + // The fronts this session provided, so a dead session only withdraws its own. + const answered = new Map(); + + let dead = false; + const closed = conn.closed.then(() => { + dead = true; + }); + + const requests = origin.requests; + for (;;) { + const map = requests.peek(); + if (!map || dead) break; + + for (const [path, slot] of map) { + if (answered.has(path) || slot.front.peek() !== undefined) continue; + const front = conn.consume(path); + answered.set(path, front); + slot.front.set(front); + } + + // A withdrawn request already closed the front; just forget our claim on the path. + for (const path of [...answered.keys()]) { + if (!map.has(path)) answered.delete(path); + } + + await Promise.race([requests.changed(), closed]); + } + + // Session gone: withdraw our answers so the next session provides fresh ones. + for (const [path, front] of answered) { + const slot = requests.peek()?.get(path); + if (slot?.front.peek() === front) slot.front.set(undefined); + front.close(); + } + 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..6c498aba00 --- /dev/null +++ b/js/net/src/connection/pool.test.ts @@ -0,0 +1,164 @@ +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): Promise { + for (let i = 0; i < 500; i++) { + if (pred()) return; + await settle(); + } + throw new Error("timed out waiting for condition"); +} + +// 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.established.peek() !== undefined); + await waitUntil(() => second.established.peek() !== undefined); + 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.established.peek()).not.toBeUndefined(); + + 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.established.peek() !== undefined); + 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.established.peek() !== undefined); + 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.established.peek() !== undefined); + 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.established.peek() !== undefined); + + toggled.enabled.set(false); + await waitUntil(() => toggled.origin.peek() === undefined); + expect(toggled.established.peek()).toBeUndefined(); + + // The steady handle keeps the connection alive through the toggle. + await expired(); + expect(steady.established.peek()).not.toBeUndefined(); + + // Re-enabling rejoins the shared connection. + toggled.enabled.set(true); + await waitUntil(() => toggled.established.peek() !== undefined); + 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 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. + const handle = watcher.origin.peek()?.consume().consume(Path.from("mine")); + expect(handle).toBeDefined(); + handle?.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..5df9a8e371 --- /dev/null +++ b/js/net/src/connection/pool.ts @@ -0,0 +1,261 @@ +/** + * 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"; + +/** 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. + * + * 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 currently established session, or undefined while disconnected. */ + readonly established: 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, consume from it, or stand requests on it; it is the same origin every + * other handle on this URL uses, and it spans the connection's reconnects. + */ + 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(); + + constructor(props?: SharedProps) { + this.url = Signal.from(props?.url); + this.enabled = Signal.from(props?.enabled ?? true); + this.status = this.#status; + this.established = this.#established; + this.probe = this.#probe; + this.origin = this.#origin; + + 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; + }); + + const pump = new Effect(); + pump.run((effect) => { + const origin = effect.get(this.#origin); + if (!origin) return; + + const upstream = origin.consume().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 }); + } + } + } + }); + }); + + this.#signals.cleanup(() => pump.close()); + void consumer.closed.then(() => pump.close()); + + 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 { + return new Announce.Broadcast({ + origin: this.#signals.computed((effect) => effect.get(this.#origin)?.consume()), + 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, + }); + entry = { origin, connection, refs: 0, linger: linger ?? LINGER_MS }; + pool.set(key, entry); + } + + 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..4628874e54 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"; @@ -147,11 +148,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; }; @@ -236,3 +236,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.consume(Path.from("remote")) !== undefined); + await waitUntil(() => servers[0]?.saw.consume().consume(Path.from("mine")) !== undefined); + + // Kill the session: the remote entry retracts, the local publish stays put. + servers[0]?.session.close(); + await waitUntil(() => reader.consume(Path.from("remote")) === undefined); + + // The reconnect re-announces the (untouched) publish and re-populates the table. + await waitUntil(() => servers.length > 1); + await waitUntil(() => reader.consume(Path.from("remote")) !== undefined); + await waitUntil(() => servers[1]?.saw.consume().consume(Path.from("mine")) !== undefined); + } 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..e19555027f 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 * 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"; @@ -99,6 +100,25 @@ export class Reload { */ discovery?: boolean; + /** + * 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. */ delay: ReloadDelay; @@ -136,6 +156,8 @@ export class Reload { this.webtransport = props?.webtransport; this.websocket = props?.websocket; this.discovery = props?.discovery; + this.publish = props?.publish; + this.subscribe = props?.subscribe; this.closed = new Promise((resolve, reject) => { this.#closedResolve = resolve; @@ -193,6 +215,8 @@ export class Reload { websocket: this.websocket, webtransport: this.webtransport, discovery: this.discovery, + publish: this.publish, + subscribe: this.subscribe, signal, }); @@ -284,6 +308,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.consume().announced(prefix); + const producer = new Announce.Producer(prefix); const consumer = producer.consume(); @@ -349,6 +377,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.consume(), 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..fe275ed062 100644 --- a/js/net/src/integration.test.ts +++ b/js/net/src/integration.test.ts @@ -1,11 +1,13 @@ 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 { 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 { 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"; @@ -17,17 +19,16 @@ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, 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, @@ -203,11 +204,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 +220,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 +234,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 +252,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 +295,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 +341,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 +379,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 +406,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 +443,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 +485,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 +528,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 +572,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 +609,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 +652,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 +696,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 +736,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 +780,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 +820,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 +857,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 +888,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 +927,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 +966,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 +1009,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 +1042,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 +1064,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 +1077,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 +1098,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 +1112,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 +1128,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 +1142,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 +1161,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 +1178,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 +1195,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 +1203,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 +1215,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 +1237,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 +1251,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 +1259,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 +1268,281 @@ 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 = reader.consume(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.consume(Path.from("test")) === undefined); + + 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.consume(Path.from("remote")) !== undefined); + + client.close(); + server.close(); + + // The session that fed the entry is gone, so the entry goes with it. + await until(() => reader.consume(Path.from("remote")) === undefined); + + // The local publish is not the session's to take. + const local = reader.consume(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.consume(Path.from("from-server")) !== undefined); + 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 = reader.consume(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.consume(Path.from("from-client")) !== undefined); + + // 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.consume(Path.from("from-server"))).toBeUndefined(); + + 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.consume(Path.from("blind"))).toBeUndefined(); + + 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.consume().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.consume(), 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(); +}); 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..b0203ae7a2 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); @@ -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); 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..c76cc15f6e --- /dev/null +++ b/js/net/src/origin.test.ts @@ -0,0 +1,350 @@ +import { expect, test } from "bun:test"; +import { Producer as BroadcastProducer } from "./broadcast.ts"; +import { Producer } from "./origin.ts"; +import * as Path from "./path.ts"; + +async function settle() { + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +test("a published broadcast resolves by path", async () => { + const origin = new Producer(); + const consumer = origin.consume(); + + const path = Path.from("room"); + expect(consumer.consume(path)).toBeUndefined(); + + const broadcast = origin.publish(path); + broadcast.createTrack("video"); + + const handle = consumer.consume(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.consume(path)).toBeDefined(); + + broadcast.close(); + await settle(); + expect(consumer.consume(path)).toBeUndefined(); + + 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 = consumer.consume(path); + expect(handle).toBeDefined(); + handle?.close(); + + second.close(); + await settle(); + expect(consumer.consume(path)).toBeUndefined(); + + 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 = consumer.consume(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.consume(Path.from("a"))).toBeUndefined(); + 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 = consumer.consume(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.consume(path)).toBeUndefined(); + + 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 = consumer.consume(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 = consumer.consume(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(); + slot?.front.set(upstream.consume()); + expect(request.active.peek()).toBeDefined(); + + // A second request for the same path shares the answer. + const again = consumer.request(path); + expect(again.active.peek()).toBe(request.active.peek()); + again.close(); + expect(request.active.peek()).toBeDefined(); + + // The last close withdraws the request and releases the front. + request.close(); + expect(origin.requests.peek()?.has(path)).toBe(false); + await settle(); + expect(upstream.closed.peek()).not.toBeUndefined(); + + origin.close(); +}); + +test("requests never appear in announced or consume", async () => { + const origin = new Producer(); + const consumer = origin.consume(); + const path = Path.from("assumed"); + + const request = consumer.request(path); + const upstream = new BroadcastProducer(); + origin.requests.peek()?.get(path)?.front.set(upstream.consume()); + + // An answered request is assumed present, not known live, so it is not availability. + expect(consumer.consume(path)).toBeUndefined(); + 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("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); + + const seeing = origin.attach(true); + expect(consumer.discovery.peek()).toBe(true); + + seeing(); + expect(consumer.discovery.peek()).toBe(false); + blind(); + expect(consumer.discovery.peek()).toBeUndefined(); + + origin.close(); +}); diff --git a/js/net/src/origin.ts b/js/net/src/origin.ts new file mode 100644 index 0000000000..e61cabcf27 --- /dev/null +++ b/js/net/src/origin.ts @@ -0,0 +1,412 @@ +/** + * 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 { type Dispose, type GetPromise, type 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: how many {@link Request} handles want it, and the front the first + * session to answer provided. The front signal outlives a session: the answering session + * clears it when it dies, and the next session answers again, which is what makes a + * request span reconnects. + * + * @internal + */ +export interface RequestSlot { + count: number; + front: 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; it 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. + 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 }); + + closed = new Once(); +} + +/** + * 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 { + #state = new OriginState(); + + /** + * 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); + }); + + // 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); + }); + }); + + return producer; + } + + /** + * Insert a broadcast discovered by a session, taking ownership of `front`. + * + * The returned dispose retracts the entry (unless something newer replaced it) and + * releases the front; 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 replaced = false; + this.#state.remote.mutate((broadcasts) => { + if (!broadcasts) { + replaced = true; + return; + } + broadcasts.get(path)?.close(); + broadcasts.set(path, front); + }); + if (replaced) { + front.close(); + return () => {}; + } + + return () => { + this.#state.remote.mutate((broadcasts) => { + if (broadcasts?.get(path) === front) broadcasts.delete(path); + }); + // Idempotent, so a close by a superseding insert is fine. + 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); + let detached = false; + return () => { + if (detached) return; + detached = true; + this.#sessions(-1, discovery); + }; + } + + #sessions(delta: number, discovery: boolean): void { + this.#state.sessions.update(({ total, discovery: d }) => ({ + total: total + delta, + discovery: d + (discovery ? delta : 0), + })); + } + + /** + * 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; + } + + /** A read handle for this origin. */ + consume(): Consumer { + return makeConsumer(this.#state); + } + + /** 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 front of broadcasts?.values() ?? []) { + front.close(); + } + return undefined; + }); + this.#state.requests.update((requests) => { + for (const slot of requests?.values() ?? []) { + slot.front.peek()?.close(); + slot.front.set(undefined); + } + return undefined; + }); + } +} + +/** + * An open request for a path nothing announced; see {@link Consumer.request}. + * + * @public + */ +export class Request { + /** The requested path. */ + readonly path: Path.Valid; + + /** + * The broadcast a session provided, or undefined while nobody has. + * + * Assumed present rather than known live: the session subscribed blind, so a missing + * broadcast surfaces as a reset on the first track subscription, not here. Drops back + * to undefined when the answering session dies and resolves again once another answers. + */ + readonly active: Getter; + + #dispose: Dispose; + #closed = false; + + /** @internal Created by {@link Consumer.request}. */ + constructor(path: Path.Valid, active: Getter, dispose: Dispose) { + this.path = path; + this.active = active; + this.#dispose = 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(); + } +} + +// 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; + +/** + * 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; + } + + static { + makeConsumer = (state) => new Consumer(state); + } + + /** Settles once the origin closes; see {@link Producer.closed}. */ + get closed(): GetPromise { + return this.#state.closed; + } + + /** + * Whether an attached session supports broadcast discovery. + * + * Undefined while no session is attached (nothing is known yet), true when at least one + * attached session announces broadcasts into the table, false when every attached + * session lacks discovery, where {@link announced} stays silent and consumers should + * {@link request} paths instead of waiting. + */ + 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 = { + peek: () => { + const { total, discovery } = this.#state.sessions.peek(); + return total === 0 ? undefined : discovery > 0; + }, + subscribe: (fn) => + this.#state.sessions.subscribe(({ total, discovery }) => fn(total === 0 ? undefined : discovery > 0)), + changed: ((fn?: (value: boolean | undefined) => void) => { + const map = ({ total, discovery }: { total: number; discovery: number }) => + total === 0 ? undefined : discovery > 0; + if (fn) return this.#state.sessions.changed((value) => fn(map(value))); + return this.#state.sessions.changed().then(map); + }) as Getter["changed"], + }; + + /** + * A handle to the broadcast at `path`, or undefined when nothing routes it. + * + * A local publish wins over a remote broadcast at the same path, so a publisher + * consuming its own path reads its own copy with no round trip. The handle is yours: + * close it when done. The broadcast stays routed for everyone else. + */ + consume(path: Path.Valid): broadcast.Consumer | undefined { + const local = this.#state.local.peek()?.get(path); + if (local) return local.clone(); + return this.#state.remote.peek()?.get(path)?.clone(); + } + + /** + * Ask the attached sessions to provide `path` without waiting for an announcement. + * + * The escape hatch for a path nothing announces: a relay without discovery, or a + * subscribe-immediately consumer that accepts a reset when the path turns out absent. + * Whichever attached session answers first backs {@link Request.active}, blind; the + * request outlives sessions, so a reconnect re-answers it. Close the request when done. + * On a closed origin the request never resolves. + */ + request(path: Path.Valid): Request { + const requests = this.#state.requests.peek(); + if (!requests) { + // Closed origin: a request that can never resolve, mirroring consume's undefined. + return new Request(path, new Signal(undefined), () => {}); + } + + let slot = requests.get(path); + if (!slot) { + const created: RequestSlot = { count: 0, front: new Signal(undefined) }; + slot = created; + this.#state.requests.mutate((map) => { + map?.set(path, created); + }); + } + slot.count += 1; + + const taken = slot; + return new Request(path, taken.front, () => { + taken.count -= 1; + if (taken.count > 0) return; + this.#state.requests.mutate((map) => { + if (map?.get(path) === taken) map.delete(path); + }); + taken.front.peek()?.close(); + taken.front.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 map of [remote, local]) { + for (const [path, front] of map ?? []) { + 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..28935c8023 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..ccfe0ce43c 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()); @@ -215,7 +220,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/watch/src/broadcast.test.ts b/js/watch/src/broadcast.test.ts index 5087691ae2..a072199047 100644 --- a/js/watch/src/broadcast.test.ts +++ b/js/watch/src/broadcast.test.ts @@ -1,33 +1,28 @@ 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 { 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.consume(), 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 +36,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.consume(), 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 +93,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 +108,7 @@ describe("relativeBroadcast", () => { } finally { console.error = error; source.close(); + owner.close(); } }); @@ -113,7 +121,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 +134,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 +151,25 @@ 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(); } }); }); diff --git a/js/watch/src/broadcast.ts b/js/watch/src/broadcast.ts index a3207878f2..f77df3ad81 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,25 @@ export class Broadcast { return active.has(path); } + // Resolve `path` without waiting for an announcement: the routed broadcast when the + // origin already has it (a local publish resolves with no round trip), else a standing + // request answered by whichever session provides it, resolving on a later run. + #blindBroadcast( + effect: Effect, + origin: Moq.Origin.Consumer, + path: Moq.Path.Valid, + ): Moq.Broadcast.Consumer | undefined { + const routed = origin.consume(path); + if (routed) { + effect.cleanup(() => routed.close()); + return routed; + } + + 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 +195,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.#blindBroadcast(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,13 +317,19 @@ 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; + + // Without an announcement gate (reload off, or no session supports discovery), + // resolve blind rather than waiting for an announcement that never comes. + if (!effect.get(this.in.reload) || effect.get(origin.discovery) === false) { + return this.#blindBroadcast(effect, origin, resolved); + } if (!this.#isPathAnnounced(effect, resolved)) return undefined; - const broadcast = conn.consume(resolved); - effect.cleanup(() => broadcast.close()); + const broadcast = origin.consume(resolved); + if (broadcast) effect.cleanup(() => broadcast.close()); return broadcast; } diff --git a/js/watch/src/element.ts b/js/watch/src/element.ts index f0c1daa7f2..34c33dac81 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.signals.computed((effect) => effect.get(this.connection.origin)?.consume()), 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, diff --git a/js/watch/src/video/source.ts b/js/watch/src/video/source.ts index 7815e00ce9..03f4b309f1 100644 --- a/js/watch/src/video/source.ts +++ b/js/watch/src/video/source.ts @@ -36,6 +36,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.Reload`'s `probe`. Optional: without + // it auto-selection falls back to the preference order alone. + probe: Getter; }; type SourceOutput = { @@ -225,6 +230,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 +311,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); From b8800a2bd6a0c4e4252a6cc567e0adb0249509ca Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 6 Aug 2026 13:30:53 -0700 Subject: [PATCH 02/22] fix(js/net): survive overlapping sessions and hold blind answers steady Review fixes, from the Codex pass and the PR comments. All four share a root cause: the origin machinery assumed one feeding session at a time, while its own API (and the coming GOAWAY drain, where old and new sessions overlap on purpose) allows several. - Remote entries keep every session's front per path, newest first: [0] is the route consumers resolve, and disposing it promotes the next, emitting the retract-then-announce restart so consumers re-consume onto the fallback. Previously a second session announcing the same path closed the first session's front, and its own death then black-holed a path a live session still carried (and would never re-announce). - Answering a request goes through origin.answer(), whose withdraw vacates the slot and pokes the requests table, waking standby serving loops so an already-attached session re-answers immediately. Previously only the slot's own signal changed, which reaches requesters but not servers, so a standing request went unanswered forever despite a live standby. A loser also stays eligible: answer() reports whether it took the slot, so a session that lost the race does not mark the path as its own. - Withdrawing the last request handle tears the slot down a microtask later. An effect whose rerun was triggered by the answer resolving closes its old request and takes a new one in the same tick; tearing down in between closed the answered front and re-dialed the subscription forever (the watch blind path flapped on every resolve). - Shared.announcedBroadcast ties its origin-mapping Computed to the returned handle instead of parking it on the connection-lifetime scope, which retained one per call until the connection closed. announce.Broadcast gained the closed promise the cleanup hangs off. - Documented the Shared constructor. Each fix carries the regression test the reviews asked for: two sessions on one path with the newer dying, a standby re-answering a dead answerer's request (wire-level, concurrent sessions), same-tick request re-acquisition, and the watch-level no-flap test. Co-Authored-By: Claude Fable 5 --- js/net/src/announced.ts | 5 ++ js/net/src/connection/forward.ts | 27 ++++---- js/net/src/connection/pool.ts | 20 ++++-- js/net/src/integration.test.ts | 105 +++++++++++++++++++++++++++++++ js/net/src/origin.test.ts | 101 ++++++++++++++++++++++++++++- js/net/src/origin.ts | 104 ++++++++++++++++++++++-------- js/watch/src/broadcast.test.ts | 39 ++++++++++++ 7 files changed, 354 insertions(+), 47 deletions(-) diff --git a/js/net/src/announced.ts b/js/net/src/announced.ts index add37bd568..f011193841 100644 --- a/js/net/src/announced.ts +++ b/js/net/src/announced.ts @@ -368,6 +368,11 @@ export class Broadcast { }); } + /** 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/forward.ts b/js/net/src/connection/forward.ts index 3b720d97c1..168cc61c0a 100644 --- a/js/net/src/connection/forward.ts +++ b/js/net/src/connection/forward.ts @@ -4,7 +4,6 @@ * @module */ import type { Dispose } from "@moq/signals"; -import type * as broadcast from "../broadcast.ts"; import type { Producer as OriginProducer } from "../origin.ts"; import type * as Path from "../path.ts"; import type { Established } from "./established.ts"; @@ -78,8 +77,9 @@ export function forwardAnnounced(conn: Established, origin: OriginProducer): voi * answers again, which is what makes a request span reconnects. */ async function serveRequests(conn: Established, origin: OriginProducer): Promise { - // The fronts this session provided, so a dead session only withdraws its own. - const answered = new Map(); + // The withdraws for the answers this session provided, so a dead session only takes + // back its own. + const answered = new Map(); let dead = false; const closed = conn.closed.then(() => { @@ -93,24 +93,23 @@ async function serveRequests(conn: Established, origin: OriginProducer): Promise for (const [path, slot] of map) { if (answered.has(path) || slot.front.peek() !== undefined) continue; - const front = conn.consume(path); - answered.set(path, front); - slot.front.set(front); + const withdraw = origin.answer(path, conn.consume(path)); + if (withdraw) answered.set(path, withdraw); } - // A withdrawn request already closed the front; just forget our claim on the path. - for (const path of [...answered.keys()]) { - if (!map.has(path)) answered.delete(path); + // A withdrawn request already released the answer; just forget our claim on the path. + for (const [path, withdraw] of [...answered]) { + if (map.has(path)) continue; + answered.delete(path); + withdraw(); } await Promise.race([requests.changed(), closed]); } - // Session gone: withdraw our answers so the next session provides fresh ones. - for (const [path, front] of answered) { - const slot = requests.peek()?.get(path); - if (slot?.front.peek() === front) slot.front.set(undefined); - front.close(); + // 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/pool.ts b/js/net/src/connection/pool.ts index 5df9a8e371..c322c411b4 100644 --- a/js/net/src/connection/pool.ts +++ b/js/net/src/connection/pool.ts @@ -3,7 +3,7 @@ * * @module */ -import { Effect, type Getter, Signal } from "@moq/signals"; +import { Computed, 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"; @@ -80,6 +80,14 @@ export class Shared { 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); @@ -167,10 +175,12 @@ export class Shared { * Close the handle when done. */ announcedBroadcast(path: Path.Valid): Announce.Broadcast { - return new Announce.Broadcast({ - origin: this.#signals.computed((effect) => effect.get(this.#origin)?.consume()), - path, - }); + // The mapping computed belongs to the handle, not this connection: parking it on + // #signals would retain one per call until the whole connection closes. + const origin = new Computed((effect) => effect.get(this.#origin)?.consume()); + const watch = new Announce.Broadcast({ origin, path }); + void watch.closed.then(() => origin.close()); + return watch; } /** Snapshot the live connection's transport counters, or undefined while disconnected. */ diff --git a/js/net/src/integration.test.ts b/js/net/src/integration.test.ts index fe275ed062..b0735bed7f 100644 --- a/js/net/src/integration.test.ts +++ b/js/net/src/integration.test.ts @@ -1546,3 +1546,108 @@ test("origin: a reactive handle follows announcements, republishes, and reconnec 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.consume(Path.from("redundant")) !== undefined); + + // 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 = reader.consume(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.consume().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/origin.test.ts b/js/net/src/origin.test.ts index c76cc15f6e..36e81380a2 100644 --- a/js/net/src/origin.test.ts +++ b/js/net/src/origin.test.ts @@ -282,15 +282,112 @@ test("a request resolves once a front answers, and survives its withdrawal", asy again.close(); expect(request.active.peek()).toBeDefined(); - // The last close withdraws the request and releases the front. + // 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(); - expect(origin.requests.peek()?.has(path)).toBe(false); 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(); + + expect(second.active.peek()).toBe(front); + 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 = consumer.consume(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.consume(path)).toBeUndefined(); + + 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 consume", async () => { const origin = new Producer(); const consumer = origin.consume(); diff --git a/js/net/src/origin.ts b/js/net/src/origin.ts index e61cabcf27..c213835afd 100644 --- a/js/net/src/origin.ts +++ b/js/net/src/origin.ts @@ -33,11 +33,16 @@ class OriginState { // 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; it 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. + // 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()); + 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 @@ -105,32 +110,40 @@ export class Producer { /** * Insert a broadcast discovered by a session, taking ownership of `front`. * - * The returned dispose retracts the entry (unless something newer replaced it) and - * releases the front; call it when the announcement ends or the session dies. Inserting - * into a closed origin releases the front immediately and retracts nothing. + * 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 replaced = false; + let closed = false; this.#state.remote.mutate((broadcasts) => { if (!broadcasts) { - replaced = true; + closed = true; return; } - broadcasts.get(path)?.close(); - broadcasts.set(path, front); + const fronts = broadcasts.get(path); + if (fronts) fronts.unshift(front); + else broadcasts.set(path, [front]); }); - if (replaced) { + if (closed) { front.close(); return () => {}; } return () => { this.#state.remote.mutate((broadcasts) => { - if (broadcasts?.get(path) === front) broadcasts.delete(path); + 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); }); - // Idempotent, so a close by a superseding insert is fine. front.close(); }; } @@ -168,6 +181,36 @@ export class Producer { return this.#state.requests; } + /** + * 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.front.peek() !== undefined) { + front.close(); + return undefined; + } + slot.front.set(front); + + return () => { + if (slot.front.peek() === front) { + slot.front.set(undefined); + // The slot signal only reaches its 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. */ consume(): Consumer { return makeConsumer(this.#state); @@ -185,8 +228,8 @@ export class Producer { }); this.#state.remote.update((broadcasts) => { // Remote broadcasts are somebody else's; only release our handles on them. - for (const front of broadcasts?.values() ?? []) { - front.close(); + for (const fronts of broadcasts?.values() ?? []) { + for (const front of fronts) front.close(); } return undefined; }); @@ -304,7 +347,7 @@ export class Consumer { consume(path: Path.Valid): broadcast.Consumer | undefined { const local = this.#state.local.peek()?.get(path); if (local) return local.clone(); - return this.#state.remote.peek()?.get(path)?.clone(); + return this.#state.remote.peek()?.get(path)?.[0]?.clone(); } /** @@ -337,11 +380,18 @@ export class Consumer { return new Request(path, taken.front, () => { taken.count -= 1; if (taken.count > 0) return; - this.#state.requests.mutate((map) => { - if (map?.get(path) === taken) map.delete(path); + + // 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.front.peek()?.close(); + taken.front.set(undefined); }); - taken.front.peek()?.close(); - taken.front.set(undefined); }); } @@ -373,11 +423,13 @@ export class Consumer { 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 map of [remote, local]) { - for (const [path, front] of map ?? []) { - const suffix = Path.stripPrefix(prefix, path); - if (suffix !== null) next.set(suffix, front); - } + 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) { diff --git a/js/watch/src/broadcast.test.ts b/js/watch/src/broadcast.test.ts index a072199047..72917cd007 100644 --- a/js/watch/src/broadcast.test.ts +++ b/js/watch/src/broadcast.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "bun:test"; import type * as Catalog from "@moq/hang/catalog"; +import * as Moq from "@moq/net"; import { Origin, Path } from "@moq/net"; import { Effect } from "@moq/signals"; import { Broadcast } from "./broadcast"; @@ -173,3 +174,41 @@ describe("relativeBroadcast", () => { } }); }); + +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.consume(), + 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(); + }); +}); From 2e86afb8bfd961b4f278f0fa39a417b4b11436b9 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 6 Aug 2026 13:43:03 -0700 Subject: [PATCH 03/22] fix(js/net): follow the table before discovery, and lend origins without ownership Second review round, all three on the API contract: - An origin-mode Broadcast handle now follows the announcement table unconditionally and treats discovery only as the trigger for the blind request fallback. Previously it returned early while no session was attached (discovery undefined), so a local publish never resolved without a connection, and the no-discovery branch consumed the local route once, non-reactively, so a republish left the handle holding the superseded broadcast. The table is knowledge and the request is assumption, so the table wins when both resolve. - BroadcastProps is a union requiring exactly one of connection or origin: a call with neither (a permanently dead handle) or both (a silently ignored connection) no longer compiles. - Shared lends its origin as the new Origin.Table, the non-owning surface (publish, consume, closed). Producer implements it; the borrowing type cannot express close(), which would have torn the shared origin down under every other handle on the URL. Regression tests: loopback with no session attached including the republish swap and unpublish, local-route-wins on a no-discovery origin, and the BroadcastProps type error. Co-Authored-By: Claude Fable 5 --- js/net/src/announced.test.ts | 62 ++++++++++++++++++++++ js/net/src/announced.ts | 97 +++++++++++++++++++---------------- js/net/src/connection/pool.ts | 7 +-- js/net/src/origin.ts | 22 +++++++- js/publish/src/broadcast.ts | 2 +- 5 files changed, 141 insertions(+), 49 deletions(-) diff --git a/js/net/src/announced.test.ts b/js/net/src/announced.test.ts index 1cd3dc31fc..7af5555798 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: origin.consume(), 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: origin.consume(), 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 f011193841..705b5b34f3 100644 --- a/js/net/src/announced.ts +++ b/js/net/src/announced.ts @@ -135,30 +135,38 @@ export class Consumer { const warnedNoDiscovery = new WeakSet(); /** - * What to watch, for {@link Broadcast}. Provide exactly one of `connection` or `origin`. + * 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; - - /** - * The origin to watch instead of a session; wins when both are given. - * - * 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. On an origin whose sessions lack - * discovery it falls back to a standing request, so `active` means assumed present. - */ - origin?: 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 @@ -311,38 +319,25 @@ export class Broadcast { const origin = effect.get(source); if (!origin) return; - const discovery = effect.get(origin.discovery); - // Nothing is attached yet, so nothing can resolve; wait rather than request from nobody. - if (discovery === undefined) return; - - if (!discovery) { - // No announcement will ever arrive. Loopback still works: prefer the routed - // broadcast (a local publish), else stand a request for whichever session answers. - const routed = origin.consume(this.path); - if (routed) { - effect.cleanup(() => routed.close()); - effect.set(this.#active, routed, undefined); - return; - } - - const request = origin.request(this.path); - effect.cleanup(() => request.close()); - effect.run((nested) => { - nested.set(this.#active, nested.get(request.active), undefined); - }); - 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; const offline = () => { - const mine = current; current?.close(); current = undefined; - // Only clear what this run put there; see the session path above. - if (this.#active.peek() === mine) this.#active.set(undefined); + table.set(undefined); }; effect.cleanup(offline); @@ -357,7 +352,7 @@ export class Broadcast { if (event.active) { current?.close(); current = origin.consume(this.path); - this.#active.set(current); + table.set(current); } else { offline(); } @@ -366,6 +361,20 @@ export class Broadcast { // The origin closed, or this run was torn down. Either way nothing routes the path. offline(); }); + + // Blind fallback: while every attached session lacks discovery, nothing remote will + // ever reach the table, so stand a request for whichever session answers. Gated on + // exactly `false`: with no session there is nobody to ask, and with discovery the + // announcement 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. */ diff --git a/js/net/src/connection/pool.ts b/js/net/src/connection/pool.ts index c322c411b4..b4220b63bb 100644 --- a/js/net/src/connection/pool.ts +++ b/js/net/src/connection/pool.ts @@ -69,10 +69,11 @@ export class Shared { /** * The shared origin for the current URL, or undefined while disabled or URL-less. * - * Publish into it, consume from it, or stand requests on it; it is the same origin every - * other handle on this URL uses, and it spans the connection's reconnects. + * 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 origin: Getter; readonly #status = new Signal("disconnected"); readonly #established = new Signal(undefined); diff --git a/js/net/src/origin.ts b/js/net/src/origin.ts index c213835afd..7bb446ec39 100644 --- a/js/net/src/origin.ts +++ b/js/net/src/origin.ts @@ -56,6 +56,26 @@ class OriginState { closed = new Once(); } +/** + * 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; + + /** Publish a broadcast at `path`, returning its producer; see {@link Producer.publish}. */ + publish(path: Path.Valid): broadcast.Producer; + + /** A read handle for this origin; see {@link Producer.consume}. */ + consume(): Consumer; +} + /** * The write side of an origin: publish broadcasts by path. * @@ -67,7 +87,7 @@ class OriginState { * * @public */ -export class Producer { +export class Producer implements Table { #state = new OriginState(); /** diff --git a/js/publish/src/broadcast.ts b/js/publish/src/broadcast.ts index 28935c8023..cfff9d2b78 100644 --- a/js/publish/src/broadcast.ts +++ b/js/publish/src/broadcast.ts @@ -10,7 +10,7 @@ import { type Kind, Rendition } from "./rendition"; export type BroadcastInput = { // The origin to publish into. Independent of any connection: whichever sessions serve the // origin announce the broadcast, and it survives their reconnects. - origin: Getter; + origin: Getter; // Whether to publish the broadcast. Defaults to false so nothing is announced until ready. enabled: Getter; From 4558b86b991327b022098a11a8ea966794932d59 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 6 Aug 2026 14:09:45 -0700 Subject: [PATCH 04/22] feat(js/net)!: make Request the one way to consume by path, and hide the shared session API revision from review discussion. - Request.active is now table-first: it resolves whatever the table routes (a local publish with no round trip, or any announced broadcast, swapping on a republish) and falls back to a session's blind answer only when nothing does. Resolution is derived per access, so a routed path resolves synchronously. That makes request() the single per-path consume primitive. - Origin.Consumer.consume(path) is gone from the public surface, renamed to an internal get(): a one-shot snapshot that neither waits nor follows a republish is a footgun next to a reactive handle, the same reasoning that removed the sync lookup on the Rust side. - Origin.Table grows the full borrowed surface (publish, request, announced, discovery, closed) and Producer implements it with passthroughs, so holding either side never needs the consume().x() stutter. - Shared no longer exposes the established session: it is shared, so no handle may close or reconfigure it, and everything else it offered is reachable through the origin. A transport getter covers the one legitimate read (labeling the negotiated transport); stats() stays as the session-aggregate snapshot pending per-broadcast estimates (#2709). - watch's Sync takes the probe estimates as its own input instead of reaching through a connection, matching Video.Source; the blind/gated resolution in watch collapses onto request(); the demo stats page consumes node broadcasts through the origin. Also filed #2708 (lazy announce interest, both languages) from the same discussion. Co-Authored-By: Claude Fable 5 --- demo/web/src/index.ts | 4 +- demo/web/src/publish.ts | 4 +- demo/web/src/stats.ts | 19 +++-- js/moq-boy/src/game.ts | 4 +- js/net/src/announced.test.ts | 4 +- js/net/src/announced.ts | 8 +- js/net/src/connection/pool.test.ts | 22 ++--- js/net/src/connection/pool.ts | 25 +++--- js/net/src/connection/reload.test.ts | 10 +-- js/net/src/connection/reload.ts | 4 +- js/net/src/integration.test.ts | 30 +++---- js/net/src/origin.test.ts | 30 +++---- js/net/src/origin.ts | 122 ++++++++++++++++++++++----- js/publish/src/element.ts | 5 +- js/watch/src/broadcast.test.ts | 6 +- js/watch/src/broadcast.ts | 34 +++----- js/watch/src/element.ts | 4 +- js/watch/src/sync.ts | 12 +-- 18 files changed, 213 insertions(+), 134 deletions(-) diff --git a/demo/web/src/index.ts b/demo/web/src/index.ts index 947b4bb987..ba61088032 100644 --- a/demo/web/src/index.ts +++ b/demo/web/src/index.ts @@ -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 f746d4f496..d7208789eb 100644 --- a/demo/web/src/stats.ts +++ b/demo/web/src/stats.ts @@ -90,12 +90,12 @@ const connection = new Net.Connection.Shared({ url: relayUrl }); 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/js/moq-boy/src/game.ts b/js/moq-boy/src/game.ts index 6020f48379..374f5084c1 100644 --- a/js/moq-boy/src/game.ts +++ b/js/moq-boy/src/game.ts @@ -116,7 +116,7 @@ export class Game { // Video pipeline. this.broadcast = new Watch.Broadcast({ - origin: config.origin.consume(), + origin: config.origin, name: Moq.Path.from(`${gamePrefix}/${sessionId}`), enabled: true, }); @@ -141,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, }); diff --git a/js/net/src/announced.test.ts b/js/net/src/announced.test.ts index 7af5555798..9e254bfc57 100644 --- a/js/net/src/announced.test.ts +++ b/js/net/src/announced.test.ts @@ -54,7 +54,7 @@ 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: origin.consume(), path }); + const watch = new Announce.Broadcast({ origin, path }); await settle(); expect(watch.active.peek()).toBeUndefined(); @@ -91,7 +91,7 @@ test("the local route wins over a blind request on a no-discovery origin", async const detach = origin.attach(false); const broadcast = origin.publish(path); - const watch = new Announce.Broadcast({ origin: origin.consume(), path }); + const watch = new Announce.Broadcast({ origin, path }); await settle(); expect(watch.active.peek()).toBeDefined(); diff --git a/js/net/src/announced.ts b/js/net/src/announced.ts index 705b5b34f3..194ef900a0 100644 --- a/js/net/src/announced.ts +++ b/js/net/src/announced.ts @@ -6,7 +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 { Consumer as OriginConsumer } from "./origin.js"; +import type { Table as OriginTable } from "./origin.js"; import * as Path from "./path.js"; /** @@ -163,7 +163,7 @@ export type BroadcastProps = { * attached session lacks discovery it falls back to a standing request, so * `active` means assumed present. */ - origin: GetterInit; + origin: GetterInit; connection?: undefined; } ); @@ -315,7 +315,7 @@ export class Broadcast { // 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 { + #runOrigin(effect: Effect, source: Getter): void { const origin = effect.get(source); if (!origin) return; @@ -351,7 +351,7 @@ export class Broadcast { if (event.active) { current?.close(); - current = origin.consume(this.path); + current = origin.get(this.path); table.set(current); } else { offline(); diff --git a/js/net/src/connection/pool.test.ts b/js/net/src/connection/pool.test.ts index 6c498aba00..b0887251e9 100644 --- a/js/net/src/connection/pool.test.ts +++ b/js/net/src/connection/pool.test.ts @@ -54,15 +54,15 @@ test("two handles on one URL share a connection and an origin", async () => { const first = new Shared({ url, linger }); const second = new Shared({ url }); - await waitUntil(() => first.established.peek() !== undefined); - await waitUntil(() => second.established.peek() !== undefined); + 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.established.peek()).not.toBeUndefined(); + expect(second.status.peek()).toBe("connected"); second.close(); }); @@ -71,7 +71,7 @@ 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.established.peek() !== undefined); + await waitUntil(() => handle.status.peek() === "connected"); const origin = handle.origin.peek(); handle.close(); @@ -83,7 +83,7 @@ test("the connection closes after the last handle and the linger window", async // The next handle dials fresh. const next = new Shared({ url, linger }); - await waitUntil(() => next.established.peek() !== undefined); + await waitUntil(() => next.status.peek() === "connected"); expect(dials.count()).toBe(2); next.close(); }); @@ -92,7 +92,7 @@ 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.established.peek() !== undefined); + await waitUntil(() => first.status.peek() === "connected"); const origin = first.origin.peek(); first.close(); @@ -108,19 +108,19 @@ test("disabling a handle releases its share", async () => { const toggled = new Shared({ url, linger }); const steady = new Shared({ url }); - await waitUntil(() => toggled.established.peek() !== undefined); + await waitUntil(() => toggled.status.peek() === "connected"); toggled.enabled.set(false); await waitUntil(() => toggled.origin.peek() === undefined); - expect(toggled.established.peek()).toBeUndefined(); + expect(toggled.status.peek()).not.toBe("connected"); // The steady handle keeps the connection alive through the toggle. await expired(); - expect(steady.established.peek()).not.toBeUndefined(); + expect(steady.status.peek()).toBe("connected"); // Re-enabling rejoins the shared connection. toggled.enabled.set(true); - await waitUntil(() => toggled.established.peek() !== undefined); + await waitUntil(() => toggled.status.peek() === "connected"); expect(toggled.origin.peek()).toBe(steady.origin.peek()); toggled.close(); @@ -154,7 +154,7 @@ test("a publish through one handle resolves locally for another", async () => { broadcast.createTrack("chat"); // Loopback: the shared origin serves the page's own publish with no round trip. - const handle = watcher.origin.peek()?.consume().consume(Path.from("mine")); + const handle = watcher.origin.peek()?.get(Path.from("mine")); expect(handle).toBeDefined(); handle?.close(); diff --git a/js/net/src/connection/pool.ts b/js/net/src/connection/pool.ts index b4220b63bb..eedd701e5c 100644 --- a/js/net/src/connection/pool.ts +++ b/js/net/src/connection/pool.ts @@ -3,13 +3,14 @@ * * @module */ -import { Computed, Effect, type Getter, Signal } from "@moq/signals"; +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; @@ -60,8 +61,13 @@ export class Shared { /** Current status of the shared connection. */ readonly status: Getter; - /** The currently established session, or undefined while disconnected. */ - readonly established: 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; @@ -93,9 +99,9 @@ export class Shared { this.url = Signal.from(props?.url); this.enabled = Signal.from(props?.enabled ?? true); this.status = this.#status; - this.established = this.#established; this.probe = this.#probe; this.origin = this.#origin; + this.transport = this.#signals.computed((effect) => effect.get(this.#established)?.transport); const linger = props?.linger; @@ -140,7 +146,7 @@ export class Shared { const origin = effect.get(this.#origin); if (!origin) return; - const upstream = origin.consume().announced(prefix); + const upstream = origin.announced(prefix); effect.cleanup(() => upstream.close()); // Track what this origin announced so a URL switch retracts it. @@ -176,12 +182,9 @@ export class Shared { * Close the handle when done. */ announcedBroadcast(path: Path.Valid): Announce.Broadcast { - // The mapping computed belongs to the handle, not this connection: parking it on - // #signals would retain one per call until the whole connection closes. - const origin = new Computed((effect) => effect.get(this.#origin)?.consume()); - const watch = new Announce.Broadcast({ origin, path }); - void watch.closed.then(() => origin.close()); - return watch; + // 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. */ diff --git a/js/net/src/connection/reload.test.ts b/js/net/src/connection/reload.test.ts index 4628874e54..fbaefe4a53 100644 --- a/js/net/src/connection/reload.test.ts +++ b/js/net/src/connection/reload.test.ts @@ -274,17 +274,17 @@ test("origins span reconnects: local re-announces, remote re-populates", async ( 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.consume(Path.from("remote")) !== undefined); - await waitUntil(() => servers[0]?.saw.consume().consume(Path.from("mine")) !== undefined); + await waitUntil(() => reader.get(Path.from("remote")) !== undefined); + await waitUntil(() => servers[0]?.saw.get(Path.from("mine")) !== undefined); // Kill the session: the remote entry retracts, the local publish stays put. servers[0]?.session.close(); - await waitUntil(() => reader.consume(Path.from("remote")) === undefined); + await waitUntil(() => reader.get(Path.from("remote")) === undefined); // The reconnect re-announces the (untouched) publish and re-populates the table. await waitUntil(() => servers.length > 1); - await waitUntil(() => reader.consume(Path.from("remote")) !== undefined); - await waitUntil(() => servers[1]?.saw.consume().consume(Path.from("mine")) !== undefined); + await waitUntil(() => reader.get(Path.from("remote")) !== undefined); + await waitUntil(() => servers[1]?.saw.get(Path.from("mine")) !== undefined); } finally { reload.close(); publishOrigin.close(); diff --git a/js/net/src/connection/reload.ts b/js/net/src/connection/reload.ts index e19555027f..c4419a3c5f 100644 --- a/js/net/src/connection/reload.ts +++ b/js/net/src/connection/reload.ts @@ -310,7 +310,7 @@ export class Reload { 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.consume().announced(prefix); + if (this.subscribe) return this.subscribe.announced(prefix); const producer = new Announce.Producer(prefix); const consumer = producer.consume(); @@ -378,7 +378,7 @@ export class Reload { */ 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.consume(), path }); + 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/integration.test.ts b/js/net/src/integration.test.ts index b0735bed7f..51527d9727 100644 --- a/js/net/src/integration.test.ts +++ b/js/net/src/integration.test.ts @@ -1312,7 +1312,7 @@ async function runOriginFlow(protocol: string, version?: number) { expect(await announced.next()).toEqual({ path: Path.from("test"), active: true }); // Consuming through the origin reaches the wire. - const remote = reader.consume(Path.from("test")); + const remote = reader.get(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"); @@ -1320,7 +1320,7 @@ async function runOriginFlow(protocol: string, version?: number) { // 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.consume(Path.from("test")) === undefined); + await until(() => reader.get(Path.from("test")) === undefined); await serving; track.close(); @@ -1354,16 +1354,16 @@ test("origin: remote entries retract when the session dies, local ones survive", const mine = clientOrigin.publish(Path.from("mine")); const reader = clientOrigin.consume(); - await until(() => reader.consume(Path.from("remote")) !== undefined); + await until(() => reader.get(Path.from("remote")) !== undefined); client.close(); server.close(); // The session that fed the entry is gone, so the entry goes with it. - await until(() => reader.consume(Path.from("remote")) === undefined); + await until(() => reader.get(Path.from("remote")) === undefined); // The local publish is not the session's to take. - const local = reader.consume(Path.from("mine")); + const local = reader.get(Path.from("mine")); expect(local).toBeDefined(); local?.close(); @@ -1389,7 +1389,7 @@ test("origin: one origin on both directions consumes locally and never echoes", { const remote = serverSees.publish(Path.from("from-server")); const reader = shared.consume(); - await until(() => reader.consume(Path.from("from-server")) !== undefined); + await until(() => reader.get(Path.from("from-server")) !== undefined); remote.close(); } @@ -1397,7 +1397,7 @@ test("origin: one origin on both directions consumes locally and never echoes", const mine = shared.publish(Path.from("from-client")); mine.createTrack("chat"); const reader = shared.consume(); - const loopback = reader.consume(Path.from("from-client")); + const loopback = reader.get(Path.from("from-client")); if (!loopback) throw new Error("expected a local route"); const track = loopback.subscribe("chat"); expect(track).toBeDefined(); @@ -1406,13 +1406,13 @@ test("origin: one origin on both directions consumes locally and never echoes", // The server sees the client's broadcast once, as its own remote entry. const serverReader = serverSees.consume(); - await until(() => serverReader.consume(Path.from("from-client")) !== undefined); + await until(() => serverReader.get(Path.from("from-client")) !== undefined); // 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.consume(Path.from("from-server"))).toBeUndefined(); + expect(serverReader.get(Path.from("from-server"))).toBeUndefined(); mine.close(); client.close(); @@ -1445,7 +1445,7 @@ test("origin: a request resolves blind on a relay without discovery", async () = expect(reader.discovery.peek()).toBe(false); // Nothing announced, so the table stays empty; a request is the only way through. - expect(reader.consume(Path.from("blind"))).toBeUndefined(); + expect(reader.get(Path.from("blind"))).toBeUndefined(); const request = reader.request(Path.from("blind")); await until(() => request.active.peek() !== undefined); @@ -1489,7 +1489,7 @@ test("origin: a request is re-answered by the next session", async () => { subscribe: clientOrigin, }); - const request = clientOrigin.consume().request(Path.from("standing")); + const request = clientOrigin.request(Path.from("standing")); try { await until(() => request.active.peek() !== undefined); @@ -1520,7 +1520,7 @@ test("origin: a reactive handle follows announcements, republishes, and reconnec accept(pair.server, url, { publish: serverOrigin.consume() }), ]); - const watch = new Announce.Broadcast({ origin: clientOrigin.consume(), path: Path.from("show") }); + 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); @@ -1574,14 +1574,14 @@ test("origin: overlapping sessions carrying one path fail over", async () => { const second = await setup(); const reader = clientOrigin.consume(); - await until(() => reader.consume(Path.from("redundant")) !== undefined); + await until(() => reader.get(Path.from("redundant")) !== undefined); // 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 = reader.consume(Path.from("redundant")); + const remote = reader.get(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"); @@ -1621,7 +1621,7 @@ test("origin: a standby session re-answers a request when the answerer dies", as }; // The first session answers the standing request; the second attaches as a standby. - const request = clientOrigin.consume().request(Path.from("blind")); + 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"); diff --git a/js/net/src/origin.test.ts b/js/net/src/origin.test.ts index 36e81380a2..d717b07f21 100644 --- a/js/net/src/origin.test.ts +++ b/js/net/src/origin.test.ts @@ -12,12 +12,12 @@ test("a published broadcast resolves by path", async () => { const consumer = origin.consume(); const path = Path.from("room"); - expect(consumer.consume(path)).toBeUndefined(); + expect(consumer.get(path)).toBeUndefined(); const broadcast = origin.publish(path); broadcast.createTrack("video"); - const handle = consumer.consume(path); + const handle = consumer.get(path); expect(handle).toBeDefined(); // The handle reaches the published tracks. @@ -36,11 +36,11 @@ test("closing the producer unpublishes the path", async () => { const path = Path.from("room"); const broadcast = origin.publish(path); - expect(consumer.consume(path)).toBeDefined(); + expect(consumer.get(path)).toBeDefined(); broadcast.close(); await settle(); - expect(consumer.consume(path)).toBeUndefined(); + expect(consumer.get(path)).toBeUndefined(); origin.close(); }); @@ -57,13 +57,13 @@ test("a stale broadcast closing does not unpublish a republished path", async () first.close(); await settle(); - const handle = consumer.consume(path); + const handle = consumer.get(path); expect(handle).toBeDefined(); handle?.close(); second.close(); await settle(); - expect(consumer.consume(path)).toBeUndefined(); + expect(consumer.get(path)).toBeUndefined(); origin.close(); }); @@ -88,7 +88,7 @@ test("a consumer clone keeps a superseded broadcast alive", async () => { const path = Path.from("room"); const first = origin.publish(path); - const mine = consumer.consume(path); + const mine = consumer.get(path); expect(mine).toBeDefined(); origin.publish(path); @@ -119,7 +119,7 @@ test("closing the origin closes every routed broadcast", async () => { expect(a.closed.peek()).toBe(abort); expect(b.closed.peek()).toBe(abort); - expect(consumer.consume(Path.from("a"))).toBeUndefined(); + expect(consumer.get(Path.from("a"))).toBeUndefined(); expect(() => origin.publish(Path.from("late"))).toThrow(); // Idempotent: the first close wins. @@ -178,7 +178,7 @@ test("a remote entry resolves by path and retracts on dispose", async () => { const upstream = new BroadcastProducer(); const dispose = origin.insertRemote(path, upstream.consume()); - const handle = consumer.consume(path); + const handle = consumer.get(path); expect(handle).toBeDefined(); handle?.close(); @@ -188,7 +188,7 @@ test("a remote entry resolves by path and retracts on dispose", async () => { dispose(); expect(await announced.next()).toEqual({ path, active: false }); - expect(consumer.consume(path)).toBeUndefined(); + expect(consumer.get(path)).toBeUndefined(); announced.close(); upstream.close(); @@ -208,7 +208,7 @@ test("a local publish shadows a remote entry", async () => { local.createTrack("local-track"); // Local wins: the handle reaches the local track, not the remote one. - const handle = consumer.consume(path); + const handle = consumer.get(path); const track = handle?.subscribe("local-track"); expect(track).toBeDefined(); track?.close(); @@ -220,7 +220,7 @@ test("a local publish shadows a remote entry", async () => { // Dropping the local publish falls back to the remote entry without a retraction. local.close(); - const back = consumer.consume(path); + const back = consumer.get(path); expect(back).toBeDefined(); back?.close(); @@ -341,7 +341,7 @@ test("disposing the newest remote route promotes the fallback", async () => { expect(await announced.next()).toEqual({ path, active: false }); expect(await announced.next()).toEqual({ path, active: true }); - const handle = consumer.consume(path); + const handle = consumer.get(path); const track = handle?.subscribe("chat"); expect(track).toBeDefined(); track?.close(); @@ -352,7 +352,7 @@ test("disposing the newest remote route promotes the fallback", async () => { disposeOlder(); await settle(); - expect(consumer.consume(path)).toBeUndefined(); + expect(consumer.get(path)).toBeUndefined(); announced.close(); origin.close(); @@ -398,7 +398,7 @@ test("requests never appear in announced or consume", async () => { origin.requests.peek()?.get(path)?.front.set(upstream.consume()); // An answered request is assumed present, not known live, so it is not availability. - expect(consumer.consume(path)).toBeUndefined(); + expect(consumer.get(path)).toBeUndefined(); const announced = consumer.announced(); origin.publish(Path.from("real")); expect(await announced.next()).toEqual({ path: Path.from("real"), active: true }); diff --git a/js/net/src/origin.ts b/js/net/src/origin.ts index 7bb446ec39..fe3bcc6d80 100644 --- a/js/net/src/origin.ts +++ b/js/net/src/origin.ts @@ -69,11 +69,20 @@ export interface Table { /** Settles once the origin closes; see {@link Producer.closed}. */ readonly closed: GetPromise; + /** Whether an attached session supports discovery; 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; - /** A read handle for this origin; see {@link Producer.consume}. */ - consume(): Consumer; + /** 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; + + /** A one-shot lookup; see {@link Consumer.get}. @internal */ + get(path: Path.Valid): broadcast.Consumer | undefined; } /** @@ -231,11 +240,37 @@ export class Producer implements Table { }; } - /** A read handle for this origin. */ + /** A read handle for this origin, the side a connection's `publish` option borrows. */ consume(): Consumer { return makeConsumer(this.#state); } + /** Whether an attached session supports discovery; 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); + } + + /** The available broadcasts under `prefix`, as a live stream; see {@link Consumer.announced}. */ + announced(prefix?: Path.Valid): announce.Consumer { + return this.#reader.announced(prefix); + } + + /** A one-shot lookup; see {@link Consumer.get}. @internal */ + get(path: Path.Valid): broadcast.Consumer | undefined { + return this.#reader.get(path); + } + + // The reader backing the passthroughs, so holding a Producer never requires the + // consume().x() stutter for everyday reads. + get #reader(): Consumer { + return makeConsumer(this.#state); + } + /** Close the origin, every broadcast it still routes, and its announcement streams. Idempotent. */ close(abort?: Error) { if (this.#state.closed.peek() !== undefined) return; @@ -273,11 +308,15 @@ export class Request { readonly path: Path.Valid; /** - * The broadcast a session provided, or undefined while nobody has. + * 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. * - * Assumed present rather than known live: the session subscribed blind, so a missing - * broadcast surfaces as a reset on the first track subscription, not here. Drops back - * to undefined when the answering session dies and resolves again once another answers. + * Borrowed, not yours to close: take a `clone()` for a lifetime of your own. */ readonly active: Getter; @@ -358,31 +397,33 @@ export class Consumer { }; /** - * A handle to the broadcast at `path`, or undefined when nothing routes it. + * A one-shot handle to the broadcast at `path`, or undefined when nothing routes it. + * + * A snapshot: it neither waits for the path to appear nor follows a republish, which is + * why it is not public. Use {@link request} for a resolution that cannot race. A local + * publish wins over a remote broadcast. The handle is yours: close it when done. * - * A local publish wins over a remote broadcast at the same path, so a publisher - * consuming its own path reads its own copy with no round trip. The handle is yours: - * close it when done. The broadcast stays routed for everyone else. + * @internal */ - consume(path: Path.Valid): broadcast.Consumer | undefined { + get(path: Path.Valid): broadcast.Consumer | undefined { const local = this.#state.local.peek()?.get(path); if (local) return local.clone(); return this.#state.remote.peek()?.get(path)?.[0]?.clone(); } /** - * Ask the attached sessions to provide `path` without waiting for an announcement. + * Resolve `path`, without waiting for an announcement. * - * The escape hatch for a path nothing announces: a relay without discovery, or a - * subscribe-immediately consumer that accepts a reset when the path turns out absent. - * Whichever attached session answers first backs {@link Request.active}, blind; the - * request outlives sessions, so a reconnect re-answers it. Close the request when done. - * On a closed origin the request never resolves. + * 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. */ request(path: Path.Valid): Request { const requests = this.#state.requests.peek(); if (!requests) { - // Closed origin: a request that can never resolve, mirroring consume's undefined. + // Closed origin: a request that can never resolve. return new Request(path, new Signal(undefined), () => {}); } @@ -397,7 +438,7 @@ export class Consumer { slot.count += 1; const taken = slot; - return new Request(path, taken.front, () => { + return new Request(path, this.#resolved(path, taken), () => { taken.count -= 1; if (taken.count > 0) return; @@ -415,6 +456,47 @@ export class Consumer { }); } + // The reactive view behind Request.active: the table's route for `path` when it has + // one (knowledge beats assumption), else the slot's blind answer. Derived per access + // over the backing signals, so a routed path resolves synchronously. + #resolved(path: Path.Valid, slot: RequestSlot): Getter { + const resolve = () => { + const local = this.#state.local.peek()?.get(path); + if (local) return local; + return this.#state.remote.peek()?.get(path)?.[0] ?? slot.front.peek(); + }; + const sources = [this.#state.local, this.#state.remote, slot.front] as const; + + return { + peek: resolve, + subscribe: (fn) => { + const notify = () => fn(resolve()); + const disposes = sources.map((source) => source.subscribe(notify)); + return () => { + for (const dispose of disposes) dispose(); + }; + }, + changed: ((fn?: (value: broadcast.Consumer | undefined) => void) => { + if (fn) { + const notify = () => fn(resolve()); + // Spelled out: mapping over the tuple trips overload resolution on the + // union of signal types. + const disposes = [ + sources[0].changed(notify), + sources[1].changed(notify), + sources[2].changed(notify), + ]; + return () => { + for (const dispose of disposes) dispose(); + }; + } + // Spelled out: mapping `.changed()` over the tuple trips overload resolution + // on the union of signal types. + return Promise.race([sources[0].changed(), sources[1].changed(), sources[2].changed()]).then(resolve); + }) as Getter["changed"], + }; + } + /** * The available broadcasts under `prefix`, as a live stream: everything currently * routed arrives first as `active`, then additions and removals as they happen. Paths diff --git a/js/publish/src/element.ts b/js/publish/src/element.ts index ccfe0ce43c..baba274615 100644 --- a/js/publish/src/element.ts +++ b/js/publish/src/element.ts @@ -186,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 () => { @@ -198,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; diff --git a/js/watch/src/broadcast.test.ts b/js/watch/src/broadcast.test.ts index 72917cd007..537338b7ca 100644 --- a/js/watch/src/broadcast.test.ts +++ b/js/watch/src/broadcast.test.ts @@ -17,7 +17,7 @@ function origin(paths: string[]): Origin.Producer { function broadcast(name: string, paths: string[] = [name]): { source: Broadcast; owner: Origin.Producer } { const owner = origin(paths); const source = new Broadcast({ - origin: owner.consume(), + origin: owner, name: Path.from(name), enabled: true, reload: false, @@ -77,7 +77,7 @@ describe("relativeBroadcast", () => { const manualCatalog = (catalog: Catalog.Root) => { const owner = origin(["a/b"]); const source = new Broadcast({ - origin: owner.consume(), + origin: owner, name: Path.from("a/b"), enabled: true, reload: false, @@ -182,7 +182,7 @@ describe("blind resolution", () => { // answer, not close the request and re-dial forever. const owner = new Origin.Producer(); const source = new Broadcast({ - origin: owner.consume(), + origin: owner, name: Path.from("blind.hang"), enabled: true, reload: false, diff --git a/js/watch/src/broadcast.ts b/js/watch/src/broadcast.ts index f77df3ad81..d9bfa1b0ec 100644 --- a/js/watch/src/broadcast.ts +++ b/js/watch/src/broadcast.ts @@ -59,7 +59,7 @@ type Status = "offline" | "loading" | "live"; export type BroadcastInput = { // 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; + origin: Getter; // Whether to start downloading the broadcast. // Defaults to false so you can make sure everything is ready before starting. @@ -169,20 +169,14 @@ export class Broadcast { return active.has(path); } - // Resolve `path` without waiting for an announcement: the routed broadcast when the - // origin already has it (a local publish resolves with no round trip), else a standing - // request answered by whichever session provides it, resolving on a later run. - #blindBroadcast( + // 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.Consumer, + origin: Moq.Origin.Table, path: Moq.Path.Valid, ): Moq.Broadcast.Consumer | undefined { - const routed = origin.consume(path); - if (routed) { - effect.cleanup(() => routed.close()); - return routed; - } - const request = origin.request(path); effect.cleanup(() => request.close()); return effect.get(request.active); @@ -202,7 +196,7 @@ export class Broadcast { // No announcement gate: subscribe immediately. if (!effect.get(this.in.reload)) { - effect.set(this.#out.active, this.#blindBroadcast(effect, origin, name), undefined); + effect.set(this.#out.active, this.#requestBroadcast(effect, origin, name), undefined); return; } @@ -321,16 +315,14 @@ export class Broadcast { if (!origin) return undefined; // Without an announcement gate (reload off, or no session supports discovery), - // resolve blind rather than waiting for an announcement that never comes. - if (!effect.get(this.in.reload) || effect.get(origin.discovery) === false) { - return this.#blindBroadcast(effect, origin, resolved); + // 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; } - if (!this.#isPathAnnounced(effect, resolved)) return undefined; - - const broadcast = origin.consume(resolved); - if (broadcast) 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 34c33dac81..4772823c53 100644 --- a/js/watch/src/element.ts +++ b/js/watch/src/element.ts @@ -156,7 +156,7 @@ export default class MoqWatch extends HTMLElement { this.signals.cleanup(() => this.connection.close()); this.broadcast = new Broadcast({ - origin: this.signals.computed((effect) => effect.get(this.connection.origin)?.consume()), + origin: this.connection.origin, enabled: this.#enabled, name: this.#name, reload: this.#reload, @@ -192,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; From 560a75b260d8ec281d7d5f90b0bd34990b94c687 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 6 Aug 2026 15:24:11 -0700 Subject: [PATCH 05/22] fix(js/net): keep shared connections and discovery recoverable Three failures found in adversarial review of the origin reshape, each of which leaves a page permanently dark after a condition it should ride out. A pooled connection gave up for good after a 10s outage. `Shared` built its `Reload` with the default retry window, which is short on purpose: it assumes whoever built the loop observes `closed` and reacts. Nothing observes a pooled loop, so once the window expired every handle on that URL, and every handle taken later, was bound to a loop that had stopped. Pooled loops now retry without a deadline. An auth rejection is still terminal, and now also evicts the entry so the next handle dials fresh instead of joining a dead loop. A relay may refuse or reset the announce stream without closing the session, and the forwarder swallowed that: it retracted the session's entries and exited while the origin still counted the session as discovering. Every announcement-gated consumer then waited forever for a table nothing could fill. Discovery ending under a live session now downgrades the attachment to non-discovery (and logs the cause), so `origin.discovery` flips to false and gated consumers fall back to the standing requests the session still answers. `Origin.Table.get()` was a one-shot snapshot that races a republish, kept because `Announce.Broadcast` needed to resolve an announced path. An `@internal` tag does not strip it from the emitted declarations, so it shipped as a second, race-prone way to consume by path. It is gone: the announce-gated follower holds a request across the announced window and resolves through it, which is race-free because a session no longer answers a request the table already routes. That skip is a fix in its own right; without it the follower's request could resolve to a blind answer and defeat the announcement gate. `ReloadDelay` fields are now optional so a caller can set one knob (here, `timeout: 0`) without restating the backoff. Co-Authored-By: Claude Opus 5 --- js/net/src/announced.ts | 22 +++- js/net/src/connection/forward.test.ts | 157 ++++++++++++++++++++++++++ js/net/src/connection/forward.ts | 41 +++++-- js/net/src/connection/pool.test.ts | 52 +++++++-- js/net/src/connection/pool.ts | 23 +++- js/net/src/connection/reload.test.ts | 10 +- js/net/src/connection/reload.ts | 37 +++--- js/net/src/integration.test.ts | 40 ++++--- js/net/src/origin.test.ts | 96 +++++++++++++--- js/net/src/origin.ts | 38 ++++--- 10 files changed, 429 insertions(+), 87 deletions(-) create mode 100644 js/net/src/connection/forward.test.ts diff --git a/js/net/src/announced.ts b/js/net/src/announced.ts index 194ef900a0..1bf1d45e30 100644 --- a/js/net/src/announced.ts +++ b/js/net/src/announced.ts @@ -6,7 +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 { Table as OriginTable } from "./origin.js"; +import type { Request as OriginRequest, Table as OriginTable } from "./origin.js"; import * as Path from "./path.js"; /** @@ -193,9 +193,10 @@ export type 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. * @@ -334,9 +335,17 @@ export class Broadcast { 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); @@ -351,7 +360,10 @@ export class Broadcast { if (event.active) { current?.close(); - current = origin.get(this.path); + 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(); diff --git a/js/net/src/connection/forward.test.ts b/js/net/src/connection/forward.test.ts new file mode 100644 index 0000000000..a919557702 --- /dev/null +++ b/js/net/src/connection/forward.test.ts @@ -0,0 +1,157 @@ +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(); +}); diff --git a/js/net/src/connection/forward.ts b/js/net/src/connection/forward.ts index 168cc61c0a..290343ad6c 100644 --- a/js/net/src/connection/forward.ts +++ b/js/net/src/connection/forward.ts @@ -25,8 +25,16 @@ import type { Established } from "./established.ts"; * @internal */ export function forwardAnnounced(conn: Established, origin: OriginProducer): void { - const detach = origin.attach(conn.discovery); - void conn.closed.then(detach); + // 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); @@ -43,6 +51,7 @@ export function forwardAnnounced(conn: Established, origin: OriginProducer): voi void conn.closed.then(() => announced.close()); void (async () => { + let failure: unknown; try { for (;;) { const event = await announced.next(); @@ -58,12 +67,25 @@ export function forwardAnnounced(conn: Established, origin: OriginProducer): voi dispose?.(); } } - } catch { - // The session died mid-stream; the cleanup below retracts everything it fed. + } 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); + } } })(); } @@ -75,6 +97,9 @@ export function forwardAnnounced(conn: Established, origin: OriginProducer): voi * 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 @@ -86,13 +111,13 @@ async function serveRequests(conn: Established, origin: OriginProducer): Promise dead = true; }); - const requests = origin.requests; for (;;) { - const map = requests.peek(); + const map = origin.requests.peek(); if (!map || dead) break; for (const [path, slot] of map) { if (answered.has(path) || slot.front.peek() !== undefined) continue; + if (origin.routes(path)) continue; const withdraw = origin.answer(path, conn.consume(path)); if (withdraw) answered.set(path, withdraw); } @@ -104,7 +129,9 @@ async function serveRequests(conn: Established, origin: OriginProducer): Promise withdraw(); } - await Promise.race([requests.changed(), closed]); + // 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. diff --git a/js/net/src/connection/pool.test.ts b/js/net/src/connection/pool.test.ts index b0887251e9..1a59b773dd 100644 --- a/js/net/src/connection/pool.test.ts +++ b/js/net/src/connection/pool.test.ts @@ -12,12 +12,14 @@ async function settle() { } // 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++) { +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(); } - throw new Error("timed out waiting for condition"); } // A tiny window keeps the linger tests quick without mocking timers. The wait is a wide @@ -141,6 +143,41 @@ test("switching URLs switches origins", async () => { 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(); @@ -153,10 +190,11 @@ test("a publish through one handle resolves locally for another", async () => { const broadcast = origin.publish(Path.from("mine")); broadcast.createTrack("chat"); - // Loopback: the shared origin serves the page's own publish with no round trip. - const handle = watcher.origin.peek()?.get(Path.from("mine")); - expect(handle).toBeDefined(); - handle?.close(); + // 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(); diff --git a/js/net/src/connection/pool.ts b/js/net/src/connection/pool.ts index eedd701e5c..9d74424daa 100644 --- a/js/net/src/connection/pool.ts +++ b/js/net/src/connection/pool.ts @@ -46,6 +46,10 @@ export interface SharedProps { * 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. * @@ -223,9 +227,24 @@ function acquire(key: string, linger?: DOMHighResTimeStamp): Entry & { release: 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 }, }); - entry = { origin, connection, refs: 0, linger: linger ?? LINGER_MS }; - pool.set(key, entry); + + 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; diff --git a/js/net/src/connection/reload.test.ts b/js/net/src/connection/reload.test.ts index fbaefe4a53..a1ef9e9f1f 100644 --- a/js/net/src/connection/reload.test.ts +++ b/js/net/src/connection/reload.test.ts @@ -274,17 +274,17 @@ test("origins span reconnects: local re-announces, remote re-populates", async ( 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.get(Path.from("remote")) !== undefined); - await waitUntil(() => servers[0]?.saw.get(Path.from("mine")) !== undefined); + 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.get(Path.from("remote")) === undefined); + 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.get(Path.from("remote")) !== undefined); - await waitUntil(() => servers[1]?.saw.get(Path.from("mine")) !== undefined); + await waitUntil(() => reader.routes(Path.from("remote"))); + await waitUntil(() => servers[1]?.saw.routes(Path.from("mine"))); } finally { reload.close(); publishOrigin.close(); diff --git a/js/net/src/connection/reload.ts b/js/net/src/connection/reload.ts index c4419a3c5f..61f096c2d5 100644 --- a/js/net/src/connection/reload.ts +++ b/js/net/src/connection/reload.ts @@ -16,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: @@ -51,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"; @@ -119,7 +125,7 @@ export class Reload { */ subscribe?: OriginProducer; - /** Backoff settings for the reconnect loop. */ + /** 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}. */ @@ -152,7 +158,7 @@ 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; @@ -253,6 +259,10 @@ 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. + const { initial, multiplier, max, timeout } = { ...DEFAULT_DELAY, ...this.delay }; + // Any session is dead now: report disconnected during the backoff rather than // when the retry reruns the effect. this.established.set(undefined); @@ -262,7 +272,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; } @@ -278,8 +288,7 @@ export class Reload { } 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) { @@ -292,7 +301,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); diff --git a/js/net/src/integration.test.ts b/js/net/src/integration.test.ts index 51527d9727..9ea8bcf1df 100644 --- a/js/net/src/integration.test.ts +++ b/js/net/src/integration.test.ts @@ -1,12 +1,13 @@ import { expect, test } from "bun:test"; import type { Getter } from "@moq/signals"; import * as Announce from "./announced.ts"; -import type { Producer as BroadcastProducer } from "./broadcast.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"; @@ -17,6 +18,19 @@ 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(); @@ -1312,7 +1326,7 @@ async function runOriginFlow(protocol: string, version?: number) { expect(await announced.next()).toEqual({ path: Path.from("test"), active: true }); // Consuming through the origin reaches the wire. - const remote = reader.get(Path.from("test")); + 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"); @@ -1320,7 +1334,7 @@ async function runOriginFlow(protocol: string, version?: number) { // 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.get(Path.from("test")) === undefined); + await until(() => !reader.routes(Path.from("test"))); await serving; track.close(); @@ -1354,16 +1368,16 @@ test("origin: remote entries retract when the session dies, local ones survive", const mine = clientOrigin.publish(Path.from("mine")); const reader = clientOrigin.consume(); - await until(() => reader.get(Path.from("remote")) !== undefined); + 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.get(Path.from("remote")) === undefined); + await until(() => !reader.routes(Path.from("remote"))); // The local publish is not the session's to take. - const local = reader.get(Path.from("mine")); + const local = routed(reader, Path.from("mine")); expect(local).toBeDefined(); local?.close(); @@ -1389,7 +1403,7 @@ test("origin: one origin on both directions consumes locally and never echoes", { const remote = serverSees.publish(Path.from("from-server")); const reader = shared.consume(); - await until(() => reader.get(Path.from("from-server")) !== undefined); + await until(() => reader.routes(Path.from("from-server"))); remote.close(); } @@ -1397,7 +1411,7 @@ test("origin: one origin on both directions consumes locally and never echoes", const mine = shared.publish(Path.from("from-client")); mine.createTrack("chat"); const reader = shared.consume(); - const loopback = reader.get(Path.from("from-client")); + 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(); @@ -1406,13 +1420,13 @@ test("origin: one origin on both directions consumes locally and never echoes", // The server sees the client's broadcast once, as its own remote entry. const serverReader = serverSees.consume(); - await until(() => serverReader.get(Path.from("from-client")) !== undefined); + 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.get(Path.from("from-server"))).toBeUndefined(); + expect(serverReader.routes(Path.from("from-server"))).toBe(false); mine.close(); client.close(); @@ -1445,7 +1459,7 @@ test("origin: a request resolves blind on a relay without discovery", async () = expect(reader.discovery.peek()).toBe(false); // Nothing announced, so the table stays empty; a request is the only way through. - expect(reader.get(Path.from("blind"))).toBeUndefined(); + expect(reader.routes(Path.from("blind"))).toBe(false); const request = reader.request(Path.from("blind")); await until(() => request.active.peek() !== undefined); @@ -1574,14 +1588,14 @@ test("origin: overlapping sessions carrying one path fail over", async () => { const second = await setup(); const reader = clientOrigin.consume(); - await until(() => reader.get(Path.from("redundant")) !== undefined); + 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 = reader.get(Path.from("redundant")); + 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"); diff --git a/js/net/src/origin.test.ts b/js/net/src/origin.test.ts index d717b07f21..ac16510bad 100644 --- a/js/net/src/origin.test.ts +++ b/js/net/src/origin.test.ts @@ -1,5 +1,6 @@ import { expect, test } from "bun:test"; -import { Producer as BroadcastProducer } from "./broadcast.ts"; +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"; @@ -7,17 +8,32 @@ 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.get(path)).toBeUndefined(); + expect(consumer.routes(path)).toBe(false); const broadcast = origin.publish(path); broadcast.createTrack("video"); - const handle = consumer.get(path); + const handle = routed(consumer, path); expect(handle).toBeDefined(); // The handle reaches the published tracks. @@ -36,11 +52,11 @@ test("closing the producer unpublishes the path", async () => { const path = Path.from("room"); const broadcast = origin.publish(path); - expect(consumer.get(path)).toBeDefined(); + expect(consumer.routes(path)).toBe(true); broadcast.close(); await settle(); - expect(consumer.get(path)).toBeUndefined(); + expect(consumer.routes(path)).toBe(false); origin.close(); }); @@ -57,13 +73,13 @@ test("a stale broadcast closing does not unpublish a republished path", async () first.close(); await settle(); - const handle = consumer.get(path); + const handle = routed(consumer, path); expect(handle).toBeDefined(); handle?.close(); second.close(); await settle(); - expect(consumer.get(path)).toBeUndefined(); + expect(consumer.routes(path)).toBe(false); origin.close(); }); @@ -88,7 +104,7 @@ test("a consumer clone keeps a superseded broadcast alive", async () => { const path = Path.from("room"); const first = origin.publish(path); - const mine = consumer.get(path); + const mine = routed(consumer, path); expect(mine).toBeDefined(); origin.publish(path); @@ -119,7 +135,7 @@ test("closing the origin closes every routed broadcast", async () => { expect(a.closed.peek()).toBe(abort); expect(b.closed.peek()).toBe(abort); - expect(consumer.get(Path.from("a"))).toBeUndefined(); + expect(consumer.routes(Path.from("a"))).toBe(false); expect(() => origin.publish(Path.from("late"))).toThrow(); // Idempotent: the first close wins. @@ -178,7 +194,7 @@ test("a remote entry resolves by path and retracts on dispose", async () => { const upstream = new BroadcastProducer(); const dispose = origin.insertRemote(path, upstream.consume()); - const handle = consumer.get(path); + const handle = routed(consumer, path); expect(handle).toBeDefined(); handle?.close(); @@ -188,7 +204,7 @@ test("a remote entry resolves by path and retracts on dispose", async () => { dispose(); expect(await announced.next()).toEqual({ path, active: false }); - expect(consumer.get(path)).toBeUndefined(); + expect(consumer.routes(path)).toBe(false); announced.close(); upstream.close(); @@ -208,7 +224,7 @@ test("a local publish shadows a remote entry", async () => { local.createTrack("local-track"); // Local wins: the handle reaches the local track, not the remote one. - const handle = consumer.get(path); + const handle = routed(consumer, path); const track = handle?.subscribe("local-track"); expect(track).toBeDefined(); track?.close(); @@ -220,7 +236,7 @@ test("a local publish shadows a remote entry", async () => { // Dropping the local publish falls back to the remote entry without a retraction. local.close(); - const back = consumer.get(path); + const back = routed(consumer, path); expect(back).toBeDefined(); back?.close(); @@ -341,7 +357,7 @@ test("disposing the newest remote route promotes the fallback", async () => { expect(await announced.next()).toEqual({ path, active: false }); expect(await announced.next()).toEqual({ path, active: true }); - const handle = consumer.get(path); + const handle = routed(consumer, path); const track = handle?.subscribe("chat"); expect(track).toBeDefined(); track?.close(); @@ -352,7 +368,7 @@ test("disposing the newest remote route promotes the fallback", async () => { disposeOlder(); await settle(); - expect(consumer.get(path)).toBeUndefined(); + expect(consumer.routes(path)).toBe(false); announced.close(); origin.close(); @@ -388,7 +404,7 @@ test("withdrawing an answer wakes the requests table", async () => { origin.close(); }); -test("requests never appear in announced or consume", async () => { +test("requests never appear in announced or the table", async () => { const origin = new Producer(); const consumer = origin.consume(); const path = Path.from("assumed"); @@ -397,8 +413,12 @@ test("requests never appear in announced or consume", async () => { const upstream = new BroadcastProducer(); origin.requests.peek()?.get(path)?.front.set(upstream.consume()); - // An answered request is assumed present, not known live, so it is not availability. - expect(consumer.get(path)).toBeUndefined(); + // 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 }); @@ -426,6 +446,46 @@ test("a republish retracts then re-announces the path", async () => { 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(); diff --git a/js/net/src/origin.ts b/js/net/src/origin.ts index fe3bcc6d80..cab453e8c0 100644 --- a/js/net/src/origin.ts +++ b/js/net/src/origin.ts @@ -80,9 +80,6 @@ export interface Table { /** The available broadcasts under `prefix`, as a live stream; see {@link Consumer.announced}. */ announced(prefix?: Path.Valid): announce.Consumer; - - /** A one-shot lookup; see {@link Consumer.get}. @internal */ - get(path: Path.Valid): broadcast.Consumer | undefined; } /** @@ -210,6 +207,16 @@ export class Producer implements Table { 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. * @@ -255,16 +262,16 @@ export class Producer implements Table { 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); } - /** A one-shot lookup; see {@link Consumer.get}. @internal */ - get(path: Path.Valid): broadcast.Consumer | undefined { - return this.#reader.get(path); - } - // The reader backing the passthroughs, so holding a Producer never requires the // consume().x() stutter for everyday reads. get #reader(): Consumer { @@ -397,18 +404,17 @@ export class Consumer { }; /** - * A one-shot handle to the broadcast at `path`, or undefined when nothing routes it. + * Whether the table routes `path` itself, by a local publish or a session's announcement. * - * A snapshot: it neither waits for the path to appear nor follows a republish, which is - * why it is not public. Use {@link request} for a resolution that cannot race. A local - * publish wins over a remote broadcast. The handle is yours: close it when done. + * 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 */ - get(path: Path.Valid): broadcast.Consumer | undefined { - const local = this.#state.local.peek()?.get(path); - if (local) return local.clone(); - return this.#state.remote.peek()?.get(path)?.[0]?.clone(); + routes(path: Path.Valid): boolean { + if (this.#state.local.peek()?.has(path)) return true; + return (this.#state.remote.peek()?.get(path)?.length ?? 0) > 0; } /** From 0f2a810e07b2137be1e199b1319c3898f7620b88 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 11 Aug 2026 15:19:38 -0700 Subject: [PATCH 06/22] feat(js/signals): add Derived, a mapped view with no lifecycle A class publishing a small derived view of its own state had no good way to expose it. Computed carries an Effect (so it needs a close(), reads undefined until its first run, and propagates on a microtask), and a hand-written object with peek/subscribe/changed is rejected by getter() as a foreign readable, so it cannot be wired into a component input. Derived names its sources up front instead of tracking them, which buys a synchronous read and no teardown. It notifies only when the derived value actually changes, matching Signal: a source can move without moving the view. Co-Authored-By: Claude Opus 5 --- js/signals/src/index.ts | 80 +++++++++++++++++++++++++++++++++++++++ js/signals/src/io.test.ts | 60 ++++++++++++++++++++++++++++- 2 files changed, 139 insertions(+), 1 deletion(-) diff --git a/js/signals/src/index.ts b/js/signals/src/index.ts index 6a98fa4650..da5af69687 100644 --- a/js/signals/src/index.ts +++ b/js/signals/src/index.ts @@ -910,6 +910,86 @@ 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. + * + * ```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` every time the derived value changes. Returns a function to unsubscribe. */ + subscribe(fn: Subscriber): Dispose { + // A source can change without moving the derived value (an unrelated key in a map, a + // count that keeps a boolean true), so compare before notifying and match Signal's + // "only when it actually changed" contract. + let last = this.peek(); + return this.#watch((value) => { + if (isEqual(last, value)) return; + last = value; + fn(value); + }); + } + + /** 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..20928f792d 100644 --- a/js/signals/src/io.test.ts +++ b/js/signals/src/io.test.ts @@ -1,5 +1,5 @@ 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"; test("getter wraps a raw value in a fresh Signal", () => { const g = getter(5); @@ -108,6 +108,64 @@ 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 notifies only when the derived value actually changes", 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 changes the counts but not the answer. + source.set({ total: 2, discovery: 2 }); + await Promise.resolve(); + source.set({ total: 0, discovery: 0 }); + await Promise.resolve(); + + expect(seen).toEqual([true, undefined]); + 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); From 74208a38d5e78dfb3b15889b1e29729663b62978 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 11 Aug 2026 15:19:49 -0700 Subject: [PATCH 07/22] fix(js/net): brand the origin getters and seal the Request constructor `origin.discovery` and `request.active` were hand-built objects with the Getter methods and none of the package's brand, so `getter()` classified them as foreign readables and threw: a consumer could not wire either into a component input even though both type-check as Getter. They are Derived now, which also retires the tuple-overload workarounds they had grown. `Request`'s constructor was public in the emitted declarations (`@internal` does not strip without `stripInternal`), so a caller could forge a handle no origin ever registered and whose lifecycle guarantees were therefore false. It takes the module-local factory Consumer already uses. The Producer's reader is built once rather than per property access, so `discovery` keeps its identity across reads. Co-Authored-By: Claude Opus 5 --- js/net/src/origin.test.ts | 16 +++++++ js/net/src/origin.ts | 94 +++++++++++++-------------------------- 2 files changed, 46 insertions(+), 64 deletions(-) diff --git a/js/net/src/origin.test.ts b/js/net/src/origin.test.ts index ac16510bad..32d7bbd3b4 100644 --- a/js/net/src/origin.test.ts +++ b/js/net/src/origin.test.ts @@ -1,4 +1,5 @@ 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"; @@ -505,3 +506,18 @@ test("discovery reflects the attached sessions", async () => { 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(); +}); diff --git a/js/net/src/origin.ts b/js/net/src/origin.ts index cab453e8c0..ce3152e517 100644 --- a/js/net/src/origin.ts +++ b/js/net/src/origin.ts @@ -8,7 +8,7 @@ * * @module */ -import { type Dispose, type GetPromise, type Getter, Once, Signal } from "@moq/signals"; +import { Derived, type Dispose, type GetPromise, type Getter, Once, Signal } from "@moq/signals"; import * as announce from "./announced.ts"; import * as broadcast from "./broadcast.ts"; import * as Path from "./path.ts"; @@ -96,6 +96,11 @@ export interface Table { 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. @@ -272,12 +277,6 @@ export class Producer implements Table { return this.#reader.announced(prefix); } - // The reader backing the passthroughs, so holding a Producer never requires the - // consume().x() stutter for everyday reads. - get #reader(): Consumer { - return makeConsumer(this.#state); - } - /** Close the origin, every broadcast it still routes, and its announcement streams. Idempotent. */ close(abort?: Error) { if (this.#state.closed.peek() !== undefined) return; @@ -305,6 +304,15 @@ export class Producer implements Table { } } +// 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, dispose: Dispose) => Request; + /** * An open request for a path nothing announced; see {@link Consumer.request}. * @@ -330,13 +338,16 @@ export class Request { #dispose: Dispose; #closed = false; - /** @internal Created by {@link Consumer.request}. */ - constructor(path: Path.Valid, active: Getter, dispose: Dispose) { + private constructor(path: Path.Valid, active: Getter, dispose: Dispose) { this.path = path; this.active = active; this.#dispose = dispose; } + static { + makeRequest = (path, active, dispose) => new Request(path, active, dispose); + } + /** Withdraw the request. The path stays routed for any other open request. Idempotent. */ close(): void { if (this.#closed) return; @@ -345,10 +356,6 @@ export class Request { } } -// 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; - /** * The read side of an origin: resolve broadcasts by path and watch what is available. * @@ -363,6 +370,9 @@ export class Consumer { private constructor(state: OriginState) { this.#state = state; + this.#discovery = new Derived([state.sessions], ({ total, discovery }) => + total === 0 ? undefined : discovery > 0, + ); } static { @@ -388,20 +398,7 @@ export class Consumer { // Derived per access rather than cached: a lightweight mapped view over the session // counts, avoiding a Computed's lifecycle. - readonly #discovery: Getter = { - peek: () => { - const { total, discovery } = this.#state.sessions.peek(); - return total === 0 ? undefined : discovery > 0; - }, - subscribe: (fn) => - this.#state.sessions.subscribe(({ total, discovery }) => fn(total === 0 ? undefined : discovery > 0)), - changed: ((fn?: (value: boolean | undefined) => void) => { - const map = ({ total, discovery }: { total: number; discovery: number }) => - total === 0 ? undefined : discovery > 0; - if (fn) return this.#state.sessions.changed((value) => fn(map(value))); - return this.#state.sessions.changed().then(map); - }) as Getter["changed"], - }; + readonly #discovery: Getter; /** * Whether the table routes `path` itself, by a local publish or a session's announcement. @@ -430,7 +427,7 @@ export class Consumer { const requests = this.#state.requests.peek(); if (!requests) { // Closed origin: a request that can never resolve. - return new Request(path, new Signal(undefined), () => {}); + return makeRequest(path, new Signal(undefined), () => {}); } let slot = requests.get(path); @@ -444,7 +441,7 @@ export class Consumer { slot.count += 1; const taken = slot; - return new Request(path, this.#resolved(path, taken), () => { + return makeRequest(path, this.#resolved(path, taken), () => { taken.count -= 1; if (taken.count > 0) return; @@ -466,41 +463,10 @@ export class Consumer { // one (knowledge beats assumption), else the slot's blind answer. Derived per access // over the backing signals, so a routed path resolves synchronously. #resolved(path: Path.Valid, slot: RequestSlot): Getter { - const resolve = () => { - const local = this.#state.local.peek()?.get(path); - if (local) return local; - return this.#state.remote.peek()?.get(path)?.[0] ?? slot.front.peek(); - }; - const sources = [this.#state.local, this.#state.remote, slot.front] as const; - - return { - peek: resolve, - subscribe: (fn) => { - const notify = () => fn(resolve()); - const disposes = sources.map((source) => source.subscribe(notify)); - return () => { - for (const dispose of disposes) dispose(); - }; - }, - changed: ((fn?: (value: broadcast.Consumer | undefined) => void) => { - if (fn) { - const notify = () => fn(resolve()); - // Spelled out: mapping over the tuple trips overload resolution on the - // union of signal types. - const disposes = [ - sources[0].changed(notify), - sources[1].changed(notify), - sources[2].changed(notify), - ]; - return () => { - for (const dispose of disposes) dispose(); - }; - } - // Spelled out: mapping `.changed()` over the tuple trips overload resolution - // on the union of signal types. - return Promise.race([sources[0].changed(), sources[1].changed(), sources[2].changed()]).then(resolve); - }) as Getter["changed"], - }; + return new Derived( + [this.#state.local, this.#state.remote, slot.front], + (local, remote, front) => local?.get(path) ?? remote?.get(path)?.[0] ?? front, + ); } /** From a1d5c5a83982a4a3a07cbbf9c8202544ede3d19c Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 11 Aug 2026 15:19:49 -0700 Subject: [PATCH 08/22] fix(js/net): keep the ReloadDelay defaults on an explicitly undefined field `exactOptionalPropertyTypes` is off, so `{ initial: maybeInitial }` built from an optional value passes an explicit undefined, and spreading it over the defaults took that as the answer. An undefined `initial`, `multiplier`, or `max` turned the backoff into NaN, which redials as fast as the event loop allows; an undefined `timeout` became an infinite retry window. Co-Authored-By: Claude Opus 5 --- js/net/src/connection/reload.test.ts | 32 ++++++++++++++++++++++++++++ js/net/src/connection/reload.ts | 11 ++++++++-- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/js/net/src/connection/reload.test.ts b/js/net/src/connection/reload.test.ts index a1ef9e9f1f..e27f97fc87 100644 --- a/js/net/src/connection/reload.test.ts +++ b/js/net/src/connection/reload.test.ts @@ -129,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++) { diff --git a/js/net/src/connection/reload.ts b/js/net/src/connection/reload.ts index 61f096c2d5..292475e2a0 100644 --- a/js/net/src/connection/reload.ts +++ b/js/net/src/connection/reload.ts @@ -260,8 +260,15 @@ export class Reload { */ #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. - const { initial, multiplier, max, timeout } = { ...DEFAULT_DELAY, ...this.delay }; + // 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. From 17393cfa962b350ad5fc7279c88acca1d6c56bbb Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 11 Aug 2026 15:19:50 -0700 Subject: [PATCH 09/22] docs(js): update the origin and component examples The Origins section still called `origin.consume(path)` and `origin.request` on a producer, neither of which survived the API revision that made Request the one way to consume by path. The watch and publish guides still built their Broadcast components with a `connection` input, which is now `origin`. Co-Authored-By: Claude Opus 5 --- doc/lib/js/@moq/net.md | 17 ++++++++++++++++- doc/lib/js/@moq/publish.md | 7 ++++++- doc/lib/js/@moq/watch.md | 16 ++++++++++++---- 3 files changed, 34 insertions(+), 6 deletions(-) diff --git a/doc/lib/js/@moq/net.md b/doc/lib/js/@moq/net.md index 4796515f96..78f37dd354 100644 --- a/doc/lib/js/@moq/net.md +++ b/doc/lib/js/@moq/net.md @@ -58,7 +58,22 @@ 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, consumable by path and gone when the session dies. `origin.consume(path)` resolves local publishes first, so a page that publishes and watches the same broadcast reads its own copy with no round trip. For a path nothing announces (a relay without discovery, or subscribing before the publisher exists on purpose), `origin.request(path)` asks the attached sessions to resolve it blind; the request stands across reconnects. +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 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, From 26a9ca2e147d8e25e5e7ae27427e6027d29640af Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 11 Aug 2026 20:32:01 -0700 Subject: [PATCH 10/22] fix(js/net): port dev's new tests onto the origin API The rebase onto dev pulled in tests written against `Established.publish` and `Publisher.publish`, which this branch removes. Git merged them without a conflict because the surrounding lines never moved, so they compiled as calls into a surface that no longer exists. They publish through an origin now, which is the same coverage: the subscribe still reaches the same producer, only by way of the table rather than the session. `Video.Source` also lost the `Moq` import in the merge, since dev's copy of the file no longer needed it and this branch's `probe` input does. Co-Authored-By: Claude Opus 5 --- js/net/src/integration.test.ts | 18 ++++++++++------ js/net/src/lite/publisher.test.ts | 36 +++++++++++++++---------------- js/watch/src/video/source.ts | 5 +++-- 3 files changed, 33 insertions(+), 26 deletions(-) diff --git a/js/net/src/integration.test.ts b/js/net/src/integration.test.ts index 9ea8bcf1df..fa247e4603 100644 --- a/js/net/src/integration.test.ts +++ b/js/net/src/integration.test.ts @@ -115,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) => { @@ -177,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 diff --git a/js/net/src/lite/publisher.test.ts b/js/net/src/lite/publisher.test.ts index b0203ae7a2..26be86fc18 100644 --- a/js/net/src/lite/publisher.test.ts +++ b/js/net/src/lite/publisher.test.ts @@ -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"); @@ -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/watch/src/video/source.ts b/js/watch/src/video/source.ts index 03f4b309f1..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"; @@ -38,8 +39,8 @@ export type SourceInput = { 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.Reload`'s `probe`. Optional: without - // it auto-selection falls back to the preference order alone. + // 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; }; From 9dc03a8a52e769ca937190713721e0531d95a562 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 11 Aug 2026 21:18:45 -0700 Subject: [PATCH 11/22] fix(js/signals): relay every source notification from Derived Deduplicating against the value at subscribe time swallowed two real edges. A source applies a change synchronously and only queues the notification, so a Derived subscribing inside that window snapshotted the already-updated value and then suppressed the flush it was waiting for. Subscribing to the source directly delivered it, which is what the hand-written getters this class replaced did. An in-place `mutate()` force-notifies precisely because the object identity cannot change, so a mapping that returns the value as-is compared it against itself and dropped the notification. Both are lost wakeups. A redundant rerun is the cheaper failure, so the view relays what its sources report and leaves the filtering to them. Co-Authored-By: Claude Opus 5 --- js/signals/src/index.ts | 18 +++++++------- js/signals/src/io.test.ts | 49 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 54 insertions(+), 13 deletions(-) diff --git a/js/signals/src/index.ts b/js/signals/src/index.ts index da5af69687..4da7c3ff2e 100644 --- a/js/signals/src/index.ts +++ b/js/signals/src/index.ts @@ -926,6 +926,12 @@ export type GetterValues[]> = { [K in keyof S * 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); * ``` @@ -948,17 +954,9 @@ export class Derived[], T> implements G return this.#fn(...(this.#sources.map((source) => source.peek()) as unknown as GetterValues)); } - /** Calls `fn` every time the derived value changes. Returns a function to unsubscribe. */ + /** Calls `fn` with the derived value every time any source notifies. */ subscribe(fn: Subscriber): Dispose { - // A source can change without moving the derived value (an unrelated key in a map, a - // count that keeps a boolean true), so compare before notifying and match Signal's - // "only when it actually changed" contract. - let last = this.peek(); - return this.#watch((value) => { - if (isEqual(last, value)) return; - last = value; - fn(value); - }); + return this.#watch(fn); } /** Resolves the next time the derived value changes, or calls `fn` once on the next change. */ diff --git a/js/signals/src/io.test.ts b/js/signals/src/io.test.ts index 20928f792d..342875bbf4 100644 --- a/js/signals/src/io.test.ts +++ b/js/signals/src/io.test.ts @@ -1,6 +1,9 @@ import { expect, test } from "bun:test"; 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); expect(g.peek()).toBe(5); @@ -126,7 +129,7 @@ test("Derived reads through on every peek, with no first-run gap", () => { expect(sum.peek()).toBe(12); }); -test("Derived notifies only when the derived value actually changes", async () => { +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)); @@ -135,13 +138,53 @@ test("Derived notifies only when the derived value actually changes", async () = source.set({ total: 1, discovery: 1 }); await Promise.resolve(); - // A second session changes the counts but not the answer. + // 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, undefined]); + 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(); }); From 9c462b86904df4e3e975229d563695aa5e6bde78 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 11 Aug 2026 21:18:45 -0700 Subject: [PATCH 12/22] fix(js/net): answer a request whose slot was replaced under it A serving session tracked the paths it had answered, but a path outlives its slot: withdrawing the last handle tears the slot down a microtask later, and a request taken after that teardown installs a fresh one. Both writes land in a single coalesced wakeup, so the loop saw a slot it had never answered under a path it had, skipped it, and refused to withdraw the stale answer because the path was still occupied. The new request then never resolved. The claim is on the slot, not the path. A replaced slot now reads as withdrawn. Co-Authored-By: Claude Opus 5 --- js/net/src/connection/forward.test.ts | 29 +++++++++++++++++++++++++++ js/net/src/connection/forward.ts | 23 ++++++++++++--------- 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/js/net/src/connection/forward.test.ts b/js/net/src/connection/forward.test.ts index a919557702..d6bc0252fe 100644 --- a/js/net/src/connection/forward.test.ts +++ b/js/net/src/connection/forward.test.ts @@ -155,3 +155,32 @@ test("a session dying does not downgrade discovery for the next one", async () = 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 index 290343ad6c..091629dba0 100644 --- a/js/net/src/connection/forward.ts +++ b/js/net/src/connection/forward.ts @@ -4,7 +4,7 @@ * @module */ import type { Dispose } from "@moq/signals"; -import type { Producer as OriginProducer } from "../origin.ts"; +import type { Producer as OriginProducer, RequestSlot } from "../origin.ts"; import type * as Path from "../path.ts"; import type { Established } from "./established.ts"; @@ -103,8 +103,11 @@ export function forwardAnnounced(conn: Established, origin: OriginProducer): voi */ 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. - const answered = new Map(); + // 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(() => { @@ -116,17 +119,19 @@ async function serveRequests(conn: Established, origin: OriginProducer): Promise if (!map || dead) break; for (const [path, slot] of map) { - if (answered.has(path) || slot.front.peek() !== undefined) continue; + if (answered.get(path)?.slot === slot || slot.front.peek() !== undefined) continue; if (origin.routes(path)) continue; const withdraw = origin.answer(path, conn.consume(path)); - if (withdraw) answered.set(path, withdraw); + if (withdraw) answered.set(path, { slot, withdraw }); } // A withdrawn request already released the answer; just forget our claim on the path. - for (const [path, withdraw] of [...answered]) { - if (map.has(path)) continue; + // 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); - withdraw(); + entry.withdraw(); } // Woken by the table too, not just the requests: a path that stops being routed needs @@ -135,7 +140,7 @@ async function serveRequests(conn: Established, origin: OriginProducer): Promise } // Session gone: withdraw our answers, waking a standby session to provide fresh ones. - for (const withdraw of answered.values()) { + for (const { withdraw } of answered.values()) { withdraw(); } answered.clear(); From 845a764650e211ba7ab01d34e473dfae18f9bc00 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 11 Aug 2026 21:18:45 -0700 Subject: [PATCH 13/22] fix(js/net): release announcement pumps as they close Each `announced()` parked a cleanup closure on the handle's own scope, which has no unregister path, so every closed pump was retained until the whole Shared handle went away. Effect.run hands back a disposer that also drops itself from the parent, which is what this repeated open/close pattern wants. Co-Authored-By: Claude Opus 5 --- js/net/src/connection/pool.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/js/net/src/connection/pool.ts b/js/net/src/connection/pool.ts index 9d74424daa..90a82865e0 100644 --- a/js/net/src/connection/pool.ts +++ b/js/net/src/connection/pool.ts @@ -145,8 +145,11 @@ export class Shared { closed = true; }); - const pump = new Effect(); - pump.run((effect) => { + // 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; @@ -175,8 +178,7 @@ export class Shared { }); }); - this.#signals.cleanup(() => pump.close()); - void consumer.closed.then(() => pump.close()); + void consumer.closed.then(stop); return consumer; } From 8a57ab559fd9979f6f7e5c9568bf12a342d54f4f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 12 Aug 2026 07:46:41 -0700 Subject: [PATCH 14/22] fix(js/net): gate discovery on every attached session, not any `discovery` was true as soon as one attached session announced, so an origin fed by both a discovering session and a blind one read as fully discoverable. A consumer gated on it then trusted the announcement table, and a path only the blind session could serve never entered that table and never got a request slot, so nothing ever asked for it and it stayed unreachable. It now means what consumers use it for: the table is complete. One session that cannot announce makes it false, which is what keeps the blind fallback armed. Co-Authored-By: Claude Opus 5 --- js/net/src/origin.test.ts | 8 +++++--- js/net/src/origin.ts | 20 ++++++++++++-------- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/js/net/src/origin.test.ts b/js/net/src/origin.test.ts index 32d7bbd3b4..6fbf38b1af 100644 --- a/js/net/src/origin.test.ts +++ b/js/net/src/origin.test.ts @@ -496,12 +496,14 @@ test("discovery reflects the attached sessions", async () => { 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(true); - - seeing(); expect(consumer.discovery.peek()).toBe(false); + blind(); + expect(consumer.discovery.peek()).toBe(true); + seeing(); expect(consumer.discovery.peek()).toBeUndefined(); origin.close(); diff --git a/js/net/src/origin.ts b/js/net/src/origin.ts index ce3152e517..7cd0f9fc18 100644 --- a/js/net/src/origin.ts +++ b/js/net/src/origin.ts @@ -69,7 +69,7 @@ export interface Table { /** Settles once the origin closes; see {@link Producer.closed}. */ readonly closed: GetPromise; - /** Whether an attached session supports discovery; see {@link Consumer.discovery}. */ + /** 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}. */ @@ -257,7 +257,7 @@ export class Producer implements Table { return makeConsumer(this.#state); } - /** Whether an attached session supports discovery; see {@link Consumer.discovery}. */ + /** Whether every attached session announces into the table; see {@link Consumer.discovery}. */ get discovery(): Getter { return this.#reader.discovery; } @@ -370,8 +370,11 @@ export class Consumer { 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 > 0, + total === 0 ? undefined : discovery === total, ); } @@ -385,12 +388,13 @@ export class Consumer { } /** - * Whether an attached session supports broadcast discovery. + * Whether the announcement table sees everything the attached sessions can serve. * - * Undefined while no session is attached (nothing is known yet), true when at least one - * attached session announces broadcasts into the table, false when every attached - * session lacks discovery, where {@link announced} stays silent and consumers should - * {@link request} paths instead of waiting. + * 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; From 7bcf13f32582ffdfd16080b6dfb60ea2f4685b33 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 12 Aug 2026 07:46:52 -0700 Subject: [PATCH 15/22] fix(js/net): give each request its own handle on the route `Request.active` handed out the table's own consumer. Closing a consumer closes the broadcast once it was the last live handle, and the table usually holds the only other one, so an ordinary `close()` by one requester could unpublish a local path for every other holder, or leave the table pointing at a consumer somebody else had closed. Only a doc comment stood between a caller and that, and a compile error beats a runtime check beats a warning nobody reads. Each request now clones the route it resolves, memoized on the route's identity so repeated reads return the same handle and only a real swap clones again. The clone happens on the read rather than in a subscription callback, because a callback lands a microtask late and a routed path resolves synchronously. Co-Authored-By: Claude Opus 5 --- js/net/src/origin.test.ts | 64 +++++++++++++++++++++++++++++++++++++-- js/net/src/origin.ts | 38 +++++++++++++++++++++-- 2 files changed, 97 insertions(+), 5 deletions(-) diff --git a/js/net/src/origin.test.ts b/js/net/src/origin.test.ts index 6fbf38b1af..dfdcd2dfac 100644 --- a/js/net/src/origin.test.ts +++ b/js/net/src/origin.test.ts @@ -293,9 +293,11 @@ test("a request resolves once a front answers, and survives its withdrawal", asy slot?.front.set(upstream.consume()); expect(request.active.peek()).toBeDefined(); - // A second request for the same path shares the answer. + // 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()).toBe(request.active.peek()); + expect(again.active.peek()).toBeDefined(); + expect(again.active.peek()).not.toBe(request.active.peek()); again.close(); expect(request.active.peek()).toBeDefined(); @@ -329,7 +331,9 @@ test("a request closed and retaken in the same tick keeps its answer", async () await settle(); await settle(); - expect(second.active.peek()).toBe(front); + // 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(); @@ -523,3 +527,57 @@ test("the exposed getters are wirable as component inputs", () => { 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(); +}); diff --git a/js/net/src/origin.ts b/js/net/src/origin.ts index 7cd0f9fc18..1559242bb9 100644 --- a/js/net/src/origin.ts +++ b/js/net/src/origin.ts @@ -331,7 +331,9 @@ export class Request { * 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. * - * Borrowed, not yours to close: take a `clone()` for a lifetime of your own. + * 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; @@ -444,8 +446,40 @@ export class Consumer { } 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; - return makeRequest(path, this.#resolved(path, taken), () => { + + // 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). + // This has to happen on the read rather than in a subscription callback, because a + // callback fires a microtask late and a routed path resolves synchronously. + 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 active = new Derived([this.#resolved(path, taken)], own); + + return makeRequest(path, active, () => { + // Releases this request's handle; the route itself belongs to the table. + released = true; + handle?.close(); + handle = undefined; + source = undefined; + taken.count -= 1; if (taken.count > 0) return; From dc14a0874cfce443590bad7dc5f6ce48b937c3e9 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 12 Aug 2026 07:46:52 -0700 Subject: [PATCH 16/22] build(js): strip @internal symbols from the published declarations `@internal` is how this workspace hides a symbol from the published surface, but without `stripInternal` the tag was only a comment: the declaration still shipped, still type-checked for consumers, and read as supported API. That let an origin's mutable request table out, where `slot.front.set(...)` corrupts routing for every handle on the path, and it is the same trap that already shipped a rival consume path and a forgeable constructor here. Enabled at the workspace root, so the convention holds for every package rather than the one that noticed. Co-Authored-By: Claude Opus 5 --- js/tsconfig.json | 4 ++++ 1 file changed, 4 insertions(+) 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, From cbe7d75eaeb26c440549489547f8b3bd072acf93 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 12 Aug 2026 08:31:50 -0700 Subject: [PATCH 17/22] fix(js/net): retire a request's route when it retracts, not when it is read Swapping the handle on the read is what keeps a routed path resolving synchronously, but it also meant a holder that only ever peeked pinned a route that had already been retracted, keeping a dead session's broadcast alive until it happened to read again or closed the request. The request follows its route as well now. The memo makes the two paths agree, since whichever runs first does the swap, so this costs nothing on the read path and bounds retention by the retraction rather than by the next reader. Co-Authored-By: Claude Opus 5 --- js/net/src/announced.ts | 8 ++++---- js/net/src/origin.test.ts | 26 ++++++++++++++++++++++++++ js/net/src/origin.ts | 12 +++++++++--- 3 files changed, 39 insertions(+), 7 deletions(-) diff --git a/js/net/src/announced.ts b/js/net/src/announced.ts index 1bf1d45e30..d235db3352 100644 --- a/js/net/src/announced.ts +++ b/js/net/src/announced.ts @@ -374,10 +374,10 @@ export class Broadcast { offline(); }); - // Blind fallback: while every attached session lacks discovery, nothing remote will - // ever reach the table, so stand a request for whichever session answers. Gated on - // exactly `false`: with no session there is nobody to ask, and with discovery the - // announcement gate is the point, so a blind subscribe would defeat it. + // 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; diff --git a/js/net/src/origin.test.ts b/js/net/src/origin.test.ts index dfdcd2dfac..63102132bf 100644 --- a/js/net/src/origin.test.ts +++ b/js/net/src/origin.test.ts @@ -581,3 +581,29 @@ test("closing a request releases the handle it was holding", async () => { 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(); +}); diff --git a/js/net/src/origin.ts b/js/net/src/origin.ts index 1559242bb9..8e96e01852 100644 --- a/js/net/src/origin.ts +++ b/js/net/src/origin.ts @@ -455,8 +455,6 @@ export class Consumer { // 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). - // This has to happen on the read rather than in a subscription callback, because a - // callback fires a microtask late and a routed path resolves synchronously. let released = false; let source: broadcast.Consumer | undefined; let handle: broadcast.Consumer | undefined; @@ -471,11 +469,19 @@ export class Consumer { return handle; }; - const active = new Derived([this.#resolved(path, taken)], own); + const route = this.#resolved(path, taken); + 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); return makeRequest(path, active, () => { // Releases this request's handle; the route itself belongs to the table. released = true; + unsubscribe(); handle?.close(); handle = undefined; source = undefined; From 9a4e1e6b0721b0c6e26451abaef2255bd572a3a6 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 12 Aug 2026 10:57:44 -0700 Subject: [PATCH 18/22] perf(js/net): wake a request only for its own path Each request derived its route over the whole `local`/`remote` tables, and a Derived relays every source notification without comparing the value it produced. Any publish or retraction anywhere therefore woke every open request: a measured 10 wakeups for 5 unrelated publish/close cycles on a different path, scaling with the number of open requests times the churn of the whole table. A request now subscribes to a route signal owned by its own path, refreshed by whichever mutator touched that path. This is what `OriginNode` does in rs/moq-net, where each node carries its own notify and only prefix announcements walk the tree. The tables stay the storage, since announcement streams and the wire publishers legitimately iterate them and are few. Reaching for a Computed instead would dedupe the propagation but keep the per-request recompute, and its Effect schedules on a microtask, which would cost the synchronous resolution a routed path has today. Co-Authored-By: Claude Opus 5 --- js/net/src/connection/forward.ts | 2 +- js/net/src/origin.test.ts | 38 +++++++++++++++- js/net/src/origin.ts | 78 +++++++++++++++++++++----------- 3 files changed, 89 insertions(+), 29 deletions(-) diff --git a/js/net/src/connection/forward.ts b/js/net/src/connection/forward.ts index 091629dba0..8d53cc355b 100644 --- a/js/net/src/connection/forward.ts +++ b/js/net/src/connection/forward.ts @@ -119,7 +119,7 @@ async function serveRequests(conn: Established, origin: OriginProducer): Promise if (!map || dead) break; for (const [path, slot] of map) { - if (answered.get(path)?.slot === slot || slot.front.peek() !== undefined) continue; + 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 }); diff --git a/js/net/src/origin.test.ts b/js/net/src/origin.test.ts index 63102132bf..f8ca9d8a58 100644 --- a/js/net/src/origin.test.ts +++ b/js/net/src/origin.test.ts @@ -290,7 +290,7 @@ test("a request resolves once a front answers, and survives its withdrawal", asy const upstream = new BroadcastProducer(); const slot = origin.requests.peek()?.get(path); expect(slot).toBeDefined(); - slot?.front.set(upstream.consume()); + 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 @@ -416,7 +416,7 @@ test("requests never appear in announced or the table", async () => { const request = consumer.request(path); const upstream = new BroadcastProducer(); - origin.requests.peek()?.get(path)?.front.set(upstream.consume()); + 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. @@ -607,3 +607,37 @@ test("a retracted route is retired even for a request nobody reads again", async 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(); +}); diff --git a/js/net/src/origin.ts b/js/net/src/origin.ts index 8e96e01852..fa13f8cf80 100644 --- a/js/net/src/origin.ts +++ b/js/net/src/origin.ts @@ -14,16 +14,23 @@ import * as broadcast from "./broadcast.ts"; import * as Path from "./path.ts"; /** - * One requested path: how many {@link Request} handles want it, and the front the first - * session to answer provided. The front signal outlives a session: the answering session - * clears it when it dies, and the next session answers again, which is what makes a - * request span reconnects. + * 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; - front: Signal; + answer?: broadcast.Consumer; + readonly route: Signal; } /** Reactive backing state shared by origin producers and consumers. */ @@ -54,6 +61,24 @@ class OriginState { sessions = new Signal({ total: 0, discovery: 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)); + } + + /** What `path` resolves to: the table's route when it has one, else the blind answer. */ + route(path: Path.Valid, slot: RequestSlot): broadcast.Consumer | undefined { + return this.local.peek()?.get(path) ?? this.remote.peek()?.get(path)?.[0] ?? slot.answer; + } } /** @@ -126,6 +151,7 @@ export class Producer implements Table { 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. @@ -133,6 +159,7 @@ export class Producer implements Table { this.#state.local.mutate((broadcasts) => { if (broadcasts?.get(path) === front) broadcasts.delete(path); }); + this.#state.refresh(path); }); return producer; @@ -165,6 +192,7 @@ export class Producer implements Table { front.close(); return () => {}; } + this.#state.refresh(path); return () => { this.#state.remote.mutate((broadcasts) => { @@ -175,6 +203,7 @@ export class Producer implements Table { fronts.splice(index, 1); if (fronts.length === 0) broadcasts?.delete(path); }); + this.#state.refresh(path); front.close(); }; } @@ -235,16 +264,18 @@ export class Producer implements Table { */ answer(path: Path.Valid, front: broadcast.Consumer): Dispose | undefined { const slot = this.#state.requests.peek()?.get(path); - if (!slot || slot.front.peek() !== undefined) { + if (!slot || slot.answer !== undefined) { front.close(); return undefined; } - slot.front.set(front); + slot.answer = front; + this.#state.refresh(path); return () => { - if (slot.front.peek() === front) { - slot.front.set(undefined); - // The slot signal only reaches its requesters; poke the map so every + 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(() => {}); } @@ -296,8 +327,9 @@ export class Producer implements Table { }); this.#state.requests.update((requests) => { for (const slot of requests?.values() ?? []) { - slot.front.peek()?.close(); - slot.front.set(undefined); + slot.answer?.close(); + slot.answer = undefined; + slot.route.set(undefined); } return undefined; }); @@ -438,7 +470,10 @@ export class Consumer { let slot = requests.get(path); if (!slot) { - const created: RequestSlot = { count: 0, front: new Signal(undefined) }; + const created: RequestSlot = { count: 0, route: new Signal(undefined) }; + // Seeded before anyone can watch it, so a path the table already routes resolves + // on the first read rather than a microtask later. + created.route.set(this.#state.route(path, created), false); slot = created; this.#state.requests.mutate((map) => { map?.set(path, created); @@ -469,7 +504,7 @@ export class Consumer { return handle; }; - const route = this.#resolved(path, taken); + const route = taken.route; const active = new Derived([route], own); // Swapping on the read is what keeps a routed path resolving synchronously, but a @@ -497,22 +532,13 @@ export class Consumer { this.#state.requests.mutate((map) => { if (map?.get(path) === taken) map.delete(path); }); - taken.front.peek()?.close(); - taken.front.set(undefined); + taken.answer?.close(); + taken.answer = undefined; + taken.route.set(undefined); }); }); } - // The reactive view behind Request.active: the table's route for `path` when it has - // one (knowledge beats assumption), else the slot's blind answer. Derived per access - // over the backing signals, so a routed path resolves synchronously. - #resolved(path: Path.Valid, slot: RequestSlot): Getter { - return new Derived( - [this.#state.local, this.#state.remote, slot.front], - (local, remote, front) => local?.get(path) ?? remote?.get(path)?.[0] ?? front, - ); - } - /** * The available broadcasts under `prefix`, as a live stream: everything currently * routed arrives first as `active`, then additions and removals as they happen. Paths From 6b6753121dd1870973a2f86bd2ab36c91fef4418 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 12 Aug 2026 11:11:35 -0700 Subject: [PATCH 19/22] feat(js/net): tell a request nothing can serve it A request parked forever with no way to distinguish "no session has answered yet" from "nothing here will ever answer". Waiting on a path no connection can serve looked exactly like waiting on one that is about to arrive, so a caller had no signal to stop waiting. rs/moq-net has drawn this line since #1772: `request_broadcast` resolves to `Unroutable` when nothing is announced and no handler is registered. `Request.unroutable` is that line, as a reactive fact rather than a terminal error, so a request still spans reconnects. It is true when nothing routes the path and nothing is prepared to answer it. "Prepared to answer" deliberately counts more than attached sessions. A reconnecting connection holds an expectation for its whole life, so the window between wiring up an origin and completing the first handshake stays pending. Keying this on attached sessions alone would make every page load report a missing broadcast for the length of a handshake. Co-Authored-By: Claude Opus 5 --- js/net/src/connection/reload.ts | 5 ++ js/net/src/origin.test.ts | 85 +++++++++++++++++++++++++++++++++ js/net/src/origin.ts | 72 +++++++++++++++++++++++++--- 3 files changed, 155 insertions(+), 7 deletions(-) diff --git a/js/net/src/connection/reload.ts b/js/net/src/connection/reload.ts index 292475e2a0..14d42012b4 100644 --- a/js/net/src/connection/reload.ts +++ b/js/net/src/connection/reload.ts @@ -165,6 +165,11 @@ export class Reload { 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 on close, when nothing is coming any more. + if (this.subscribe) this.#signals.cleanup(this.subscribe.expect()); + this.closed = new Promise((resolve, reject) => { this.#closedResolve = resolve; this.#closedReject = reject; diff --git a/js/net/src/origin.test.ts b/js/net/src/origin.test.ts index f8ca9d8a58..7e2a6f7663 100644 --- a/js/net/src/origin.test.ts +++ b/js/net/src/origin.test.ts @@ -641,3 +641,88 @@ test("a request only wakes for its own path", async () => { 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(); +}); diff --git a/js/net/src/origin.ts b/js/net/src/origin.ts index fa13f8cf80..f26cb6a2dd 100644 --- a/js/net/src/origin.ts +++ b/js/net/src/origin.ts @@ -8,7 +8,7 @@ * * @module */ -import { Derived, type Dispose, type GetPromise, type Getter, Once, Signal } from "@moq/signals"; +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"; @@ -60,6 +60,12 @@ class OriginState { // 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(); /** @@ -216,11 +222,13 @@ export class Producer implements Table { */ 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(); }; } @@ -231,6 +239,28 @@ export class Producer implements Table { })); } + /** + * 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; + this.#state.answerers.update((count) => count - 1); + }; + } + /** * The open requests, watched by attached sessions to answer them; see * {@link Consumer.request}. Undefined once the origin closes. @@ -343,7 +373,12 @@ 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, dispose: Dispose) => Request; +let makeRequest: ( + path: Path.Valid, + active: Getter, + unroutable: Getter, + dispose: Dispose, +) => Request; /** * An open request for a path nothing announced; see {@link Consumer.request}. @@ -369,17 +404,35 @@ export class Request { */ 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, dispose: Dispose) { + 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, dispose) => new Request(path, active, dispose); + makeRequest = (path, active, unroutable, dispose) => new Request(path, active, unroutable, dispose); } /** Withdraw the request. The path stays routed for any other open request. Idempotent. */ @@ -464,8 +517,8 @@ export class Consumer { request(path: Path.Valid): Request { const requests = this.#state.requests.peek(); if (!requests) { - // Closed origin: a request that can never resolve. - return makeRequest(path, new Signal(undefined), () => {}); + // Closed origin: a request that can never resolve, and says so. + return makeRequest(path, new Signal(undefined), getter(true), () => {}); } let slot = requests.get(path); @@ -513,7 +566,12 @@ export class Consumer { // and the memo makes the two paths agree: whichever runs first does the swap. const unsubscribe = route.subscribe(own); - return makeRequest(path, active, () => { + // 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(); From 46c6aa18e3303ad2e009d84a2cb102f19414ed99 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 12 Aug 2026 14:01:15 -0700 Subject: [PATCH 20/22] docs(js/net): record why a wrong answer to a request is not corrected Several sessions on one origin race to answer a request and the winner may not carry the path. Nothing detects it: a missing broadcast resets the track and deliberately leaves the handle open, because the wire cannot distinguish "not here" from "not yet" and a blind handle is expected to survive until a publisher arrives (integration.test.ts covers this for both protocols). So the three obvious repairs are all worse than the gap. Rejecting on failure needs a signal that does not exist, failing over between answers cannot tell which answer works, and having announcing sessions decline unannounced paths breaks a relay serving them from a dynamic upstream. Co-Authored-By: Claude Opus 5 --- js/net/src/origin.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/js/net/src/origin.ts b/js/net/src/origin.ts index f26cb6a2dd..a1700bb781 100644 --- a/js/net/src/origin.ts +++ b/js/net/src/origin.ts @@ -513,6 +513,15 @@ export class Consumer { * 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(); From 75ac33e279165112b4a3d7731180bf0d669b7a77 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 12 Aug 2026 20:55:58 -0700 Subject: [PATCH 21/22] test(js/net): port the served-subscription helper to the origin API The rebase picked up #2796's new serving tests, which still build a broadcast directly and hand it to `Publisher.publish`. This branch moves that table into the origin, so the helper now publishes through an origin and gives the publisher a consumer of it, matching every other test in the file. No textual conflict, so only the type checker caught it. Co-Authored-By: Claude Opus 5 --- js/net/src/lite/publisher.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/js/net/src/lite/publisher.test.ts b/js/net/src/lite/publisher.test.ts index 26be86fc18..80712407f7 100644 --- a/js/net/src/lite/publisher.test.ts +++ b/js/net/src/lite/publisher.test.ts @@ -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); From 5f4aba1625b517a46796af6bcf2bd888e11edde1 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 12 Aug 2026 21:12:05 -0700 Subject: [PATCH 22/22] fix(js/net): stop a request from missing the retraction of a seeded route Three ways a request could sit on a stale answer, all introduced by the two commits before this one. Seeding the route with a silent set looked free, but `Signal.set` captures the previous value as the baseline for the next comparison before it checks the notify flag, and a silent set returns before the flush that would clear it. A path routed at request time therefore kept `undefined` as its baseline, so the route retracting back to `undefined` compared equal and woke nobody: consumers kept showing a broadcast that was gone, and the request never released its clone. The seed now goes through the Signal constructor, which touches none of that machinery. Closing an origin left the answerer count alone, so a request taken out before the close stayed merely unanswered forever rather than unroutable. The count is zeroed on close, and the disposers clamp so the sessions that were attached at the time can still release without driving it negative. A reconnect loop that gives up, on an auth rejection or a retry timeout, kept its expectation and so kept promising an answer that was never coming. Both terminal paths release it now. Co-Authored-By: Claude Opus 5 --- js/net/src/connection/reload.test.ts | 43 +++++++++++++++++++++++ js/net/src/connection/reload.ts | 17 +++++++-- js/net/src/origin.test.ts | 52 ++++++++++++++++++++++++++++ js/net/src/origin.ts | 23 +++++++----- 4 files changed, 124 insertions(+), 11 deletions(-) diff --git a/js/net/src/connection/reload.test.ts b/js/net/src/connection/reload.test.ts index e27f97fc87..fbcb4f54c8 100644 --- a/js/net/src/connection/reload.test.ts +++ b/js/net/src/connection/reload.test.ts @@ -217,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/"); diff --git a/js/net/src/connection/reload.ts b/js/net/src/connection/reload.ts index 14d42012b4..e3fb4f1e0c 100644 --- a/js/net/src/connection/reload.ts +++ b/js/net/src/connection/reload.ts @@ -1,4 +1,4 @@ -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"; @@ -141,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; @@ -167,8 +171,13 @@ export class Reload { // 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 on close, when nothing is coming any more. - if (this.subscribe) this.#signals.cleanup(this.subscribe.expect()); + // 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; @@ -295,6 +304,7 @@ 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; } @@ -306,6 +316,7 @@ export class Reload { 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; } diff --git a/js/net/src/origin.test.ts b/js/net/src/origin.test.ts index 7e2a6f7663..3cf241507a 100644 --- a/js/net/src/origin.test.ts +++ b/js/net/src/origin.test.ts @@ -726,3 +726,55 @@ test("a request on a closed origin is unroutable", () => { 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 index a1700bb781..a561425a47 100644 --- a/js/net/src/origin.ts +++ b/js/net/src/origin.ts @@ -78,12 +78,12 @@ class OriginState { refresh(path: Path.Valid): void { const slot = this.requests.peek()?.get(path); if (!slot) return; - slot.route.set(this.route(path, slot)); + 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, slot: RequestSlot): broadcast.Consumer | undefined { - return this.local.peek()?.get(path) ?? this.remote.peek()?.get(path)?.[0] ?? slot.answer; + route(path: Path.Valid, answer?: broadcast.Consumer): broadcast.Consumer | undefined { + return this.local.peek()?.get(path) ?? this.remote.peek()?.get(path)?.[0] ?? answer; } } @@ -257,7 +257,9 @@ export class Producer implements Table { return () => { if (released) return; released = true; - this.#state.answerers.update((count) => count - 1); + // 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)); }; } @@ -355,6 +357,9 @@ export class Producer implements Table { } 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(); @@ -532,10 +537,12 @@ export class Consumer { let slot = requests.get(path); if (!slot) { - const created: RequestSlot = { count: 0, route: new Signal(undefined) }; - // Seeded before anyone can watch it, so a path the table already routes resolves - // on the first read rather than a microtask later. - created.route.set(this.#state.route(path, created), false); + // 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);