From 9a3dcef06b10e8857f663b09a12f0372331cc871 Mon Sep 17 00:00:00 2001 From: Luke Curley <432854+kixelated@users.noreply.github.com> Date: Thu, 22 Oct 2026 21:46:06 -0700 Subject: [PATCH 01/15] chore(quest): claim quest/m1/public-model/claim-request-type.md Quest-Claim: c17f011f45b743c2 From d7da82760a1a72e50d3a08a44b69c0b82dee16c6 Mon Sep 17 00:00:00 2001 From: Luke Curley <432854+kixelated@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:07:59 -0700 Subject: [PATCH 02/15] feat(net)!: an application claim's request cannot accept Split the claim handle by who answers it. `origin::Producer::dynamic` returns a `Dynamic` whose `Request` only prepares or rejects; the hidden `dynamic_route` returns a `Forward` whose `ForwardRequest` keeps `accept`. Calling `accept` on an application claim's request now fails to compile (Rust) or typecheck (JS) instead of panicking or throwing, so the runtime guards and their tests are gone. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EeyiEy5FWz3z44DYZVo7vU --- js/net/MIGRATE.md | 8 +- js/net/src/connection/forward.ts | 6 +- js/net/src/origin-request.test.ts | 19 +- js/net/src/origin.ts | 394 +++++++++++------- js/net/src/wire.ts | 2 +- quest/m1/api-audit/README.md | 2 +- quest/m1/api-audit/announce-owner.md | 1 - quest/m1/api-audit/js-prepared.md | 8 - quest/m1/public-model/README.md | 1 - quest/m1/public-model/claim-request-type.md | 41 -- quest/m1/public-model/routes.md | 9 +- rs/moq-c/src/test.rs | 2 +- rs/moq-hls/src/export/mod.rs | 2 +- rs/moq-net/MIGRATE.md | 8 +- rs/moq-net/benches/origin.rs | 2 +- rs/moq-net/benches/retirement.rs | 6 +- rs/moq-net/src/fuzz.rs | 2 +- rs/moq-net/src/ietf/subscriber.rs | 2 +- rs/moq-net/src/lite/subscriber.rs | 6 +- rs/moq-net/src/model/broadcast.rs | 2 +- rs/moq-net/src/model/follow.rs | 3 +- rs/moq-net/src/model/mod.rs | 2 + rs/moq-net/src/model/origin.rs | 349 ++++++++++------ rs/moq-net/tests/integration/drained_claim.rs | 2 +- rs/moq-net/tests/integration/restart.rs | 4 +- 25 files changed, 534 insertions(+), 349 deletions(-) delete mode 100644 quest/m1/public-model/claim-request-type.md diff --git a/js/net/MIGRATE.md b/js/net/MIGRATE.md index 85a6485f847..1e35dd260c5 100644 --- a/js/net/MIGRATE.md +++ b/js/net/MIGRATE.md @@ -4,6 +4,12 @@ How to update your code for each breaking change, newest first. ## Unreleased +### An application claim's request has no `accept` ([#6496](https://github.com/moq-dev/moq/pull/6496)) + +`Origin.Request`, from `Origin.Producer.dynamic`, no longer has the internal `accept`, so calling it fails to typecheck instead of throwing. +Answer it with `prepare(epoch)` then `Prepared.publish()`, or `reject`. +The internal `Origin.Producer.dynamicRoute` now returns an `Origin.Forward`, whose `Origin.ForwardRequest` keeps `accept`. + ### Stage a requested track before subscribers see it (#6111) `Track.Request.stage(info)` returns a `Track.Staged` whose `producer` writes while subscribers waiting on the request stay pending; `commit()` publishes it with everything written so far and returns the `Producer`. @@ -34,7 +40,7 @@ Treat a rejected group read as the loss of that group and keep reading the track Answer each `Origin.Request` with `request.prepare(epoch)` (a fresh `Epoch.mint()` per output run), fill `prepared.broadcast`, then `prepared.publish()`, which announces the broadcast at the exact path under that epoch and returns its producer. Only `publish` announces it: `prepared.broadcast.announce()` throws. Call `prepared.reject(err)` to refuse instead; JS has no destructor, so an abandoned `Prepared` holds its request open. -`Dynamic.update` and `Request.accept` are no longer part of the public API, and `accept` throws on a request from `dynamic`. +`Dynamic.update` and `Request.accept` are no longer part of the public API. Close the claim and claim again to change it, and close an answer's producer to end that output. ### `largest()` reports within a reader's `setGroups` bounds diff --git a/js/net/src/connection/forward.ts b/js/net/src/connection/forward.ts index 684482c7977..5a55b8ada3f 100644 --- a/js/net/src/connection/forward.ts +++ b/js/net/src/connection/forward.ts @@ -9,7 +9,7 @@ import { type Dispose, race } from "@moq/signals"; import type * as Announce from "../announced.ts"; import { type Route, routesEqual } from "../hop.ts"; import { coveringPrefixes } from "../internal.ts"; -import type { Dynamic, Producer as OriginProducer, RequestSlot } from "../origin.ts"; +import type { Forward, Producer as OriginProducer, RequestSlot } from "../origin.ts"; import * as Path from "../path.ts"; import { after } from "../util/timer.ts"; import { wireOf } from "../wire.ts"; @@ -39,7 +39,7 @@ interface Interest { * request handing over to another never retracts it. The first reporter drives it. */ interface Reported { - handle: Dynamic; + handle: Forward; /** Each request's latest route for the prefix. */ readonly reporters: Map; } @@ -272,7 +272,7 @@ export function forwardAnnounced(conn: Established, origin: OriginProducer): voi } /** Answer the requests under a received route by consuming them from the session. */ -async function drive(handle: Dynamic, conn: Established): Promise { +async function drive(handle: Forward, conn: Established): Promise { const session = wireOf(conn); try { for await (const request of handle.requested()) { diff --git a/js/net/src/origin-request.test.ts b/js/net/src/origin-request.test.ts index 4a92d9b7113..db419e7b6a4 100644 --- a/js/net/src/origin-request.test.ts +++ b/js/net/src/origin-request.test.ts @@ -2,7 +2,7 @@ import { expect, test } from "bun:test"; import { Producer as BroadcastProducer } from "./broadcast"; import * as Epoch from "./epoch"; import { Route } from "./hop"; -import { Producer } from "./origin"; +import { Producer, type Request } from "./origin"; import * as Path from "./path"; import { wireOf } from "./wire"; @@ -341,17 +341,12 @@ test("a prepared answer cannot announce itself before publish", async () => { origin.close(); }); -test("an application claim refuses an arbitrary broadcast", async () => { - const origin = new Producer(); - const claim = origin.dynamic(Path.from("room")); - const requests = claim.requested(); - const request = origin.request(Path.from("room/alice")); - const pending = (await requests.next()).value; - if (!pending) throw new Error("expected a request"); - expect(() => pending.accept(new BroadcastProducer())).toThrow("prepared and published"); - request.close(); - claim.close(); - origin.close(); +test("an application claim's request cannot accept an arbitrary broadcast", () => { + const answer = (request: Request) => { + // @ts-expect-error An application claim answers through prepare and publish. + request.accept(new BroadcastProducer()); + }; + expect(answer).toBeDefined(); }); test("rejecting a prepared answer refuses the request and closes its broadcast", async () => { diff --git a/js/net/src/origin.ts b/js/net/src/origin.ts index c42bbc876bb..702e8e2f498 100644 --- a/js/net/src/origin.ts +++ b/js/net/src/origin.ts @@ -387,8 +387,8 @@ class ServeState { this.originated = originated; } - queue = new Signal(new Set()); - pending = new Map(); + queue = new Signal(new Set()); + pending = new Map(); served = new Map(); rejected = new Map(); // demand() is the only reader of `rejected`. A Consumer.request refusal never @@ -408,7 +408,7 @@ class ServeState { if (this.pending.has(path)) return; const live = this.served.get(path); if (live && live.closed.peek() === undefined) return; - const request = makeRequest(Path.stripPrefix(this.root, path) ?? Path.empty(), this); + const request = new Handoff(Path.stripPrefix(this.root, path) ?? Path.empty(), this); this.pending.set(path, request); this.queue.mutate((queue) => { queue.add(request); @@ -416,17 +416,17 @@ class ServeState { } /** Withdraw only this pending request; a later answer cannot replace a newer one. */ - forget(path: Path.Valid, request: Request): void { + forget(path: Path.Valid, request: Handoff): void { if (this.pending.get(path) !== request) return; this.pending.delete(path); this.queue.mutate((queue) => { queue.delete(request); }); this.rejected.delete(path); - finishRequest(request, unroutable()); + request.withdraw(); } - accept(request: Request, front: broadcast.Consumer): void { + accept(request: Handoff, front: broadcast.Consumer): void { const path = Path.join(this.root, request.path); if (this.closed.peek() !== undefined || this.pending.get(path) !== request) { front.close(); @@ -453,11 +453,11 @@ class ServeState { this.settled.update((n) => n + 1); } - has(request: Request): boolean { + has(request: Handoff): boolean { return this.closed.peek() === undefined && this.pending.get(Path.join(this.root, request.path)) === request; } - reject(request: Request, err: Error): void { + reject(request: Handoff, err: Error): void { const path = Path.join(this.root, request.path); if (this.pending.get(path) !== request) return; this.pending.delete(path); @@ -468,16 +468,15 @@ class ServeState { close(abort?: Error): void { if (this.closed.peek() !== undefined) return; - const err = abort ?? unroutable(); - this.closed.set(err); - this.reset(err); + this.closed.set(abort ?? unroutable()); + this.reset(); } /** * Release answers and the requests handlers hold when the publisher instance changes: a * handler's late answer is for the old one, and the requesters ask the new one again. */ - reset(err: Error = unroutable()): void { + reset(): void { this.resets++; const queued = [...this.pending.values()]; this.pending.clear(); @@ -485,7 +484,7 @@ class ServeState { queue.clear(); }); for (const request of queued) { - finishRequest(request, err); + request.withdraw(); } const served = [...this.served]; this.served.clear(); @@ -499,6 +498,33 @@ class ServeState { } } +/** A request handed to a handler, pending in its {@link ServeState} until answered or released. */ +class Handoff { + /** The path that was requested, relative to the origin's root. */ + readonly path: Path.Valid; + readonly server: ServeState; + /** Answered, refused, or released: it takes no further answer. */ + done = false; + /** Released unanswered, so an answer handed to it afterwards is closed. */ + withdrawn = false; + + constructor(path: Path.Valid, server: ServeState) { + this.path = path; + this.server = server; + } + + withdraw(): void { + this.done = true; + this.withdrawn = true; + } + + reject(err: Error): void { + if (this.done) return; + this.done = true; + this.server.reject(this, err); + } +} + interface Presented extends Advertised { readonly captures: Path.Pattern[] | undefined; } @@ -1112,7 +1138,7 @@ export interface Table { dynamicRoute( prefix: Path.Valid, route?: Route | { epoch?: Route["epoch"]; hops?: Route["hops"]; cost?: Route["cost"]; auth?: Route["auth"] }, - ): Dynamic; + ): Forward; } /** Options for resolving a broadcast path. */ @@ -1304,7 +1330,7 @@ export class Producer implements Table { * no announced local broadcast wins is handed to the handle as a {@link Request}. */ dynamic(prefix: Path.Valid): Dynamic { - return this.#insertRoute(prefix, Route.default, true, true); + return makeDynamic(this.#insertRoute(prefix, Route.default, true), this); } /** Insert an originated claim with route metadata that answers through `accept`, for tests and benches. @internal */ @@ -1318,8 +1344,8 @@ export class Producer implements Table { cost?: Route["cost"]; auth?: Route["auth"]; } = Route.default, - ): Dynamic { - return this.#insertRoute(prefix, Route.normalize(route), true, false); + ): Forward { + return makeForward(this.#insertRoute(prefix, Route.normalize(route), true)); } /** @@ -1338,11 +1364,11 @@ export class Producer implements Table { cost?: Route["cost"]; auth?: Route["auth"]; } = Route.default, - ): Dynamic { - return this.#insertRoute(prefix, Route.normalize(route), false, false); + ): Forward { + return makeForward(this.#insertRoute(prefix, Route.normalize(route), false)); } - #insertRoute(prefix: Path.Valid, route: Route, originated: boolean, publishes: boolean): Dynamic { + #insertRoute(prefix: Path.Valid, route: Route, originated: boolean): Serve { prefix = this.#scope.prefix(prefix); const server = new ServeState(this.#scope.root, prefix, originated); server.onChange = (path) => this.#state.refresh(path); @@ -1369,7 +1395,7 @@ export class Producer implements Table { }); if (closed) { server.close(); - return makeDynamic(prefix, entry, this.#state, () => {}, publishes ? this : undefined); + return new Serve(prefix, entry, this.#state, () => {}); } this.#state.rebuildOriginated(); this.#state.refreshPrefix(prefix); @@ -1388,7 +1414,7 @@ export class Producer implements Table { this.#state.refreshPrefix(prefix); }; - return makeDynamic(prefix, entry, this.#state, retract, publishes ? this : undefined); + return new Serve(prefix, entry, this.#state, retract); } /** @@ -1572,16 +1598,16 @@ let makeRequesting: ( dispose: Dispose, ) => Requesting; -let makeDynamic: ( - prefix: Path.Valid, - entry: RouteEntry, - state: OriginState, - retract: Dispose, - publisher?: Producer, -) => Dynamic; - -let makeRequest: (path: Path.Valid, server: ServeState) => Request; -let finishRequest: (request: Request, err: Error) => void; +let makeDynamic: (serve: Serve, publisher: Producer) => Dynamic; +let makeForward: (serve: Serve) => Forward; +let makeRequest: (handoff: Handoff, publisher: Producer) => Request; +let makeForwardRequest: (handoff: Handoff) => ForwardRequest; +let makePrepared: ( + handoff: Handoff, + answer: broadcast.Producer, + announcer: broadcast.Announcer, + epoch: Epoch.Valid, +) => Prepared; /** * An owned request for a broadcast path; see {@link Consumer.request}. @@ -2238,67 +2264,27 @@ export class Consumer { } } -/** - * A served route from {@link Producer.dynamic}: advertises a prefix and answers the - * requests beneath it. - * - * Drop it (or {@link close}) to retract the route and reject anything still waiting - * with {@link StreamCode.Unroutable}. {@link update} re-prices it in place. - * - * @public - */ -export class Dynamic { - /** The prefix this handle advertises. */ +/** The advertisement and request queue behind a {@link Dynamic} or {@link Forward}. */ +class Serve { + /** The prefix this route advertises, relative to its scope's root. */ readonly prefix: Path.Valid; #entry: RouteEntry; #state: OriginState; #retract: Dispose; - #publisher?: Producer; #closed = false; - private constructor( - prefix: Path.Valid, - entry: RouteEntry, - state: OriginState, - retract: Dispose, - publisher?: Producer, - ) { + constructor(prefix: Path.Valid, entry: RouteEntry, state: OriginState, retract: Dispose) { this.prefix = Path.stripPrefix(entry.scope.root, prefix) ?? Path.empty(); this.#entry = entry; this.#state = state; this.#retract = retract; - this.#publisher = publisher; } - static { - makeDynamic = (prefix, entry, state, retract, publisher) => - new Dynamic(prefix, entry, state, retract, publisher); - } - - /** The route this handle advertises. */ get route(): Route { return this.#entry.route.peek(); } - /** - * Replace the route in place. The prefix is fixed at announce time. - * - * The route is taken as given. At the same epoch this re-prices: consumers see an update - * and every handle survives. Another epoch, or none, names another publisher instance: - * consumers see a restart, resolved requests keep their old broadcast until closed, and a - * re-request never joins it. Requests still waiting on this handle carry over: the handler - * is asked again under the new epoch, and its answer to a request asked before the change - * is dropped, never served under the new epoch. A new or unresolved request pinned to the - * old epoch is refused as unroutable. The answers already served are forgotten, so the next - * request for one of those paths asks the handler again too. The broadcasts it served keep - * running for the subscriptions already on them; close them to end those too. - * Route selection still applies: another route still at the old epoch outranks one - * without. To re-price, start from the current route, `updateRoute({ ...dynamic.route, cost })`. - * - * @internal - */ - // pending: /quest/m1/public-model/answer.md updateRoute( route: Route | { epoch?: Route["epoch"]; hops?: Route["hops"]; cost?: Route["cost"]; auth?: Route["auth"] }, ): void { @@ -2313,18 +2299,17 @@ export class Dynamic { this.#state.routes.mutate(() => {}); } - /** Retract the route and reject anything still waiting. Idempotent. */ close(): void { if (this.#closed) return; this.#closed = true; this.#retract(); } - /** Requests under this prefix, as they arrive, each to {@link Request.prepare} or reject. */ - async *requested(): AsyncIterableIterator { + /** Requests under this prefix as they arrive; advancing or closing refuses the last one unanswered. */ + async *requested(): AsyncIterableIterator { const server = this.#entry.server; if (!server) return; - let current: Request | undefined; + let current: Handoff | undefined; const drop = () => { current?.reject(unroutable()); current = undefined; @@ -2340,7 +2325,6 @@ export class Dynamic { queue.delete(next.value); }); current = next.value; - current.publisher = this.#publisher; yield next.value; continue; } @@ -2354,12 +2338,128 @@ export class Dynamic { } } +/** + * A served route from {@link Producer.dynamic}: advertises a prefix and answers the + * requests beneath it. + * + * Drop it (or {@link close}) to retract the route and reject anything still waiting + * with {@link StreamCode.Unroutable}. + * + * @public + */ +export class Dynamic { + /** The prefix this handle advertises. */ + readonly prefix: Path.Valid; + + #serve: Serve; + #publisher: Producer; + + private constructor(serve: Serve, publisher: Producer) { + this.prefix = serve.prefix; + this.#serve = serve; + this.#publisher = publisher; + } + + static { + makeDynamic = (serve, publisher) => new Dynamic(serve, publisher); + } + + /** The route this handle advertises. */ + get route(): Route { + return this.#serve.route; + } + + /** + * Replace the route in place; see {@link Forward.updateRoute}. + * + * @internal + */ + // pending: /quest/m1/public-model/answer.md + updateRoute( + route: Route | { epoch?: Route["epoch"]; hops?: Route["hops"]; cost?: Route["cost"]; auth?: Route["auth"] }, + ): void { + this.#serve.updateRoute(route); + } + + /** Retract the route and reject anything still waiting. Idempotent. */ + close(): void { + this.#serve.close(); + } + + /** Requests under this prefix, as they arrive, each to {@link Request.prepare} or reject. */ + async *requested(): AsyncIterableIterator { + for await (const handoff of this.#serve.requested()) yield makeRequest(handoff, this.#publisher); + } +} + +/** + * A peer's route landed by a session, from {@link Producer.dynamicRoute}: answers each + * request with the broadcast the session already routes from that peer. + * + * Drop it (or {@link close}) to retract the route and reject anything still waiting + * with {@link StreamCode.Unroutable}. {@link updateRoute} re-prices it in place. + * + * @internal + */ +// pending: /quest/m1/public-model/answer.md +export class Forward { + /** The prefix this handle advertises. */ + readonly prefix: Path.Valid; + + #serve: Serve; + + private constructor(serve: Serve) { + this.prefix = serve.prefix; + this.#serve = serve; + } + + static { + makeForward = (serve) => new Forward(serve); + } + + /** The route this handle advertises. */ + get route(): Route { + return this.#serve.route; + } + + /** + * Replace the route in place. The prefix is fixed at announce time. + * + * The route is taken as given. At the same epoch this re-prices: consumers see an update + * and every handle survives. Another epoch, or none, names another publisher instance: + * consumers see a restart, resolved requests keep their old broadcast until closed, and a + * re-request never joins it. Requests still waiting on this handle carry over: the handler + * is asked again under the new epoch, and its answer to a request asked before the change + * is dropped, never served under the new epoch. A new or unresolved request pinned to the + * old epoch is refused as unroutable. The answers already served are forgotten, so the next + * request for one of those paths asks the handler again too. The broadcasts it served keep + * running for the subscriptions already on them; close them to end those too. + * Route selection still applies: another route still at the old epoch outranks one + * without. To re-price, start from the current route, `updateRoute({ ...forward.route, cost })`. + */ + updateRoute( + route: Route | { epoch?: Route["epoch"]; hops?: Route["hops"]; cost?: Route["cost"]; auth?: Route["auth"] }, + ): void { + this.#serve.updateRoute(route); + } + + /** Retract the route and reject anything still waiting. Idempotent. */ + close(): void { + this.#serve.close(); + } + + /** Requests under this prefix, as they arrive, each to {@link ForwardRequest.accept} or reject. */ + async *requested(): AsyncIterableIterator { + for await (const handoff of this.#serve.requested()) yield makeForwardRequest(handoff); + } +} + /** * A pending request for a broadcast to be served on demand. * - * Yielded by {@link Dynamic.requested}. {@link accept} resolves it with a live - * broadcast; {@link reject} resolves it with an error. Advancing the iterator or - * closing it without either rejects the request. + * Yielded by {@link Dynamic.requested}. {@link prepare} then {@link Prepared.publish} + * resolves it with a new publisher instance; {@link reject} resolves it with an error. + * Advancing the iterator or closing it without either rejects the request. * * @public */ @@ -2367,62 +2467,60 @@ export class Request { /** The path that was requested. */ readonly path: Path.Valid; - #server: ServeState; - #done = false; - #withdrawn = false; - /** @internal Set only by an application claim. */ - publisher?: Producer; + #handoff: Handoff; + #publisher: Producer; - private constructor(path: Path.Valid, server: ServeState) { - this.path = path; - this.#server = server; + private constructor(handoff: Handoff, publisher: Producer) { + this.path = handoff.path; + this.#handoff = handoff; + this.#publisher = publisher; } static { - makeRequest = (path, server) => new Request(path, server); - finishRequest = (request, err) => { - request.#done = true; - request.#withdrawn = true; - void err; - }; + makeRequest = (handoff, publisher) => new Request(handoff, publisher); } /** Start answering as a new publisher instance, hidden until published. */ prepare(epoch: Epoch.Valid): Prepared { - if (this.#done) throw new Error("request is closed"); - if (!this.publisher) throw new Error("forwarded requests cannot be prepared"); - this.#done = true; - const answer = this.publisher.createBroadcast(this.path); + if (this.#handoff.done) throw new Error("request is closed"); + this.#handoff.done = true; + const answer = this.#publisher.createBroadcast(this.path); // Withheld until publish, so the answer cannot announce itself under another // epoch or skip the request's admission. const announcer = hooks.withholdAnnouncer(answer); if (!announcer) throw new Error("origin is closed"); - return new Prepared(this, answer, announcer, epoch); + return makePrepared(this.#handoff, answer, announcer, epoch); } - /** @internal Publish a prepared answer after its tracks have been filled. */ - publishPrepared( - answer: broadcast.Producer, - announcer: broadcast.Announcer, - epoch: Epoch.Valid, - ): broadcast.Producer { - if (!this.#server.has(this)) throw new Error("request is closed"); - if (answer.closed.peek() !== undefined) throw new Error("broadcast is closed"); - hooks.attachAnnouncer(answer, announcer); - // The exact route usually settles the request on its own, resolving its waiters - // through the new epoch; answer it directly if it is still pending. - answer.announce({ epoch }); - if (this.#server.has(this)) this.#server.accept(this, answer.consume()); - return answer; + /** Reject the request, resolving every awaiting requester with `err`. */ + reject(err: Error): void { + this.#handoff.reject(err); } +} - /** @internal Refuse the request a prepared answer abandoned. */ - rejectPrepared(err: Error): void { - this.#server.reject(this, err); +/** + * A pending request under a {@link Forward}, answered with the broadcast its session + * already routes from the peer. + * + * @internal + */ +export class ForwardRequest { + /** The path that was requested. */ + readonly path: Path.Valid; + + #handoff: Handoff; + + private constructor(handoff: Handoff) { + this.path = handoff.path; + this.#handoff = handoff; + } + + static { + makeForwardRequest = (handoff) => new ForwardRequest(handoff); } /** - * Resolve a session-forwarded request with its peer's routed broadcast. + * Resolve the request with its peer's routed broadcast. * * The caller keeps producing into `broadcast`; repeat requests for the path share * it for as long as it stays live. @@ -2431,31 +2529,28 @@ export class Request { * this origin's hop. To serve upstream content, copy its tracks into a prepared answer * and publish it. * - * @throws Error when handed a broadcast a session delivered. The request stays open. - * @internal + * @throws Error when an originated route is handed a broadcast a session delivered. The + * request stays open. */ - // pending: /quest/m1/public-model/answer.md accept(source: broadcast.Producer | broadcast.Consumer): void { - if (this.#done) { + const handoff = this.#handoff; + if (handoff.done) { // A withdrawn request takes no answer. Only the handed-off consumer is ours; // the application still owns its producer and any other consumers. - if (this.#withdrawn && source instanceof broadcast.Consumer) source.close(); + if (handoff.withdrawn && source instanceof broadcast.Consumer) source.close(); return; } - if (this.publisher) throw new Error("claim answers must be prepared and published"); - if (this.#server.originated && !(source instanceof broadcast.Producer) && wireOf(source).fromSession) { + if (handoff.server.originated && !(source instanceof broadcast.Producer) && wireOf(source).fromSession) { throw new Error("origin cannot serve a broadcast it did not produce"); } - this.#done = true; + handoff.done = true; const front = source instanceof broadcast.Producer ? source.consume() : source; - this.#server.accept(this, front); + handoff.server.accept(handoff, front); } /** Reject the request, resolving every awaiting requester with `err`. */ reject(err: Error): void { - if (this.#done) return; - this.#done = true; - this.#server.reject(this, err); + this.#handoff.reject(err); } } @@ -2463,28 +2558,45 @@ export class Request { export class Prepared { /** The unannounced broadcast to fill before publishing; only {@link publish} announces it. */ readonly broadcast: broadcast.Producer; - readonly #request: Request; + readonly #handoff: Handoff; readonly #announcer: broadcast.Announcer; readonly #epoch: Epoch.Valid; #done = false; - /** @internal */ - constructor(request: Request, answer: broadcast.Producer, announcer: broadcast.Announcer, epoch: Epoch.Valid) { - this.#request = request; + private constructor( + handoff: Handoff, + answer: broadcast.Producer, + announcer: broadcast.Announcer, + epoch: Epoch.Valid, + ) { + this.#handoff = handoff; this.broadcast = answer; this.#announcer = announcer; this.#epoch = epoch; } + static { + makePrepared = (handoff, answer, announcer, epoch) => new Prepared(handoff, answer, announcer, epoch); + } + /** Announce the filled broadcast and resolve every waiting requester, refusing them if it cannot be announced. */ publish(): broadcast.Producer { if (this.#done) throw new Error("prepared answer is closed"); this.#done = true; + const handoff = this.#handoff; + const answer = this.broadcast; try { - return this.#request.publishPrepared(this.broadcast, this.#announcer, this.#epoch); + if (!handoff.server.has(handoff)) throw new Error("request is closed"); + if (answer.closed.peek() !== undefined) throw new Error("broadcast is closed"); + hooks.attachAnnouncer(answer, this.#announcer); + // The exact route usually settles the request on its own, resolving its waiters + // through the new epoch; answer it directly if it is still pending. + answer.announce({ epoch: this.#epoch }); + if (handoff.server.has(handoff)) handoff.server.accept(handoff, answer.consume()); + return answer; } catch (err) { - this.broadcast.close(); - this.#request.rejectPrepared(err instanceof Error ? err : new Error(String(err))); + answer.close(); + handoff.server.reject(handoff, err instanceof Error ? err : new Error(String(err))); throw err; } } @@ -2494,6 +2606,6 @@ export class Prepared { if (this.#done) return; this.#done = true; this.broadcast.close(); - this.#request.rejectPrepared(err); + this.#handoff.server.reject(this.#handoff, err); } } diff --git a/js/net/src/wire.ts b/js/net/src/wire.ts index 709302ca558..3c638cf2a47 100644 --- a/js/net/src/wire.ts +++ b/js/net/src/wire.ts @@ -67,7 +67,7 @@ export interface OriginProducer { receive( prefix: Path.Valid, route?: Route | { epoch?: Route["epoch"]; hops?: Route["hops"]; cost?: Route["cost"]; auth?: Route["auth"] }, - ): origin.Dynamic; + ): origin.Forward; // pending: /quest/m1/public-model/answer.md attach(discovery: boolean): Dispose; // pending: /quest/m1/public-model/answer.md diff --git a/quest/m1/api-audit/README.md b/quest/m1/api-audit/README.md index a223e5ad187..8a85f7c979f 100644 --- a/quest/m1/api-audit/README.md +++ b/quest/m1/api-audit/README.md @@ -98,7 +98,7 @@ Rejected in planning: ## Related -- [Claim request type](/quest/m1/public-model/claim-request-type.md) - removes `accept` from application requests; [the origin announces](/quest/m1/api-audit/announce-owner.md) narrows `Prepared` beside it +- [The origin announces](/quest/m1/api-audit/announce-owner.md) - narrows `Prepared` to its fill surface - [Readers follow restarts](/quest/m1/sink-restart-follow.md) - moves readers onto the follow operation that [two broadcast resolutions](/quest/m1/api-audit/resolve.md) names - [API-diff check on release PRs](/quest/m1/api-diff.md) - the backstop that catches breaks this epic misses - [Main is no slower than release](/quest/m1/release-perf/README.md) - the other release-readiness check diff --git a/quest/m1/api-audit/announce-owner.md b/quest/m1/api-audit/announce-owner.md index 8b06e16b98f..6341d46a9b2 100644 --- a/quest/m1/api-audit/announce-owner.md +++ b/quest/m1/api-audit/announce-owner.md @@ -28,5 +28,4 @@ trap). Wire: none. ## Related -- [Claim request type](/quest/m1/public-model/claim-request-type.md) - splits `accept` off application requests in the same file; coordinate the order - [Public routes and answering](/quest/m1/public-model/answer.md) - later makes the forwarding claim public diff --git a/quest/m1/api-audit/js-prepared.md b/quest/m1/api-audit/js-prepared.md index 84108f75c22..cef7e0c389c 100644 --- a/quest/m1/api-audit/js-prepared.md +++ b/quest/m1/api-audit/js-prepared.md @@ -19,10 +19,6 @@ closes (`js/net/src/origin.ts`, `prepare`, `Prepared`, `ServeState.enqueue`). `[Symbol.dispose]` (`Bandwidth.Reservation` is the precedent), mirroring Rust where dropping a `Prepared` refuses with `Unroutable`. - Closing `prepared.broadcast` before `publish()` abandons the request too. -- Make the `Prepared` constructor private with a static factory, and move - `Request.publisher`, `publishPrepared`, and `rejectPrepared` into module - hooks (the `finishRequest` pattern), so callers cannot bypass the done - state or the "forwarded requests cannot be prepared" check. - Update `doc/lib/js/net.md`, the `js/net/MIGRATE.md` "A claim answers by publishing" entry, and `origin-request.test.ts`; examples use `using`. @@ -30,7 +26,3 @@ Test: a handler that throws after `prepare()` leaves the path answerable by the next request. moq.pro has no JS claim handlers. Public API: `@moq/net` breaking (`Prepared.reject` to `close`). Wire: none. - -## Related - -- [Claim request type](/quest/m1/public-model/claim-request-type.md) - the JS `accept` half lives there diff --git a/quest/m1/public-model/README.md b/quest/m1/public-model/README.md index 2d3df7a7f04..7ee4fca2d07 100644 --- a/quest/m1/public-model/README.md +++ b/quest/m1/public-model/README.md @@ -102,7 +102,6 @@ Work owned by this README, after the children land: ## Required -- [Claim request type](/quest/m1/public-model/claim-request-type.md) - accepting an application claim's request fails to compile instead of panicking - [Public serving](/quest/m1/public-model/serve.md) - a custom publisher reads a track's start, cursor, and group expiry for its wire through public calls - [Public answering](/quest/m1/public-model/answer.md) - a custom relay answers broadcast and track requests on demand and closes them once unused - [Public routes](/quest/m1/public-model/routes.md) - a custom relay ingests routes under a peer's identity and advertises without echoing them back, once cluster routing lands diff --git a/quest/m1/public-model/claim-request-type.md b/quest/m1/public-model/claim-request-type.md deleted file mode 100644 index 91c37ff4a85..00000000000 --- a/quest/m1/public-model/claim-request-type.md +++ /dev/null @@ -1,41 +0,0 @@ -# [S] An application claim's request cannot be accepted - -## Goal - -Answering an application claim's request with `accept` fails to compile in -Rust and to typecheck in TypeScript, instead of panicking (Rust) or throwing -(JS) at runtime. `origin::Producer::dynamic(prefix)` hands out requests that -only `prepare` and `reject`; the session-forwarded claim from -`dynamic_route` / `dynamicRoute` keeps `accept`. Behavior is otherwise -unchanged: the escape hatches stay hidden until -[Public routes](/quest/m1/public-model/routes.md) gives them a -public shape. - -## Plan - -Decided 2026-10-21 (maintainer), while moving a downstream application onto -[#5678](https://github.com/moq-dev/moq/pull/5678): its handler's -`request.accept(..)` on a `dynamic()` claim still compiled against the new -API and would have panicked on the first request in production. #5678's -decision 1 kept `accept` `#[doc(hidden)] pub` as the interim, guarded only by -the runtime check. Rejected: folding this into answer.md, which leaves the -trap until that [L] lands; opening the code PR without a quest. - -Kept from #5678: prepare and publish, one epoch per answer, and the accepted -cold-start reset until -[announce takeover](/quest/m1/announce-takeover.md) lands. The downstream -check confirmed the reset is recoverable: a reader that follows the -announcement reaches the published answer, while a one-shot reader (an HTTP -fetch) cannot and gets `Unroutable`. - -Guidance: split the request type by the handle that yields it, for example -`dynamic_route` returning its own handle whose request carries `accept`, -rather than one `Request` with an optional publisher. Delete the runtime -assert and its test once the type makes the call unreachable; a -compile-fail doctest (Rust) and a `@ts-expect-error` line (JS) take their -place. Bindings (moq-ffi, moq-c) already expose no `accept` and need no -change. - -## Related - -- [Public routes](/quest/m1/public-model/routes.md) - later removes the hidden peer-claim API this quest narrows diff --git a/quest/m1/public-model/routes.md b/quest/m1/public-model/routes.md index 0b95b164003..dd7c7998d06 100644 --- a/quest/m1/public-model/routes.md +++ b/quest/m1/public-model/routes.md @@ -41,9 +41,10 @@ the route layer under their new types, and drop items whose need it removes - A public `Route::with_via`, which a caller could forward by mistake. - `origin::Consumer::excluding` (both publishers, client): public; split-horizon toward a peer, the counterpart of the identity above. -- **Peer-route escape hatches** (`origin::Producer::dynamic_route`, - `origin::Dynamic::update_route`, `origin::Request::accept`; JS - `Origin.Producer.dynamicRoute`, `Dynamic.updateRoute`, `Request.accept`). +- **Peer-route escape hatches** (`origin::Producer::dynamic_route` with + the `origin::Forward` and `origin::ForwardRequest` it yields, and + `origin::Dynamic::update_route`; JS `Origin.Producer.dynamicRoute`, + `Forward`, `ForwardRequest`, and `Dynamic.updateRoute`). Settled as the interim in [#5678](https://github.com/moq-dev/moq/pull/5678): an application claim (`dynamic(prefix)`) carries no route and answers through `Request::prepare` then `Prepared::publish`, and these stay @@ -56,7 +57,7 @@ the route layer under their new types, and drop items whose need it removes - `wire.ts` `OriginProducer.receive` (`connection/forward.ts`): mirror the identity decision. A received route is a public `Origin.Producer.dynamic` on a handle marked with the peer's identity, so `receive` goes and - `Origin.Request.accept` stops special-casing session-delivered broadcasts + `Origin.ForwardRequest.accept` stops special-casing session-delivered broadcasts (`Broadcast.fromSession`). - `wire.ts` `OriginConsumer.advertised`, `local`, `demand`, with `Advertised`, `Advertisements`, `sameInstance`, and `internal.ts` diff --git a/rs/moq-c/src/test.rs b/rs/moq-c/src/test.rs index d98cc3fe827..91099b3dc49 100644 --- a/rs/moq-c/src/test.rs +++ b/rs/moq-c/src/test.rs @@ -3707,7 +3707,7 @@ fn consume_announced_delivers_a_held_takeover_once() { } /// Serve `prefix` on `origin` dynamically through `route`. -fn serve_dynamic(origin: u32, prefix: &str, route: moq_net::origin::Route) -> moq_net::origin::Dynamic { +fn serve_dynamic(origin: u32, prefix: &str, route: moq_net::origin::Route) -> moq_net::origin::Forward { let _runtime = crate::ffi::RUNTIME.enter(); let origin = crate::Id::try_from(origin).unwrap(); State::lock() diff --git a/rs/moq-hls/src/export/mod.rs b/rs/moq-hls/src/export/mod.rs index 2eed7f670e4..35c583a249f 100644 --- a/rs/moq-hls/src/export/mod.rs +++ b/rs/moq-hls/src/export/mod.rs @@ -3546,7 +3546,7 @@ mod tests { media } - async fn accept_sibling(server: &moq_net::origin::Dynamic, broadcast: &moq_net::broadcast::Producer) { + async fn accept_sibling(server: &moq_net::origin::Forward, broadcast: &moq_net::broadcast::Producer) { let request = tokio::time::timeout(Duration::from_secs(5), server.requested_broadcast()) .await .expect("the sibling path is requested") diff --git a/rs/moq-net/MIGRATE.md b/rs/moq-net/MIGRATE.md index e16d361e179..93d7f40e49f 100644 --- a/rs/moq-net/MIGRATE.md +++ b/rs/moq-net/MIGRATE.md @@ -4,6 +4,12 @@ How to update your code for each breaking change, newest first. ## Unreleased +### An application claim's request has no `accept` ([#6496](https://github.com/moq-dev/moq/pull/6496)) + +`origin::Request`, from `origin::Producer::dynamic`, no longer has the hidden `accept`, so calling it fails to compile instead of panicking. +Answer it with `prepare(epoch)` then `Prepared::publish`, or `reject`. +The hidden `origin::Producer::dynamic_route` now returns an `origin::Forward`, whose `origin::ForwardRequest` keeps `accept`; name those types where you named `Dynamic` or `Request` for one. + ### IETF whole-group FETCH answers name the requested group ([#6199](https://github.com/moq-dev/moq/issues/6199)) On moq-transport drafts 14 through 19, a whole-group `FETCH_OK` now names `{g, 0}` instead of `{g+1, 0}`. @@ -128,7 +134,7 @@ Treat a failed group read as the loss of that group and keep reading the track, Answer each `origin::Request` with `request.prepare(epoch)?` (a fresh `Epoch::mint()` per output run), fill `prepared.broadcast()`, then `prepared.publish()?`, which announces the broadcast at the exact path under that epoch and returns its producer. Only `publish` announces it: `prepared.broadcast().announce(..)` fails with `Error::Closed`. `Dynamic::update` is gone: drop the claim and claim again to change it, and close an answer's producer to end that output. -`Request::accept` is no longer part of the public API and panics on a request from `dynamic`. +`Request::accept` is no longer part of the public API. Dropping a `Prepared` unpublished refuses the request, as dropping a `Request` does, and `publish` fails with `Error::Unroutable` once the claim went away. ### Anchors can name a time (#6074) diff --git a/rs/moq-net/benches/origin.rs b/rs/moq-net/benches/origin.rs index 5decd12b2fd..1fd00e46cf7 100644 --- a/rs/moq-net/benches/origin.rs +++ b/rs/moq-net/benches/origin.rs @@ -717,7 +717,7 @@ const POOL_HOP: u64 = 1_000; struct Pool { producer: origin::Producer, driver: origin::Driver, - _members: Vec, + _members: Vec, _producers: Vec, _consumers: Vec, cursors: Vec, diff --git a/rs/moq-net/benches/retirement.rs b/rs/moq-net/benches/retirement.rs index fdc5281f03a..77475375d37 100644 --- a/rs/moq-net/benches/retirement.rs +++ b/rs/moq-net/benches/retirement.rs @@ -22,7 +22,11 @@ fn retirement(c: &mut Criterion) { || { let (origin, driver) = origin::Producer::new(origin::Config::default()); let routes: Vec<_> = (0..routes) - .map(|i| origin.dynamic(format!("route/{i}")).unwrap()) + .map(|i| { + origin + .dynamic_route(format!("route/{i}"), origin::Route::default()) + .unwrap() + }) .collect(); let mut watches = Vec::new(); let mut controls = Vec::new(); diff --git a/rs/moq-net/src/fuzz.rs b/rs/moq-net/src/fuzz.rs index 2c752971a7f..d5c70068af6 100644 --- a/rs/moq-net/src/fuzz.rs +++ b/rs/moq-net/src/fuzz.rs @@ -1340,7 +1340,7 @@ pub struct RequestValidity(crate::model::broadcast::RequestValidity); impl RequestValidity { /// Watch which instance this route's peer selects for a path. - pub fn new(route: &crate::origin::Dynamic, path: &crate::Path) -> Self { + pub fn new(route: &crate::origin::Forward, path: &crate::Path) -> Self { Self(crate::model::broadcast::RequestValidity::for_source(route, path)) } diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index e7283473dfb..556fbea9315 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -622,7 +622,7 @@ struct BroadcastState { // The served route: dropping it (and the serve task's clone) retracts the // route and rejects its queued requests. `None` while the session's limit holds // it back, though the peer still advertises it. - dynamic: Option, + dynamic: Option, // Bumped each time the route attaches, so a serve task outlived by a limit that // took the route away and gave it back ends rather than serve beside the new one. diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index 057546afaa5..89f0cb1c29e 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -5958,7 +5958,7 @@ impl Announced { } /// Attach a route, queued for its first serve pass. - fn attach(&mut self, path: PathOwned, route: crate::origin::Route, dynamic: crate::origin::Dynamic) { + fn attach(&mut self, path: PathOwned, route: crate::origin::Route, dynamic: crate::origin::Forward) { let wake = crate::util::QueueWake::new(path.clone(), self.ready.clone()); let route = AnnouncedRoute::new(route, dynamic, wake); route.waker.wake_by_ref(); @@ -6150,7 +6150,7 @@ struct AnnouncedRoute { /// without recomputing the chain. route: crate::origin::Route, /// Dropping it retracts the route and rejects its queued requests. - dynamic: crate::origin::Dynamic, + dynamic: crate::origin::Forward, /// Closes as the route goes, which closes every source it minted. live: kio::Producer<()>, /// Whether the GOAWAY drain already re-priced this route. @@ -6165,7 +6165,7 @@ struct AnnouncedRoute { impl AnnouncedRoute { fn new( route: crate::origin::Route, - dynamic: crate::origin::Dynamic, + dynamic: crate::origin::Forward, wake: Arc>, ) -> Self { Self { diff --git a/rs/moq-net/src/model/broadcast.rs b/rs/moq-net/src/model/broadcast.rs index f4b0af63950..8af8e364c8a 100644 --- a/rs/moq-net/src/model/broadcast.rs +++ b/rs/moq-net/src/model/broadcast.rs @@ -739,7 +739,7 @@ impl RequestValidity { #[cfg_attr(not(feature = "async"), allow(dead_code))] // pending: /quest/m1/public-model/answer.md - pub(crate) fn for_source(route: &crate::origin::Dynamic, path: &crate::Path) -> Self { + pub(crate) fn for_source(route: &crate::origin::Forward, path: &crate::Path) -> Self { route .request_validity(path) .map(|selected| Self::Selected(std::sync::Arc::new(selected))) diff --git a/rs/moq-net/src/model/follow.rs b/rs/moq-net/src/model/follow.rs index d1c69270da0..d34b6fc05a9 100644 --- a/rs/moq-net/src/model/follow.rs +++ b/rs/moq-net/src/model/follow.rs @@ -564,7 +564,8 @@ mod tests { exact.unannounce(); let served = broadcast::Info::new().produce(); let (again, ()) = futures::join!(available(&mut announced), async { - requested(&dynamic).await.accept(&served); + let request = moq_clock::timeout(Duration::from_secs(1), dynamic.requested_broadcast()).await; + request.expect("never asked").expect("the route closed").accept(&served); }); assert!(again.is_clone(&first), "the route above did not carry the instance on"); } diff --git a/rs/moq-net/src/model/mod.rs b/rs/moq-net/src/model/mod.rs index 3c443bc5c77..cadd717bb4f 100644 --- a/rs/moq-net/src/model/mod.rs +++ b/rs/moq-net/src/model/mod.rs @@ -56,6 +56,8 @@ pub mod origin { Config, Consumer, Cost, DEFAULT_UPDATE_HOLD, Driver, Dynamic, Preference, Prepared, Producer, Request, Requesting, Route, Routing, Source, }; + #[doc(hidden)] + pub use super::origin_impl::{Forward, ForwardRequest}; } /// Subscribing to route (un)announcements from an origin. diff --git a/rs/moq-net/src/model/origin.rs b/rs/moq-net/src/model/origin.rs index 710ff840845..023e9817499 100644 --- a/rs/moq-net/src/model/origin.rs +++ b/rs/moq-net/src/model/origin.rs @@ -1132,7 +1132,7 @@ struct RouteEntry { advertised: bool, /// Whether a peer the chain passes through has since withdrawn this prefix. /// The route was derived from that peer's advertisement, so it serves nobody - /// until the peer announces again. See [`Dynamic::withdrawn`]. + /// until the peer announces again. See [`Forward::withdrawn`]. stale: bool, /// [`prefix_claim`] of [`Self::prefix`], built once at announce time. /// @@ -2157,16 +2157,21 @@ impl Producer { /// The prefix must overlap this producer's pattern scope. Individual requests /// remain authoritative and are refused when they do not match the scope. pub fn dynamic(&self, prefix: impl AsPath) -> Result { - self.dynamic_inner(prefix, Route::default(), true) + Ok(Dynamic { + serve: self.serve(prefix, Route::default())?, + publisher: self.clone(), + }) } /// Land a peer's claim with its wire route metadata. #[doc(hidden)] - pub fn dynamic_route(&self, prefix: impl AsPath, route: Route) -> Result { - self.dynamic_inner(prefix, route, false) + pub fn dynamic_route(&self, prefix: impl AsPath, route: Route) -> Result { + Ok(Forward { + serve: self.serve(prefix, route)?, + }) } - fn dynamic_inner(&self, prefix: impl AsPath, route: Route, publishes: bool) -> Result { + fn serve(&self, prefix: impl AsPath, route: Route) -> Result { let announcing = Announcing::new(self, prefix)?; let serve = kio::Shared::::default(); serve.lock().requests.add_handler(); @@ -2178,10 +2183,9 @@ impl Producer { advertised: true, }, )?; - Ok(Dynamic { + Ok(Serve { announcement, state: serve, - publisher: publishes.then(|| self.clone()), _keepalive: self.tasks.keepalive(), }) } @@ -4633,7 +4637,7 @@ struct OriginState { fronts: WeakCache, // Peers that withdrew a prefix while routes there still passed through them, - // which are stale. See [`Dynamic::withdrawn`]. + // which are stale. See [`Forward::withdrawn`]. withdrawn: HashMap>, // Set when the origin's driver dropped: new requests fail with `Closed` @@ -5094,8 +5098,8 @@ impl OriginState { /// One-shot result of a dynamic broadcast request. /// -/// Stays `None` until a handler [`accept`](Request::accept)s (yielding the served -/// broadcast) or [`reject`](Request::reject)s (yielding an error). The producer is +/// Stays `None` until a handler answers (yielding the served broadcast) or +/// [`reject`](Request::reject)s (yielding an error). The producer is /// dropped right after writing, closing the channel; kio checks the value before the closed /// flag, so an awaiting requester still observes the final result. #[derive(Default)] @@ -5157,9 +5161,7 @@ impl Resolution { /// /// The origin-level analogue of [`broadcast::Dynamic`]: where that serves tracks /// on demand within a broadcast, this serves whole broadcasts on demand within -/// an origin. A relay holds one per route a peer announces to it, materializing -/// a requested path from that peer; an application holds one to answer a -/// subtree it never publishes ahead of time. +/// an origin, to answer a subtree it never publishes ahead of time. /// /// Drop it to retract the route and reject the requests still waiting to be /// served. @@ -5168,18 +5170,34 @@ impl Resolution { /// handler can drop every [`Producer`] and keep serving. #[must_use = "dropping an origin::Dynamic retracts the route"] pub struct Dynamic { + serve: Serve, + /// Each answer is published into this origin as an exact route. + publisher: Producer, +} + +/// A peer's route landed by a session, from [`Producer::dynamic_route`]: answers +/// each request with the broadcast the session already routes from that peer. +/// +/// A relay holds one per route a peer announces to it. Drop it to retract the +/// route and reject the requests still waiting to be served. +#[doc(hidden)] +#[must_use = "dropping an origin::Forward retracts the route"] +pub struct Forward { + serve: Serve, +} + +/// The advertisement and request queue behind a [`Dynamic`] or [`Forward`]. +struct Serve { /// The advertisement, retracted on drop. announcement: AnnounceProducer, state: kio::Shared, - /// Present for application claims, which publish each answer as an exact route. - publisher: Option, /// Producer-side children pin their parent where no cycle exists. The /// origin's state holds only the [`ServeState`], never this handle, so the /// driver cannot keep itself alive. _keepalive: Keepalive, } -impl Dynamic { +impl Serve { #[cfg_attr(not(feature = "async"), allow(dead_code))] pub(super) fn request_validity(&self, path: &Path) -> Option { let shared = &self.announcement.shared; @@ -5242,51 +5260,13 @@ impl Dynamic { }) } - /// Retire an explicit peer withdrawal, hiding derived paths only once its - /// last live advertisement at the prefix is gone. A lost session just drops. - #[cfg_attr(not(feature = "async"), allow(dead_code))] - // pending: /quest/m1/public-model/answer.md - pub(crate) fn withdrawn(self) { - self.announcement.retract(true); - } - - /// The route this handle advertises, as last given. - pub fn route(&self) -> Route { + fn route(&self) -> Route { self.announcement .route() .expect("a dynamic route stands until its handle drops") } - /// Replace the route in place, taken as given. - /// - /// At the same [`Route::epoch`] this re-prices: consumers observe an update and - /// every handle survives. Another epoch, or none, names another publisher - /// instance: consumers see an [`AnnounceEvent::Restart`], and a re-request never - /// joins what was resolved under the old one. Requests still waiting on this - /// handle carry over: the handler is asked again under the new epoch, and its - /// answer to a request asked before the change is dropped, never served under the - /// new epoch. A request pinned to the old epoch is refused with - /// [`Error::Unroutable`]. The answers already served are forgotten, so the next - /// request for one of those paths asks the handler again too. The broadcasts it - /// served keep running for the subscriptions already on them; close them to end - /// those too. Route selection still applies: another route still at the old epoch - /// outranks one without. - /// To re-price, start from the current route: - /// `dynamic.update_route(dynamic.route().with_cost(cost))`. - /// - /// The prefix is fixed at announce time: to move a route, drop this and call - /// [`Producer::dynamic`] again. Fails with [`Error::Closed`] once the origin's - /// [`Driver`] has been dropped. - #[doc(hidden)] - pub fn update_route(&self, route: Route) -> Result<(), Error> { - self.announcement.update(route) - } - - /// Poll for the next requested path under this route, without blocking. - /// - /// Returns [`Error::Closed`] once the origin's [`Driver`] has been dropped: - /// no request will ever arrive again, so handler loops should end. - pub fn poll_requested_broadcast(&self, waiter: &kio::Waiter) -> Poll> { + fn poll_requested(&self, waiter: &kio::Waiter) -> Poll> { let mut state = ready!(self.state.poll(waiter, |state| { if state.closed || state.requests.has_queued() { Poll::Ready(()) @@ -5302,15 +5282,39 @@ impl Dynamic { let path = state.requests.pop().expect("predicate guaranteed a request"); // The popped request stays pending, so a repeat request in the window between - // hand-off and accept coalesces onto it instead of re-invoking the handler. The - // producer is a shared clone; `Request::{accept, reject, drop}` removes the + // hand-off and an answer coalesces onto it instead of re-invoking the handler. + // The producer is a shared clone; answering or dropping the request removes the // entry. This mirrors how `poll_requested_track` keeps a served track // discoverable via the weak cache across the same window. let producer = state.requests.get(&path).expect("popped key must be pending").clone(); - Poll::Ready(Ok(Request { + Poll::Ready(Ok(Handoff { path, producer, home: self.state.clone(), + })) + } +} + +impl Dynamic { + /// The route this handle advertises, as last given. + pub fn route(&self) -> Route { + self.serve.route() + } + + /// Replace the route in place, taken as given; see [`Forward::update_route`]. + #[doc(hidden)] + pub fn update_route(&self, route: Route) -> Result<(), Error> { + self.serve.announcement.update(route) + } + + /// Poll for the next requested path under this route, without blocking. + /// + /// Returns [`Error::Closed`] once the origin's [`Driver`] has been dropped: + /// no request will ever arrive again, so handler loops should end. + pub fn poll_requested_broadcast(&self, waiter: &kio::Waiter) -> Poll> { + let handoff = ready!(self.serve.poll_requested(waiter))?; + Poll::Ready(Ok(Request { + handoff, publisher: self.publisher.clone(), })) } @@ -5326,6 +5330,65 @@ impl Dynamic { } } +impl Forward { + #[cfg_attr(not(feature = "async"), allow(dead_code))] + pub(super) fn request_validity(&self, path: &Path) -> Option { + self.serve.request_validity(path) + } + + /// Retire an explicit peer withdrawal, hiding derived paths only once its + /// last live advertisement at the prefix is gone. A lost session just drops. + #[cfg_attr(not(feature = "async"), allow(dead_code))] + // pending: /quest/m1/public-model/answer.md + pub(crate) fn withdrawn(self) { + self.serve.announcement.retract(true); + } + + /// The route this handle advertises, as last given. + pub fn route(&self) -> Route { + self.serve.route() + } + + /// Replace the route in place, taken as given. + /// + /// At the same [`Route::epoch`] this re-prices: consumers observe an update and + /// every handle survives. Another epoch, or none, names another publisher + /// instance: consumers see an [`AnnounceEvent::Restart`], and a re-request never + /// joins what was resolved under the old one. Requests still waiting on this + /// handle carry over: the handler is asked again under the new epoch, and its + /// answer to a request asked before the change is dropped, never served under the + /// new epoch. A request pinned to the old epoch is refused with + /// [`Error::Unroutable`]. The answers already served are forgotten, so the next + /// request for one of those paths asks the handler again too. The broadcasts it + /// served keep running for the subscriptions already on them; close them to end + /// those too. Route selection still applies: another route still at the old epoch + /// outranks one without. + /// To re-price, start from the current route: + /// `forward.update_route(forward.route().with_cost(cost))`. + /// + /// The prefix is fixed at announce time: to move a route, drop this and call + /// [`Producer::dynamic_route`] again. Fails with [`Error::Closed`] once the + /// origin's [`Driver`] has been dropped. + pub fn update_route(&self, route: Route) -> Result<(), Error> { + self.serve.announcement.update(route) + } + + /// Poll for the next requested path under this route, without blocking. + /// + /// Returns [`Error::Closed`] once the origin's [`Driver`] has been dropped. + pub fn poll_requested_broadcast(&self, waiter: &kio::Waiter) -> Poll> { + let handoff = ready!(self.serve.poll_requested(waiter))?; + Poll::Ready(Ok(ForwardRequest { handoff })) + } + + /// Block until a consumer requests a path under this route, returning a + /// [`ForwardRequest`] to serve. + #[cfg(feature = "async")] + pub async fn requested_broadcast(&self) -> Result { + kio::wait(|waiter| self.poll_requested_broadcast(waiter)).await + } +} + impl ServeState { /// Resolve a pending request: cache an accepted broadcast for repeat /// requests, remove the queue entry, and wake the requesters. @@ -5418,60 +5481,89 @@ impl ServeState { /// [`Consumer::request_broadcast`]; [`prepare`](Self::prepare) then [`Prepared::publish`] /// resolves it with a new exact-path broadcast, and [`reject`](Self::reject) resolves it /// with an error. Dropping the request without either rejects it. +/// +/// It has no `accept`: an application answers as its own publisher instance, never +/// with a broadcast routed from elsewhere. +/// +/// ```compile_fail +/// # fn answer(request: moq_net::origin::Request, other: moq_net::broadcast::Producer) { +/// request.accept(&other); +/// # } +/// ``` pub struct Request { - // Absolute path that was requested. - path: PathOwned, - - // Result channel back to the awaiting requester(s). Writing `resolved` and dropping - // this wakes them with the outcome. - producer: kio::Producer, - - // The queue this request came from, so `accept` can cache the served - // broadcast for repeat requests. - home: kio::Shared, - - // Applications publish answers into this origin; session-forwarded claims accept - // their peer's already-routed broadcast directly instead. - publisher: Option, + handoff: Handoff, + // The origin each answer is published into as an exact route. + publisher: Producer, } impl Request { /// The absolute path that was requested. pub fn path(&self) -> &Path<'_> { - &self.path + &self.handoff.path } /// Start an answer under a new publisher `epoch`, hidden until it is published. pub fn prepare(self, epoch: Epoch) -> Result { - let publisher = self.publisher.clone().ok_or(Error::Unroutable)?; - let (broadcast, announcer) = publisher.create_withheld(self.path.clone())?; + let (broadcast, announcer) = self.publisher.create_withheld(self.handoff.path.clone())?; Ok(Prepared { - request: self, + request: self.handoff, broadcast, announcer, epoch, }) } - /// Resolve a session-forwarded claim with its peer's already-routed broadcast. - /// - /// # Panics - /// - /// On a request from [`Producer::dynamic`], which answers only through [`Self::prepare`]. - #[doc(hidden)] + /// Reject the request, resolving every awaiting requester with `err`. + pub fn reject(self, err: Error) { + self.handoff.resolve(Err(err)); + } +} + +/// A pending request under a [`Forward`], answered with the broadcast its session +/// already routes from the peer. +/// +/// Yielded by [`Forward::poll_requested_broadcast`]. Dropping it without an answer +/// rejects it. +#[doc(hidden)] +pub struct ForwardRequest { + handoff: Handoff, +} + +impl ForwardRequest { + /// The absolute path that was requested. + pub fn path(&self) -> &Path<'_> { + &self.handoff.path + } + + /// Resolve every awaiting requester with the peer's already-routed broadcast. pub fn accept(self, broadcast: impl Consume) { - assert!( - self.publisher.is_none(), - "an application claim answers through prepare and publish" - ); - let broadcast = broadcast.consume(); - let _ = ServeState::resolve(&self.home, &self.path, &self.producer, Ok(broadcast)); - // `self.producer` drops here, closing the channel; the value is still observable. + self.handoff.resolve(Ok(broadcast.consume())); } /// Reject the request, resolving every awaiting requester with `err`. pub fn reject(self, err: Error) { - let _ = ServeState::resolve(&self.home, &self.path, &self.producer, Err(err)); + self.handoff.resolve(Err(err)); + } +} + +/// A request handed to a handler, refused on drop unless answered. +struct Handoff { + // Absolute path that was requested. + path: PathOwned, + + // Result channel back to the awaiting requester(s). Writing `resolved` and dropping + // this wakes them with the outcome. + producer: kio::Producer, + + // The queue this request came from, so an answer can cache the served + // broadcast for repeat requests. + home: kio::Shared, +} + +impl Handoff { + fn resolve(self, result: Result) { + let _ = ServeState::resolve(&self.home, &self.path, &self.producer, result); + // `self.producer` drops here, closing the channel; the value is still observable. } } @@ -5479,7 +5571,7 @@ impl Request { /// /// Dropping it unpublished refuses the request, as dropping a [`Request`] does. pub struct Prepared { - request: Request, + request: Handoff, broadcast: broadcast::Producer, // Withheld from the broadcast until `publish`, so the answer cannot announce // itself under another epoch or skip the request's admission. @@ -5527,13 +5619,13 @@ impl Prepared { } } -impl Drop for Request { +impl Drop for Handoff { fn drop(&mut self) { - // Handed off but neither accepted nor rejected: drop the still-pending entry so its - // producer clone (plus this one) closes the channel, resolving coalesced requesters to + // Handed off but never answered: drop the still-pending entry so its producer + // clone (plus this one) closes the channel, resolving coalesced requesters to // `Unroutable` rather than hanging. // - // The identity guard matters: `accept`/`reject` already removed our entry and released + // The identity guard matters: an answer already removed our entry and released // the lock before we run, so a concurrent request for the same path may have registered // a *new* one here. Removing unconditionally would clobber it, stranding its requesters. ServeState::forget(&self.home, &self.path, &self.producer); @@ -5727,8 +5819,7 @@ impl kio::Task for Routing { /// Derive a read view from a handle. /// /// Lets APIs accept either a producer or a consumer (e.g. -/// `Client::with_publisher`, -/// [`Request::accept`]). The blanket `&T` impl means you can +/// `Client::with_publisher`). The blanket `&T` impl means you can /// pass by value (`foo(x)`) to hand off ownership, or by reference (`foo(&x)`) /// to keep it, without spelling out `.consume()`. pub trait Consume { @@ -7096,11 +7187,31 @@ mod tests { panic!("condition never settled"); } + /// A handle that serves requests: an application [`Dynamic`] or a peer's [`Forward`]. + trait Serves { + type Request; + fn poll_requested(&self, waiter: &kio::Waiter) -> Poll>; + } + + impl Serves for Dynamic { + type Request = Request; + fn poll_requested(&self, waiter: &kio::Waiter) -> Poll> { + self.poll_requested_broadcast(waiter) + } + } + + impl Serves for Forward { + type Request = ForwardRequest; + fn poll_requested(&self, waiter: &kio::Waiter) -> Poll> { + self.poll_requested_broadcast(waiter) + } + } + /// Yield to the driver until the server's front watcher delivers a request. #[cfg(feature = "async")] - async fn queued(server: &Dynamic) -> Request { + async fn queued(server: &S) -> S::Request { let mut request = None; - settle(|| match server.poll_requested_broadcast(&kio::Waiter::noop()) { + settle(|| match server.poll_requested(&kio::Waiter::noop()) { Poll::Ready(Ok(popped)) => { request = Some(popped); true @@ -7897,7 +8008,7 @@ mod tests { // arrived in `order`. fn winners(order: impl Iterator) -> Vec { let producer = origin(1).produce(); - let _pool: Vec = order + let _pool: Vec = order .map(|id| { producer .dynamic_route("pool", Route::default().with_hops(hops(&[id])).with_cost(3)) @@ -8049,7 +8160,7 @@ mod tests { /// An origin delivering restarts at once, and a way to claim `prefix` with a /// producer scoped to `scope`, from `hop` at `cost`. #[cfg(feature = "async")] - fn scoped_pool() -> (Producer, impl Fn(&str, &str, u64, u64) -> Dynamic) { + fn scoped_pool() -> (Producer, impl Fn(&str, &str, u64, u64) -> Forward) { let producer = Config { update_hold: Duration::ZERO, ..Config::new(origin(1)) @@ -8069,7 +8180,7 @@ mod tests { /// Request `path` from `consumer` and have `server` answer it: the answer, and /// the requester's broadcast. #[cfg(feature = "async")] - async fn hold(consumer: &Consumer, path: &str, server: &Dynamic) -> (broadcast::Producer, broadcast::Consumer) { + async fn hold(consumer: &Consumer, path: &str, server: &Forward) -> (broadcast::Producer, broadcast::Consumer) { let pending = consumer.request_broadcast(path, None); let answer = broadcast::Info::new().produce(); queued(server).await.accept(&answer); @@ -8798,7 +8909,7 @@ mod tests { /// A front resolved through a served route, as a relay's upstream session serves /// one: the upstream broadcast's track requests arrive on the returned handle. #[cfg(feature = "async")] - async fn served_front() -> (Dynamic, broadcast::Producer, broadcast::Dynamic, broadcast::Consumer) { + async fn served_front() -> (Forward, broadcast::Producer, broadcast::Dynamic, broadcast::Consumer) { let producer = origin(1).produce(); let consumer = producer.consume(); let server = producer @@ -10410,16 +10521,6 @@ mod tests { assert!(matches!(stale.await, Err(Error::Unroutable))); } - #[moq_clock::test] - #[cfg(feature = "async")] - #[should_panic(expected = "prepare and publish")] - async fn an_application_claim_cannot_accept_an_arbitrary_broadcast() { - let producer = origin(1).produce(); - let claim = producer.dynamic("room").unwrap(); - let _pending = producer.consume().request_broadcast("room/alice", None); - queued(&claim).await.accept(broadcast::Info::new().produce()); - } - /// A refusal from the longest prefix is the answer: neither a costlier advertiser of /// that prefix nor a catch-all is asked instead. #[moq_clock::test] @@ -10821,7 +10922,10 @@ mod tests { panic!("front requested its route"); }; drop(driver); - assert!(matches!(request.producer.read().resolved, Some(Err(Error::Closed)))); + assert!(matches!( + request.handoff.producer.read().resolved, + Some(Err(Error::Closed)) + )); } /// One live subscription reading a track through a remote front, plus the @@ -10841,7 +10945,7 @@ mod tests { /// Announce a served route with `first` as its first hop, materialize /// "room/alice" through it with a one-group "before" track, and subscribe. #[cfg(feature = "async")] - async fn new(first: &[u64]) -> (Self, Dynamic, broadcast::Producer) { + async fn new(first: &[u64]) -> (Self, Forward, broadcast::Producer) { let producer = origin(1).produce(); let consumer = producer.consume(); @@ -10887,7 +10991,7 @@ mod tests { /// Stand up a second served route with `first` as its first hop and hand /// back its handle, ready to answer the front's re-request. - fn standby(&self, first: &[u64]) -> Dynamic { + fn standby(&self, first: &[u64]) -> Forward { self.producer .dynamic_route("room", Route::default().with_epoch(epoch()).with_hops(hops(first))) .unwrap() @@ -10899,7 +11003,7 @@ mod tests { /// rig's subscription resumes onto it: the successor group is delivered on /// the same subscription, at the group boundary. #[cfg(feature = "async")] - async fn assert_resumes(rig: &mut ResumeRig, server: &Dynamic) { + async fn assert_resumes(rig: &mut ResumeRig, server: &Forward) { let request = queued(server).await; let replacement = broadcast::Info::new().produce(); let track = replacement.create_track("video", crate::track::Info::timed()).unwrap(); @@ -11125,7 +11229,12 @@ mod tests { matches!(pinned.await, Err(Error::Unroutable)), "a request pinned to the old epoch resolved" ); - let cached = dynamic.state.lock().served.get(&Path::new("room/alice").to_owned()); + let cached = dynamic + .serve + .state + .lock() + .served + .get(&Path::new("room/alice").to_owned()); assert!( cached.is_some_and(|cached| cached.consume().is_clone(&fresh.consume())), "the old answer was cached" @@ -12200,7 +12309,7 @@ mod tests { #[cfg(feature = "async")] async fn remote_front() -> ( Producer, - Dynamic, + Forward, broadcast::Producer, track::Producer, track::Subscriber, diff --git a/rs/moq-net/tests/integration/drained_claim.rs b/rs/moq-net/tests/integration/drained_claim.rs index 84ac5f0ad17..6013a41db96 100644 --- a/rs/moq-net/tests/integration/drained_claim.rs +++ b/rs/moq-net/tests/integration/drained_claim.rs @@ -45,7 +45,7 @@ async fn settle() { /// A worker claiming `pool`, answering each request with an output whose `video` /// track carries the worker's name. struct Worker { - claim: Rc, + claim: Rc, /// Each output with its track, which would end once its producer drops. outputs: Rc>>, origin: origin::Producer, diff --git a/rs/moq-net/tests/integration/restart.rs b/rs/moq-net/tests/integration/restart.rs index c5b24d1d1a2..a311a4068b3 100644 --- a/rs/moq-net/tests/integration/restart.rs +++ b/rs/moq-net/tests/integration/restart.rs @@ -175,7 +175,7 @@ relay_chain_tests! { /// whose `video` track carries the worker's name. struct Worker { origin: origin::Producer, - _claim: Rc, + _claim: Rc, _outputs: Rc>>, } @@ -257,7 +257,7 @@ async fn prefix_pool_lite07() { /// A claim at `live` whose handler answers each request with a fresh output, kept so /// the test can write into it: `outputs[n]` answered the `n`th request. struct Claim { - dynamic: Rc, + dynamic: Rc, outputs: Rc>>, } From e05e792f4071d22a67ba243af9c496e6e68b73bc Mon Sep 17 00:00:00 2001 From: Luke Curley <432854+kixelated@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:22:55 -0700 Subject: [PATCH 03/15] test(drill): retarget the lost-publisher mutation at origin::Forward Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EeyiEy5FWz3z44DYZVo7vU --- test/drill/mutations/relay-withdraws-lost-publisher.patch | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/drill/mutations/relay-withdraws-lost-publisher.patch b/test/drill/mutations/relay-withdraws-lost-publisher.patch index 38c8bf4b571..5fbfdde7d81 100644 --- a/test/drill/mutations/relay-withdraws-lost-publisher.patch +++ b/test/drill/mutations/relay-withdraws-lost-publisher.patch @@ -16,14 +16,14 @@ diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber. /// without recomputing the chain. route: crate::origin::Route, /// Dropping it retracts the route and rejects its queued requests. -- dynamic: crate::origin::Dynamic, +- dynamic: crate::origin::Forward, + // MUTATION: never dropped, so a route outlives the session that announced it. -+ dynamic: std::mem::ManuallyDrop, ++ dynamic: std::mem::ManuallyDrop, /// Closes as the route goes, which closes every source it minted. live: kio::Producer<()>, /// Whether the GOAWAY drain already re-priced this route. @@ -3584,7 +3585,7 @@ - fn new(route: crate::origin::Route, dynamic: crate::origin::Dynamic, wake: Arc) -> Self { + fn new(route: crate::origin::Route, dynamic: crate::origin::Forward, wake: Arc) -> Self { Self { route, - dynamic, From fbea6e87af2a401a111283473475b2d1c6c9f856 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 23 Oct 2026 16:37:25 -0700 Subject: [PATCH 04/15] feat(net)!: make the peer-route hatches crate-private origin::Producer::dynamic_route, origin::Dynamic::update_route, and origin::Request::accept become pub(crate), so accepting an application claim's request fails to compile. A compile_fail doctest replaces the runtime assert. The origin bench and the moq-net tests that model a peer's claim reach them through the hidden fuzz module. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/Cargo.toml | 3 +- rs/moq-net/benches/origin.rs | 58 +++++++++--------- rs/moq-net/src/fuzz.rs | 19 ++++++ rs/moq-net/src/model/origin.rs | 35 ++++------- rs/moq-net/tests/announce_allocs.rs | 4 +- rs/moq-net/tests/integration/drained_claim.rs | 59 ++++++++++--------- .../tests/integration/ietf_conformance.rs | 20 ++++--- rs/moq-net/tests/integration/mesh_withdraw.rs | 14 ++--- rs/moq-net/tests/integration/restart.rs | 42 +++++++++---- 9 files changed, 140 insertions(+), 114 deletions(-) diff --git a/rs/moq-net/Cargo.toml b/rs/moq-net/Cargo.toml index 962e67b9aa2..fcd182fb587 100644 --- a/rs/moq-net/Cargo.toml +++ b/rs/moq-net/Cargo.toml @@ -80,10 +80,11 @@ name = "fuzz-seeds" path = "examples/fuzz-seeds.rs" required-features = ["fuzz"] +# Lands peer claims through the hidden `fuzz` module, as a session does. [[bench]] name = "origin" harness = false -required-features = ["async"] +required-features = ["fuzz"] # Reaches the crate-private session view of an origin through the hidden `fuzz` module. [[bench]] diff --git a/rs/moq-net/benches/origin.rs b/rs/moq-net/benches/origin.rs index c74c16c81d4..6bb41eb1079 100644 --- a/rs/moq-net/benches/origin.rs +++ b/rs/moq-net/benches/origin.rs @@ -29,7 +29,9 @@ //! Readers draining a swapped track are swept over their count, the groups every //! route delivered, and the routes each reader still holds a copy of. //! -//! Run with `cargo bench -p moq-net --bench origin`. +//! Peer claims land through the hidden `fuzz` module, as a session lands them. +//! +//! Run with `cargo bench -p moq-net --features fuzz --bench origin`. #![expect( clippy::disallowed_methods, @@ -165,7 +167,7 @@ fn bench_withdraw(c: &mut Criterion) { .poll(moq_net::time::Instant::now(), &kio::Waiter::noop()) .unwrap(); let routes: Vec<_> = (0..publishers) - .map(|i| producer.dynamic_route(format!("room/{i}"), peer_route(9, 1)).unwrap()) + .map(|i| moq_net::fuzz::dynamic_route(&producer, format!("room/{i}"), peer_route(9, 1)).unwrap()) .collect(); let mut cursors: Vec<_> = (0..subscribers).map(|_| producer.consume().announced()).collect(); for cursor in &mut cursors { @@ -219,10 +221,10 @@ fn bench_withdraw_wave(c: &mut Criterion) { .poll(moq_net::time::Instant::now(), &kio::Waiter::noop()) .unwrap(); let near: Vec<_> = (0..broadcasts) - .map(|i| producer.dynamic_route(format!("room/{i}"), route(&[9, 2])).unwrap()) + .map(|i| moq_net::fuzz::dynamic_route(&producer, format!("room/{i}"), route(&[9, 2])).unwrap()) .collect(); let far: Vec<_> = (0..broadcasts) - .map(|i| producer.dynamic_route(format!("room/{i}"), route(&[9, 3, 4])).unwrap()) + .map(|i| moq_net::fuzz::dynamic_route(&producer, format!("room/{i}"), route(&[9, 3, 4])).unwrap()) .collect(); let mut cursors: Vec<_> = (0..subscribers).map(|_| producer.consume().announced()).collect(); for cursor in &mut cursors { @@ -266,13 +268,13 @@ fn bench_route_churn(c: &mut Criterion) { group.bench_function(id, |b| { let (producer, _driver) = origin::Producer::new(origin::Config::default()); let _routes: Vec<_> = (0..broadcasts) - .map(|i| producer.dynamic_route(format!("room/{i}"), peer_route(1, 1)).unwrap()) + .map(|i| moq_net::fuzz::dynamic_route(&producer, format!("room/{i}"), peer_route(1, 1)).unwrap()) .collect(); let mut cursors: Vec<_> = (0..subscribers).map(|_| producer.consume().announced()).collect(); for cursor in &mut cursors { while cursor.try_next().is_some() {} } - b.iter(|| drop(producer.dynamic_route("room/0", peer_route(2, 5)).unwrap())); + b.iter(|| drop(moq_net::fuzz::dynamic_route(&producer, "room/0", peer_route(2, 5)).unwrap())); for cursor in &mut cursors { assert!(cursor.try_next().is_none(), "the winner is unchanged"); } @@ -397,9 +399,7 @@ fn bench_announce_duplicate(c: &mut Criterion) { // the entries are distinct routes rather than one re-priced in place. let _routes: Vec<_> = (1..=duplicates) .map(|peer| { - producer - .dynamic_route(PATH, peer_route(peer as u64, INCUMBENT_COST)) - .unwrap() + moq_net::fuzz::dynamic_route(&producer, PATH, peer_route(peer as u64, INCUMBENT_COST)).unwrap() }) .collect(); let mut cursors: Vec = (0..subscribers) @@ -410,9 +410,9 @@ fn bench_announce_duplicate(c: &mut Criterion) { } b.iter(|| { - let handle = producer - .dynamic_route(PATH, peer_route(duplicates as u64 + 1, INCUMBENT_COST - 1)) - .unwrap(); + let handle = + moq_net::fuzz::dynamic_route(&producer, PATH, peer_route(duplicates as u64 + 1, INCUMBENT_COST - 1)) + .unwrap(); for cursor in &mut cursors { cursor.next().now_or_never().flatten().expect("announce delivered"); } @@ -441,20 +441,18 @@ fn bench_reprice_duplicate(c: &mut Criterion) { let (producer, _driver) = origin::Producer::new(config); let _routes: Vec<_> = (1..=duplicates) .map(|peer| { - producer + let scoped = producer .scope( "", &Patterns::from(Pattern::subtree(&format!("{PATH}/p{}", (peer - 1) % 2)).unwrap()), ) - .unwrap() - .dynamic_route(PATH, peer_route(peer as u64, incumbent_cost)) - .unwrap() + .unwrap(); + moq_net::fuzz::dynamic_route(&scoped, PATH, peer_route(peer as u64, incumbent_cost)).unwrap() }) .collect(); let challenger = duplicates as u64 + 1; - let route = producer - .dynamic_route(PATH, peer_route(challenger, incumbent_cost + 1)) - .unwrap(); + let route = + moq_net::fuzz::dynamic_route(&producer, PATH, peer_route(challenger, incumbent_cost + 1)).unwrap(); let mut cursors: Vec = (0..subscribers) .map(|peer| { producer @@ -473,7 +471,7 @@ fn bench_reprice_duplicate(c: &mut Criterion) { } b.iter(|| { for cost in [incumbent_cost - 1, incumbent_cost + 1] { - route.update_route(peer_route(challenger, cost)).unwrap(); + moq_net::fuzz::update_route(&route, peer_route(challenger, cost)).unwrap(); for cursor in &mut cursors { // The winner switches between epochless routes, which is another instance. let event = cursor.next().now_or_never().flatten().expect("winner changed"); @@ -515,7 +513,7 @@ fn bench_announce_fronts(c: &mut Criterion) { let (producer, mut driver) = origin::Producer::new(origin::Config::default()); let consumer = producer.consume(); // Served but never answered: every request under it parks a front. - let _served = producer.dynamic_route("room", origin::Route::default()).unwrap(); + let _served = producer.dynamic("room").unwrap(); let _requests: Vec<_> = (0..fronts) .map(|i| consumer.request_broadcast(format!("room/{i}"), None)) .collect(); @@ -556,9 +554,7 @@ fn bench_serve_idle(c: &mut Criterion) { // One route per announced path, exactly as a session lands a peer's. let dynamics: Vec<_> = (0..routes) .map(|i| { - producer - .dynamic_route(format!(".stats/p{}/node/edge{i}", i % 8), origin::Route::default()) - .unwrap() + producer.dynamic(format!(".stats/p{}/node/edge{i}", i % 8)).unwrap() }) .collect(); b.iter(|| { @@ -714,8 +710,8 @@ fn bench_unroutable_churn(c: &mut Criterion) { group.bench_function(id, |b| { let (producer, mut driver) = origin::Producer::new(origin::Config::default()); let consumer = producer.consume(); - let server = producer.dynamic_route("room", origin::Route::default()).unwrap(); - let unrelated = producer.dynamic_route("other", origin::Route::default()).unwrap(); + let server = moq_net::fuzz::dynamic_route(&producer, "room", origin::Route::default()).unwrap(); + let unrelated = moq_net::fuzz::dynamic_route(&producer, "other", origin::Route::default()).unwrap(); let mut waiting = Waiting::new( (0..requesters) .map(|i| { @@ -747,7 +743,7 @@ fn bench_unroutable_churn(c: &mut Criterion) { b.iter(|| { for _ in 0..changes { cost = 3 - cost; - changed.update_route(origin::Route::default().with_cost(cost)).unwrap(); + moq_net::fuzz::update_route(changed, origin::Route::default().with_cost(cost)).unwrap(); assert!(covering || !waiting.is_woken(), "unrelated churn wakes no request"); waiting.drain(); driver.poll(now, &waiter).unwrap(); @@ -839,7 +835,7 @@ fn pool(members: usize, paths: usize, cursors: usize) -> Pool { let consumer = producer.consume(); let cursors = (0..cursors).map(|_| consumer.announced()).collect(); let members: Vec<_> = (0..members) - .map(|i| producer.dynamic_route("pool", pool_route(POOL_HOP + i as u64)).unwrap()) + .map(|i| moq_net::fuzz::dynamic_route(&producer, "pool", pool_route(POOL_HOP + i as u64)).unwrap()) .collect(); let waiter = kio::Waiter::noop(); @@ -851,7 +847,7 @@ fn pool(members: usize, paths: usize, cursors: usize) -> Pool { for member in &members { while let Poll::Ready(request) = member.poll_requested_broadcast(&waiter) { let broadcast = broadcast::Info::new().produce(); - request.unwrap().accept(&broadcast); + moq_net::fuzz::accept(request.unwrap(), &broadcast); producers.push(broadcast); } } @@ -898,7 +894,7 @@ fn bench_pool_churn(c: &mut Criterion) { let waiter = kio::Waiter::noop(); let hop = POOL_HOP + members as u64; b.iter(|| { - let joined = pool.producer.dynamic_route("pool", pool_route(hop)).unwrap(); + let joined = moq_net::fuzz::dynamic_route(&pool.producer, "pool", pool_route(hop)).unwrap(); pool.driver.poll(moq_net::time::Instant::now(), &waiter).unwrap(); drop(joined); pool.driver.poll(moq_net::time::Instant::now(), &waiter).unwrap(); @@ -926,7 +922,7 @@ fn bench_pool_cursors(c: &mut Criterion) { let waiter = kio::Waiter::noop(); let hop = POOL_HOP + members as u64; b.iter(|| { - let joined = pool.producer.dynamic_route("pool", pool_route(hop)).unwrap(); + let joined = moq_net::fuzz::dynamic_route(&pool.producer, "pool", pool_route(hop)).unwrap(); pool.driver.poll(moq_net::time::Instant::now(), &waiter).unwrap(); drop(joined); pool.driver.poll(moq_net::time::Instant::now(), &waiter).unwrap(); diff --git a/rs/moq-net/src/fuzz.rs b/rs/moq-net/src/fuzz.rs index 8ca7c1a2d70..b21df1dca43 100644 --- a/rs/moq-net/src/fuzz.rs +++ b/rs/moq-net/src/fuzz.rs @@ -303,6 +303,25 @@ pub fn excluding(origin: crate::origin::Consumer, peer: crate::Hop) -> crate::or origin.excluding(peer) } +/// Land a claim with a peer's wire route, as a session does, for the origin bench. +pub fn dynamic_route( + origin: &crate::origin::Producer, + prefix: impl crate::AsPath, + route: crate::origin::Route, +) -> Result { + origin.dynamic_route(prefix, route) +} + +/// Replace a peer claim's route in place, as a session does on a route update. +pub fn update_route(dynamic: &crate::origin::Dynamic, route: crate::origin::Route) -> Result<(), crate::Error> { + dynamic.update_route(route) +} + +/// Answer a peer claim's request with the broadcast its session already holds. +pub fn accept(request: crate::origin::Request, broadcast: impl crate::Consume) { + request.accept(broadcast) +} + /// A session's snapshot of its peer's initial announcements, for the synced bench. /// Drop it to land the snapshot. pub struct Snapshot(#[allow(dead_code, reason = "held for its drop, which lands the snapshot")] crate::model::Snapshot); diff --git a/rs/moq-net/src/model/origin.rs b/rs/moq-net/src/model/origin.rs index 2259b66a9ac..712ec1e836b 100644 --- a/rs/moq-net/src/model/origin.rs +++ b/rs/moq-net/src/model/origin.rs @@ -2352,8 +2352,7 @@ impl Producer { } /// Land a peer's claim with its wire route metadata. - #[doc(hidden)] - pub fn dynamic_route(&self, prefix: impl AsPath, route: Route) -> Result { + pub(crate) fn dynamic_route(&self, prefix: impl AsPath, route: Route) -> Result { self.dynamic_inner(prefix, route, false) } @@ -5443,8 +5442,7 @@ impl Dynamic { /// The prefix is fixed at announce time: to move a route, drop this and call /// [`Producer::dynamic`] again. Fails with [`Error::Closed`] once the origin's /// [`Driver`] has been dropped. - #[doc(hidden)] - pub fn update_route(&self, route: Route) -> Result<(), Error> { + pub(crate) fn update_route(&self, route: Route) -> Result<(), Error> { self.announcement.update(route) } @@ -5584,6 +5582,14 @@ impl ServeState { /// [`Consumer::request_broadcast`]; [`prepare`](Self::prepare) then [`Prepared::publish`] /// resolves it with a new exact-path broadcast, and [`reject`](Self::reject) resolves it /// with an error. Dropping the request without either rejects it. +/// +/// Only the built-in sessions answer with a broadcast they already hold: +/// +/// ```compile_fail,E0624 +/// fn answer(request: moq_net::origin::Request, broadcast: moq_net::broadcast::Consumer) { +/// request.accept(broadcast); +/// } +/// ``` pub struct Request { // Absolute path that was requested. path: PathOwned, @@ -5620,16 +5626,7 @@ impl Request { } /// Resolve a session-forwarded claim with its peer's already-routed broadcast. - /// - /// # Panics - /// - /// On a request from [`Producer::dynamic`], which answers only through [`Self::prepare`]. - #[doc(hidden)] - pub fn accept(self, broadcast: impl Consume) { - assert!( - self.publisher.is_none(), - "an application claim answers through prepare and publish" - ); + pub(crate) fn accept(self, broadcast: impl Consume) { let broadcast = broadcast.consume(); let _ = ServeState::resolve(&self.home, &self.path, &self.producer, Ok(broadcast)); // `self.producer` drops here, closing the channel; the value is still observable. @@ -10663,16 +10660,6 @@ mod tests { assert!(matches!(stale.await, Err(Error::Unroutable))); } - #[moq_clock::test] - #[cfg(feature = "async")] - #[should_panic(expected = "prepare and publish")] - async fn an_application_claim_cannot_accept_an_arbitrary_broadcast() { - let producer = origin(1).produce(); - let claim = producer.dynamic("room").unwrap(); - let _pending = producer.consume().request_broadcast("room/alice", None); - queued(&claim).await.accept(broadcast::Info::new().produce()); - } - /// A refusal from the longest prefix is the answer: neither a costlier advertiser of /// that prefix nor a catch-all is asked instead. #[moq_clock::test] diff --git a/rs/moq-net/tests/announce_allocs.rs b/rs/moq-net/tests/announce_allocs.rs index b41579ff697..1b6e3eb84c4 100644 --- a/rs/moq-net/tests/announce_allocs.rs +++ b/rs/moq-net/tests/announce_allocs.rs @@ -57,7 +57,7 @@ fn peer_route(peer: u64, cost: u64) -> origin::Route { /// again, with `cursors` cursors rooted at `root` watching it. fn churn_allocs(root: &str, cursors: usize) -> usize { let (producer, _driver) = origin::Producer::new(origin::Config::default()); - let _winner = producer.dynamic_route("room/a", peer_route(1, 1)).unwrap(); + let _winner = producer.publish("room/a", peer_route(1, 1)).unwrap(); let all = Patterns::from(Pattern::all()); let mut cursors: Vec<_> = (0..cursors) .map(|_| producer.consume().scope(root, &all).unwrap().announced()) @@ -66,7 +66,7 @@ fn churn_allocs(root: &str, cursors: usize) -> usize { while cursor.try_next().is_some() {} } - let cycle = || drop(producer.dynamic_route("room/a", peer_route(2, 5)).unwrap()); + let cycle = || drop(producer.publish("room/a", peer_route(2, 5)).unwrap()); // Reach the steady state first, with every table grown to what it settles at. for _ in 0..4 { cycle(); diff --git a/rs/moq-net/tests/integration/drained_claim.rs b/rs/moq-net/tests/integration/drained_claim.rs index 84ac5f0ad17..de3ba3d33ee 100644 --- a/rs/moq-net/tests/integration/drained_claim.rs +++ b/rs/moq-net/tests/integration/drained_claim.rs @@ -4,16 +4,16 @@ //! //! Two workers each claim the `pool` prefix with `origin::Producer::dynamic` and //! announce it to relay `R` on their own sessions, the way a transcode pool does. A -//! viewer on `R` reads `pool/p` from worker `A`, which then closes that output and -//! drains its claim. Nothing tells `R` the output closed, so only the front ending -//! once unread lets the next viewer reach worker `B`, which wins the path now, rather -//! than the drained worker. +//! viewer on `R` reads `pool/p` from worker `A`, over the cheaper link, which then +//! closes that output and drains its session. Nothing tells `R` the output closed, so +//! only the front ending once unread lets the next viewer reach worker `B`, which wins +//! the path now, rather than the drained worker. use crate::support; use std::{cell::RefCell, rc::Rc, time::Duration}; -use moq_net::{Hop, Timestamp, Version, broadcast, origin, track}; +use moq_net::{Epoch, Hop, Timestamp, Version, broadcast, goaway::Goaway, origin, track}; use support::harness::{MockConnectOptions, MockPair, connect_mock}; /// A millisecond track: the frames written here carry timestamps. @@ -29,11 +29,12 @@ fn produce_origin(hop: u64) -> origin::Producer { producer } -/// Have `to` pull everything `from` publishes. -async fn link(version: Version, from: &origin::Producer, to: &origin::Producer) -> MockPair { +/// Have `to` pull everything `from` publishes, over a link priced at `cost`. +async fn link(version: Version, from: &origin::Producer, to: &origin::Producer, cost: u64) -> MockPair { let mut options = MockConnectOptions::new(version); options.server_publish = Some(from.consume()); options.client_subscribe = Some(to.clone()); + options.cost = Some(cost); connect_mock(options).await } @@ -45,34 +46,33 @@ async fn settle() { /// A worker claiming `pool`, answering each request with an output whose `video` /// track carries the worker's name. struct Worker { - claim: Rc, + _claim: Rc, /// Each output with its track, which would end once its producer drops. outputs: Rc>>, origin: origin::Producer, } impl Worker { - fn new(hop: u64, name: &'static str, cost: u64) -> Self { + fn new(hop: u64, name: &'static str) -> Self { let origin = produce_origin(hop); - let claim = Rc::new( - origin - .dynamic_route("pool", origin::Route::default().with_cost(cost)) - .unwrap(), - ); + let claim = Rc::new(origin.dynamic("pool").unwrap()); let outputs = Rc::new(RefCell::new(Vec::new())); let (handler, served) = (claim.clone(), outputs.clone()); drop(moq_clock::sim::spawn(async move { while let Ok(request) = handler.requested_broadcast().await { - let output = broadcast::Info::new().produce(); - let track = output.create_track("video", timed()).unwrap(); + let prepared = request.prepare(Epoch::mint()).unwrap(); + let track = prepared.broadcast().create_track("video", timed()).unwrap(); let mut group = track.append_group().unwrap(); group.write_frame(Timestamp::ZERO, name.as_bytes().to_vec()).unwrap(); group.finish().unwrap(); - request.accept(&output); - served.borrow_mut().push((output, track)); + served.borrow_mut().push((prepared.publish().unwrap(), track)); } })); - Self { claim, outputs, origin } + Self { + _claim: claim, + outputs, + origin, + } } /// How many outputs the worker was asked for. @@ -81,8 +81,13 @@ impl Worker { } } -/// Read the first frame of `video` at `pool/p` through `relay`. +/// Read the first frame of `video` at `pool/p` through `relay`. The first subscription +/// through a claim is reset once the answer's exact route arrives, the cold-start reset +/// accepted until announce takeover lands, so the read retries once. async fn watch(relay: &origin::Producer) -> String { + let cold = relay.consume().request_broadcast("pool/p", None).await.unwrap(); + drop(cold.track("video").unwrap().subscribe(None).await); + settle().await; let broadcast = relay.consume().request_broadcast("pool/p", None).await.unwrap(); let mut subscription = broadcast.track("video").unwrap().subscribe(None).await.unwrap(); let mut group = subscription.recv_group().await.unwrap().expect("a group"); @@ -93,19 +98,17 @@ async fn watch(relay: &origin::Producer) -> String { async fn drained_claim(version: &str) { let version: Version = version.parse().unwrap(); let relay = produce_origin(3); - let a = Worker::new(1, "a", 0); - let b = Worker::new(2, "b", 10); - let _a = link(version, &a.origin, &relay).await; - let _b = link(version, &b.origin, &relay).await; + let a = Worker::new(1, "a"); + let b = Worker::new(2, "b"); + let a_link = link(version, &a.origin, &relay, 1).await; + let _b = link(version, &b.origin, &relay, 10).await; settle().await; assert_eq!(watch(&relay).await, "a", "{version}"); - // A closes its output and drains its claim, so B wins the path from here on. + // A closes its output and drains its session, so B wins the path from here on. a.outputs.borrow().iter().for_each(|(output, _)| output.close()); - a.claim - .update_route(a.claim.route().with_cost(origin::Cost::DRAIN)) - .unwrap(); + a_link.server.drain().send(Goaway::new()).unwrap(); // Past the linger, which is as long as the cache window, nothing reads the relay's // front for the path. moq_clock::sleep(moq_net::cache::DEFAULT_EXPIRY * 2).await; diff --git a/rs/moq-net/tests/integration/ietf_conformance.rs b/rs/moq-net/tests/integration/ietf_conformance.rs index 163ae649fe2..bc471683437 100644 --- a/rs/moq-net/tests/integration/ietf_conformance.rs +++ b/rs/moq-net/tests/integration/ietf_conformance.rs @@ -3,8 +3,8 @@ //! Moxygen announces one prefix, then serves its sixteen-field test namespaces. use crate::support::harness::{MockConnectOptions, connect_mock, spawn}; -use moq_net::{Hop, Version, broadcast, origin}; -use std::sync::Arc; +use moq_net::{Epoch, Hop, Version, origin}; +use std::{cell::RefCell, rc::Rc}; // Moxygen c27ae6358df62a1195da479b2e07c50dd8174279, moqtest/Utils.cpp. const PREFIX: &str = "moq-test-00"; @@ -21,15 +21,18 @@ async fn routing(version: &str, cluster: bool) { let publisher = origin(1); let relay = origin(2); let reader = origin(4); - let prefix = Arc::new(publisher.dynamic_route(PREFIX, Default::default()).unwrap()); - let broadcast = broadcast::Info::new().produce(); - let track = broadcast.create_track("test", moq_net::track::Info::default()).unwrap(); - let serving = prefix.clone(); - let answer = broadcast.consume(); + let prefix = Rc::new(publisher.dynamic(PREFIX).unwrap()); + let answered = Rc::new(RefCell::new(None)); + let (serving, answer) = (prefix.clone(), answered.clone()); moq_clock::sim::spawn(async move { let request = serving.requested_broadcast().await.unwrap(); assert_eq!(request.path().as_str(), NAMESPACE); - request.accept(answer); + let prepared = request.prepare(Epoch::mint()).unwrap(); + let track = prepared + .broadcast() + .create_track("test", moq_net::track::Info::default()) + .unwrap(); + *answer.borrow_mut() = Some((prepared.publish().unwrap(), track)); }); let mut options = MockConnectOptions::new(version); @@ -56,6 +59,7 @@ async fn routing(version: &str, cluster: bool) { reader.consume().routed(NAMESPACE).await.unwrap(); let remote = reader.consume().request_broadcast(NAMESPACE, None).await.unwrap(); let mut subscription = remote.track("test").unwrap().subscribe(None).await.unwrap(); + let (broadcast, track) = answered.borrow_mut().take().expect("the claim answered"); crate::support::harness::subscribed(&track).await; let mut group = track.append_group().unwrap(); group.write_frame(None, &b"conformance"[..]).unwrap(); diff --git a/rs/moq-net/tests/integration/mesh_withdraw.rs b/rs/moq-net/tests/integration/mesh_withdraw.rs index 278189f292a..f4d81b7f299 100644 --- a/rs/moq-net/tests/integration/mesh_withdraw.rs +++ b/rs/moq-net/tests/integration/mesh_withdraw.rs @@ -657,9 +657,9 @@ async fn hop_limit_bounds_receive_and_send() { .unwrap(), ) }; - let _long = source.dynamic_route("long", route(2)).unwrap(); - let _boundary = source.dynamic_route("boundary", route(1)).unwrap(); - let short = source.dynamic_route("short", route(0)).unwrap(); + let _long = source.publish("long", route(2)).unwrap(); + let _boundary = source.publish("boundary", route(1)).unwrap(); + let short = source.publish("short", route(0)).unwrap(); moq_clock::sim::idle().await; let received = drain(&mut received); assert!(!received.contains_key("long"), "{name}: accepted over-limit path"); @@ -678,12 +678,12 @@ async fn hop_limit_bounds_receive_and_send() { Some(&Kind::End), "{name}" ); - let _replacement = source.dynamic_route("short", route(1)).unwrap(); + let _replacement = source.publish("short", route(1)).unwrap(); moq_clock::sim::idle().await; assert!(drain(&mut sent).is_empty(), "{name}: forwarded over-limit replacement"); continue; } - short.update_route(route(1)).unwrap(); + short.announce(route(1)).unwrap(); moq_clock::sim::idle().await; let ended = drain(&mut sent); assert_eq!( @@ -738,9 +738,9 @@ async fn hop_limit_falls_back_to_a_forwardable_route() { .with_cost(origin::Cost::new(cost)) }; // Three hops at the relay once the sender is appended: in the cap, but full. - let _cheap = far.dynamic_route("room", route(100, 2, 1)).unwrap(); + let _cheap = far.publish("room", route(100, 2, 1)).unwrap(); // Two hops at the relay, so it has room for the relay's own. - let pricey = near.dynamic_route("room", route(200, 1, 50)).unwrap(); + let pricey = near.publish("room", route(200, 1, 50)).unwrap(); moq_clock::sim::idle().await; let started = drain(&mut sent); assert_eq!( diff --git a/rs/moq-net/tests/integration/restart.rs b/rs/moq-net/tests/integration/restart.rs index c5b24d1d1a2..1cd5c46c5c5 100644 --- a/rs/moq-net/tests/integration/restart.rs +++ b/rs/moq-net/tests/integration/restart.rs @@ -80,6 +80,7 @@ async fn expect_replaced(version: Version, announced: &mut announce::Consumer, p } /// Nothing more arrives, past the update hold. +#[cfg(feature = "fuzz")] async fn quiet(version: Version, announced: &mut announce::Consumer) { if let Ok(event) = moq_clock::timeout(Duration::from_secs(1), announced.next()).await { panic!("{version}: expected nothing more, got {event:?}"); @@ -172,17 +173,20 @@ relay_chain_tests! { } /// A worker claiming `pool` without an epoch, answering each request with an output -/// whose `video` track carries the worker's name. +/// whose `video` track carries the worker's name. The claim is a peer's, landed through +/// the hidden `fuzz` module as a session lands it, so its answers carry no exact route. +#[cfg(feature = "fuzz")] struct Worker { origin: origin::Producer, _claim: Rc, _outputs: Rc>>, } +#[cfg(feature = "fuzz")] impl Worker { fn new(hop: u64) -> Self { let origin = produce_origin(hop); - let claim = Rc::new(origin.dynamic_route("pool", origin::Route::default()).unwrap()); + let claim = Rc::new(moq_net::fuzz::dynamic_route(&origin, "pool", origin::Route::default()).unwrap()); let outputs = Rc::new(RefCell::new(Vec::new())); let (handler, served) = (claim.clone(), outputs.clone()); drop(moq_clock::sim::spawn(async move { @@ -190,7 +194,7 @@ impl Worker { let output = broadcast::Info::new().produce(); let track = output.create_track("video", timed()).unwrap(); write(&track, &format!("{hop}")); - request.accept(&output); + moq_net::fuzz::accept(request, &output); served.borrow_mut().push((output, track)); } })); @@ -206,6 +210,7 @@ impl Worker { /// `pool/job-2` from `10` to itself while `11` keeps winning `pool` itself and /// `pool/job-0`, and `10` keeps `pool/job-1` (pinned by the spread hash). `D` restarts /// `pool`, and the next subscribes reach the right workers. +#[cfg(feature = "fuzz")] async fn prefix_pool(version: &str) { let version: Version = version.parse().unwrap(); let upstream = produce_origin(2); @@ -240,6 +245,7 @@ async fn prefix_pool(version: &str) { } } +#[cfg(feature = "fuzz")] #[moq_clock::test] async fn prefix_pool_lite06() { moq_clock::timeout(TEST_TIMEOUT, prefix_pool("moq-lite-06")) @@ -247,6 +253,7 @@ async fn prefix_pool_lite06() { .expect("timed out"); } +#[cfg(feature = "fuzz")] #[moq_clock::test] async fn prefix_pool_lite07() { moq_clock::timeout(TEST_TIMEOUT, prefix_pool("moq-lite-07-wip")) @@ -255,24 +262,28 @@ async fn prefix_pool_lite07() { } /// A claim at `live` whose handler answers each request with a fresh output, kept so -/// the test can write into it: `outputs[n]` answered the `n`th request. +/// the test can write into it: `outputs[n]` answered the `n`th request. Only a peer's +/// claim carries an epoch or re-prices, so it lands through the hidden `fuzz` module as +/// a session lands it. +#[cfg(feature = "fuzz")] struct Claim { dynamic: Rc, outputs: Rc>>, } +#[cfg(feature = "fuzz")] impl Claim { fn new(origin: &origin::Producer, epoch: Option) -> Self { let mut route = origin::Route::default(); route.epoch = epoch; - let dynamic = Rc::new(origin.dynamic_route("live", route).unwrap()); + let dynamic = Rc::new(moq_net::fuzz::dynamic_route(origin, "live", route).unwrap()); let outputs = Rc::new(RefCell::new(Vec::new())); let (handler, served) = (dynamic.clone(), outputs.clone()); drop(moq_clock::sim::spawn(async move { while let Ok(request) = handler.requested_broadcast().await { let output = broadcast::Info::new().produce(); let track = output.create_track("video", timed()).unwrap(); - request.accept(&output); + moq_net::fuzz::accept(request, &output); served.borrow_mut().push((output, track)); } })); @@ -292,7 +303,11 @@ impl Claim { fn update_epoch(&self, epoch: Option) { let mut route = self.dynamic.route(); route.epoch = epoch; - self.dynamic.update_route(route).unwrap(); + self.update_route(route); + } + + fn update_route(&self, route: origin::Route) { + moq_net::fuzz::update_route(&self.dynamic, route).unwrap(); } } @@ -300,6 +315,7 @@ impl Claim { /// A same-epoch re-price keeps every handle. Moving to `to` restarts `V`, the /// subscription already open stays on the old answer, and a re-request asks the /// handler again and reads none of the old answer's cached groups. +#[cfg(feature = "fuzz")] async fn dynamic_epoch(version: &str, from: Option, to: Option) { let version: Version = version.parse().unwrap(); let publisher = produce_origin(1); @@ -320,7 +336,7 @@ async fn dynamic_epoch(version: &str, from: Option, to: Option) { assert_eq!(read(&mut sticky).await, "old:0", "{version}"); // A re-price at the same epoch is an update at most: every handle survives. - claim.dynamic.update_route(claim.dynamic.route().with_cost(5)).unwrap(); + claim.update_route(claim.dynamic.route().with_cost(5)); while let Ok(event) = moq_clock::timeout(Duration::from_secs(1), announced.next()).await { assert!( matches!(event, Some(announce::Event::Update(_))), @@ -373,6 +389,7 @@ fn epochs() -> (Epoch, Epoch) { macro_rules! dynamic_epoch_tests { ($($name:ident: $version:literal,)*) => { $( + #[cfg(feature = "fuzz")] #[moq_clock::test] async fn $name() { let (a, b) = epochs(); @@ -396,17 +413,15 @@ dynamic_epoch_tests! { /// An update does not override route selection: with a second claim still at the /// epoch, a claim dropping it hands re-requests to the second, which outranks any /// route without one. +#[cfg(feature = "fuzz")] #[moq_clock::test] async fn dynamic_epoch_second_route_keeps_winning() { let (a, _) = epochs(); let origin = produce_origin(1); let first = Claim::new(&origin, Some(a.clone())); - first.dynamic.update_route(first.dynamic.route().with_cost(1)).unwrap(); + first.update_route(first.dynamic.route().with_cost(1)); let second = Claim::new(&origin, Some(a.clone())); - second - .dynamic - .update_route(second.dynamic.route().with_cost(5)) - .unwrap(); + second.update_route(second.dynamic.route().with_cost(5)); let mut announced = origin.consume().announced(); match next_event(&mut announced).await { announce::Event::Start(announce) => assert_eq!(announce.route.epoch.as_ref(), Some(&a)), @@ -563,6 +578,7 @@ async fn withdrawal_refuses_new_fetches_but_keeps_open_reads() { } } +#[cfg(feature = "fuzz")] #[moq_clock::test] async fn a_more_specific_source_cannot_fill_a_held_covering_track() { for name in [ From e78ed1dbed6e12eca59f481a9b906af4b18a9107 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 23 Oct 2026 16:46:50 -0700 Subject: [PATCH 05/15] test(mux): answer claims through prepare and publish Co-Authored-By: Claude Opus 5.5 --- rs/moq-mux/benches/containers.rs | 17 ++---- rs/moq-mux/benches/export.rs | 17 ++---- rs/moq-mux/src/catalog/hang/consumer.rs | 29 +++++----- rs/moq-mux/src/container/consumer.rs | 37 ++++-------- rs/moq-mux/src/container/fmp4/export_test.rs | 6 +- rs/moq-mux/src/container/ts/export_test.rs | 59 ++++++++------------ rs/moq-mux/src/source.rs | 24 +++----- 7 files changed, 67 insertions(+), 122 deletions(-) diff --git a/rs/moq-mux/benches/containers.rs b/rs/moq-mux/benches/containers.rs index fa067578ca8..67948093bb1 100644 --- a/rs/moq-mux/benches/containers.rs +++ b/rs/moq-mux/benches/containers.rs @@ -34,13 +34,11 @@ struct Fixture { _published: Box, _origin: moq_net::origin::Producer, driver: tokio::task::JoinHandle, - requests: tokio::task::JoinHandle<()>, } impl Drop for Fixture { fn drop(&mut self) { self.driver.abort(); - self.requests.abort(); } } @@ -56,8 +54,9 @@ async fn prepare(video: Video, payload: &Bytes) -> (moq_mux::Source, impl Stream frames, frames_per_group, } = video; - let mut broadcast = moq_net::broadcast::Info::new().produce(); - let consumer = broadcast.consume(); + let (origin, driver) = moq_net::origin::Producer::new(Default::default()); + let driver = tokio::spawn(moq_net::time::run(driver)); + let mut broadcast = origin.create_broadcast("bench").unwrap(); let mut catalog = moq_mux::catalog::Producer::new(&mut broadcast, Default::default()).unwrap(); let mut publishers = Vec::new(); for index in 0..tracks { @@ -85,14 +84,7 @@ async fn prepare(video: Video, payload: &Bytes) -> (moq_mux::Source, impl Stream publishers.push(publisher); } catalog.finish().unwrap(); - let (origin, driver) = moq_net::origin::Producer::new(Default::default()); - let driver = tokio::spawn(moq_net::time::run(driver)); - let dynamic = origin.dynamic_route("", Default::default()).unwrap(); - let requests = tokio::spawn(async move { - while let Ok(request) = dynamic.requested_broadcast().await { - request.accept(consumer.clone()); - } - }); + broadcast.announce(Default::default()).unwrap(); let resolved = origin.consume().request_broadcast("bench", None).await.unwrap(); let source = moq_mux::Source::new(origin.consume(), resolved); let stream = source.catalog::<()>(CatalogFormat::Hang).await.unwrap(); @@ -100,7 +92,6 @@ async fn prepare(video: Video, payload: &Bytes) -> (moq_mux::Source, impl Stream _published: Box::new((broadcast, catalog, publishers)), _origin: origin, driver, - requests, }; (source, stream, fixture) } diff --git a/rs/moq-mux/benches/export.rs b/rs/moq-mux/benches/export.rs index 6815de0c639..6262d5831c4 100644 --- a/rs/moq-mux/benches/export.rs +++ b/rs/moq-mux/benches/export.rs @@ -14,20 +14,19 @@ struct Fixture { _published: Box, _origin: moq_net::origin::Producer, driver: tokio::task::JoinHandle, - requests: tokio::task::JoinHandle<()>, } impl Drop for Fixture { fn drop(&mut self) { self.driver.abort(); - self.requests.abort(); } } /// A finished broadcast of `tracks` audio tracks with `frames` queued frames each. async fn prepare(tracks: usize, frames: usize) -> (moq_mux::Source, moq_mux::catalog::Consumer, Fixture) { - let mut broadcast = moq_net::broadcast::Info::new().produce(); - let consumer = broadcast.consume(); + let (origin, driver) = moq_net::origin::Producer::new(Default::default()); + let driver = tokio::spawn(moq_net::time::run(driver)); + let mut broadcast = origin.create_broadcast("bench").unwrap(); let mut catalog = moq_mux::catalog::Producer::new(&mut broadcast, Default::default()).unwrap(); let stream = moq_mux::catalog::Consumer::from(catalog.consume().unwrap()); let mut publishers = Vec::new(); @@ -56,21 +55,13 @@ async fn prepare(tracks: usize, frames: usize) -> (moq_mux::Source, moq_mux::cat track.finish().unwrap(); } catalog.finish().unwrap(); - let (origin, driver) = moq_net::origin::Producer::new(Default::default()); - let driver = tokio::spawn(moq_net::time::run(driver)); - let dynamic = origin.dynamic_route("", Default::default()).unwrap(); - let requests = tokio::spawn(async move { - while let Ok(request) = dynamic.requested_broadcast().await { - request.accept(consumer.clone()); - } - }); + broadcast.announce(Default::default()).unwrap(); let resolved = origin.consume().request_broadcast("bench", None).await.unwrap(); let source = moq_mux::Source::new(origin.consume(), resolved); let fixture = Fixture { _published: Box::new((broadcast, catalog, publishers)), _origin: origin, driver, - requests, }; (source, stream, fixture) } diff --git a/rs/moq-mux/src/catalog/hang/consumer.rs b/rs/moq-mux/src/catalog/hang/consumer.rs index f7c9ec2f178..513abcd4b7b 100644 --- a/rs/moq-mux/src/catalog/hang/consumer.rs +++ b/rs/moq-mux/src/catalog/hang/consumer.rs @@ -158,33 +158,32 @@ mod test { publish_catalog(published) } - /// [`publish_catalog`] against a *standalone* broadcast (no origin, so its own path is - /// empty) that a dynamic handler serves at `a/pub`, read back through the requesting - /// origin cursor. That is the shape an FFI app takes when it answers an origin request - /// with a broadcast it built itself. + /// [`publish_catalog`] against the answer a dynamic handler prepares and publishes at + /// `a/pub`, read back through the requesting origin cursor. That is the shape an app + /// takes when it answers an origin request. async fn publish_catalog_served(published: Catalog<()>) -> Result> { - let broadcast = moq_net::broadcast::Info::new().produce(); - let track = broadcast - .create_track(hang::Catalog::DEFAULT_NAME, hang::Catalog::default_track_info()) - .expect("catalog track should create"); - let origin = crate::source::produce_origin(); - let dynamic = origin.dynamic_route("", Default::default()).unwrap(); + let dynamic = origin.dynamic("").unwrap(); let requesting = origin.consume().request_broadcast("a/pub", None); - let served = broadcast.consume(); // The handler hands its route back: dropping it would retract the route and end // the broadcast it just served. let handler = tokio::spawn(async move { - dynamic + let prepared = dynamic .requested_broadcast() .await .expect("handler should be asked for the path") - .accept(&served); - dynamic + .prepare(moq_net::Epoch::mint()) + .expect("the answer should prepare"); + let track = prepared + .broadcast() + .create_track(hang::Catalog::DEFAULT_NAME, hang::Catalog::default_track_info()) + .expect("catalog track should create"); + let broadcast = prepared.publish().expect("the answer should publish"); + (dynamic, broadcast, track) }); let resolved = requesting.await; - let _dynamic = handler.await.expect("handler task"); + let (_dynamic, _broadcast, track) = handler.await.expect("handler task"); let subscriber = resolved .expect("the handler served it") .track(hang::Catalog::DEFAULT_NAME) diff --git a/rs/moq-mux/src/container/consumer.rs b/rs/moq-mux/src/container/consumer.rs index 89bea924e73..83700aa4e70 100644 --- a/rs/moq-mux/src/container/consumer.rs +++ b/rs/moq-mux/src/container/consumer.rs @@ -2381,21 +2381,17 @@ mod tests { let epoch = moq_net::Epoch::mint(); let origin = crate::source::produce_origin(); let hops = moq_net::Hops::try_from(vec![moq_net::Hop::new(10).unwrap()]).unwrap(); - let first_route = origin - .dynamic_route( - "live", - moq_net::origin::Route::default() - .with_epoch(epoch.clone()) - .with_hops(hops.clone()) - .with_cost(5), - ) - .unwrap(); - let pending = origin.consume().request_broadcast("live", None); - let first = moq_net::broadcast::Info::new().produce(); + let route = |cost: u64| { + moq_net::origin::Route::default() + .with_epoch(epoch.clone()) + .with_hops(hops.clone()) + .with_cost(cost) + }; + let first = origin.create_broadcast("live").unwrap(); let info = hang::container::track_info(hang::catalog::PRIORITY.video); let first_track = first.create_track("video", info.clone()).unwrap(); - first_route.requested_broadcast().await.unwrap().accept(&first); - let broadcast = pending.await.unwrap(); + first.announce(route(5)).unwrap(); + let broadcast = origin.consume().request_broadcast("live", None).await.unwrap(); let track = broadcast .track("video") .unwrap() @@ -2422,21 +2418,11 @@ mod tests { // while the first route stays up but silent. They carry the same broadcast, so each // holds every group from 1 on. The open group is given up once the track runs a // full budget past it. - let mut routes = Vec::new(); let mut sources = Vec::new(); for sequence in 1..=4 { - let route = origin - .dynamic_route( - "live", - moq_net::origin::Route::default() - .with_epoch(epoch.clone()) - .with_hops(hops.clone()) - .with_cost(5 - sequence), - ) - .unwrap(); - let source = moq_net::broadcast::Info::new().produce(); + let source = origin.create_broadcast("live").unwrap(); let mut track = source.create_track("video", info.clone()).unwrap(); - route.requested_broadcast().await.unwrap().accept(&source); + source.announce(route(5 - sequence)).unwrap(); let mut demand = track.demand(); while demand.wanted_changed().await.unwrap().is_none() {} if sequence == 1 { @@ -2445,7 +2431,6 @@ mod tests { for sequence in 1..=sequence { write_group(&mut track, sequence, &[ts(sequence * 1_000_000)]); } - routes.push(route); sources.push((source, track)); } diff --git a/rs/moq-mux/src/container/fmp4/export_test.rs b/rs/moq-mux/src/container/fmp4/export_test.rs index 58bf7593a87..896ca3e6d80 100644 --- a/rs/moq-mux/src/container/fmp4/export_test.rs +++ b/rs/moq-mux/src/container/fmp4/export_test.rs @@ -1928,12 +1928,12 @@ async fn split_h265_parameter_sets_keep_the_sync_point() { #[tokio::test(start_paused = true)] async fn an_in_band_keyframe_without_parameter_sets_fails() { let mut live = Live::avc3(); - live.track.write(annexb_frame(0, &[IDR])).unwrap(); - live.track.finish().unwrap(); - let mut exporter = crate::container::fmp4::Export::new(live.source().await, live.catalog_stream().await) .with_max_delay(RECORDING_MAX_DELAY); chunk_now(&mut exporter).await.init().expect("init"); + + live.track.write(annexb_frame(0, &[IDR])).unwrap(); + live.track.finish().unwrap(); let err = error_now(&mut exporter).await; assert!( matches!( diff --git a/rs/moq-mux/src/container/ts/export_test.rs b/rs/moq-mux/src/container/ts/export_test.rs index 9e4c14a3ef2..81aa9a970e6 100644 --- a/rs/moq-mux/src/container/ts/export_test.rs +++ b/rs/moq-mux/src/container/ts/export_test.rs @@ -9327,38 +9327,33 @@ async fn a_follower_built_after_its_route_went_follows_the_return() { assert_eq!(start.elapsed(), Duration::from_secs(4) + linger); } -/// The export's own instance winning the path back from a more specific route that went is no +/// The export's own instance winning the path back from a cheaper route that went is no /// replacement: its end ends the export cleanly, without stitching. #[tokio::test(start_paused = true)] async fn a_follower_takes_its_own_instance_winning_back_for_no_replacement() { let origin = crate::source::produce_origin(); - let epoch = moq_net::Epoch::mint(); - let route = |epoch| moq_net::origin::Route::default().with_epoch(epoch); + let route = |cost: u64| { + moq_net::origin::Route::default() + .with_epoch(moq_net::Epoch::mint()) + .with_cost(cost) + }; - // The export resolves through a prefix claim serving its own instance. - let mut info = moq_net::broadcast::Info::new(); - info.epoch = Some(epoch.clone()); - let mut served = info.produce(); + // The export resolves its own instance. + let mut served = origin.create_broadcast("pool/job").unwrap(); let mut catalog = crate::catalog::Producer::<()>::new(&mut served, Default::default()).unwrap(); - let pool = origin.dynamic_route("pool", route(epoch)).unwrap(); - let consumer = served.consume(); - tokio::spawn(async move { - while let Ok(request) = pool.requested_broadcast().await { - request.accept(consumer.clone()); - } - }); + served.announce(route(5)).unwrap(); let source = crate::source::resolved(&origin, "pool/job").await; let mut follower = super::Follower::new(open_export(source).await.unwrap()).unwrap(); - // A more specific route takes the path for a while, then goes. - let exact = origin.publish("pool/job", route(moq_net::Epoch::mint())).unwrap(); + // A cheaper route takes the path for a while, then goes. + let cheaper = origin.publish("pool/job", route(1)).unwrap(); assert!( tokio::time::timeout(Duration::from_secs(1), follower.next()) .await .is_err(), "still on its own instance" ); - drop(exact); + drop(cheaper); assert!( tokio::time::timeout(Duration::from_secs(1), follower.next()) .await @@ -9449,10 +9444,10 @@ async fn drain_follower(follower: &mut super::Follower) -> (Vec, Option::new(&mut served, Default::default()).unwrap(); let mut track = aac_rendition(&mut served, &mut catalog, "a.aac"); - let pool = origin.dynamic_route("pool", route).unwrap(); - let consumer = served.consume(); - tokio::spawn(async move { - while let Ok(request) = pool.requested_broadcast().await { - request.accept(consumer.clone()); - } - }); + served.announce(route).unwrap(); let resumed = start.elapsed().as_millis() as u64; for ms in (resumed..resumed + 200).step_by(20) { write_aac(&mut track, ms); @@ -9503,7 +9490,7 @@ async fn a_follower_continues_through_a_same_epoch_handoff_after_a_gap() { frames.extend(rest); assert!( matches!(end, Some(Ok(()))), - "the prefix's broadcast ends the export: {end:?}" + "the second route's broadcast ends the export: {end:?}" ); assert_eq!(pes_count(&frames), 20, "every frame of both routes went out"); @@ -9532,10 +9519,10 @@ async fn a_follower_ignores_a_covering_prefix_while_the_exact_route_serves() { let mut follower = super::Follower::new(open_export(source).await.unwrap()).unwrap(); let quiet = Duration::from_secs(1); - let pool = origin.dynamic_route("pool", route(moq_net::Epoch::mint())).unwrap(); + let pool = origin.dynamic("pool").unwrap(); assert!(tokio::time::timeout(quiet, follower.next()).await.is_err(), "a start"); drop(pool); - let pool = origin.dynamic_route("pool", route(moq_net::Epoch::mint())).unwrap(); + let pool = origin.dynamic("pool").unwrap(); assert!(tokio::time::timeout(quiet, follower.next()).await.is_err(), "a restart"); drop(pool); assert!(tokio::time::timeout(quiet, follower.next()).await.is_err(), "an end"); diff --git a/rs/moq-mux/src/source.rs b/rs/moq-mux/src/source.rs index 665c70a441e..f68afe5663a 100644 --- a/rs/moq-mux/src/source.rs +++ b/rs/moq-mux/src/source.rs @@ -444,21 +444,13 @@ pub(crate) async fn resolved(origin: &moq_net::origin::Producer, path: &str) -> Source::new(consumer, broadcast) } -/// Test helper: serve `broadcast` at `test` (and every other path) on a throwaway origin's -/// dynamic handler and return a [`Source`] built from it, so exporter tests that build a -/// local broadcast still resolve references through an origin. The origin is leaked so the -/// broadcast stays reachable for the source's lifetime (harmless in a test binary). +/// Test helper: a [`Source`] for a standalone `broadcast`, resolving its references through +/// a throwaway origin that serves nothing. The origin is leaked so its driver runs for the +/// source's lifetime (harmless in a test binary). #[cfg(test)] pub(crate) async fn announced(broadcast: &moq_net::broadcast::Consumer) -> Source { let origin = produce_origin(); - let dynamic = origin.dynamic_route("", Default::default()).unwrap(); - let served = broadcast.clone(); - tokio::spawn(async move { - while let Ok(request) = dynamic.requested_broadcast().await { - request.accept(served.clone()); - } - }); - let source = resolved(&origin, "test").await; + let source = Source::new(origin.consume(), broadcast.clone()); Box::leak(Box::new(origin)); source } @@ -483,7 +475,7 @@ mod tests { let _catalog = origin.publish("pub", Default::default()).unwrap(); settle().await; let source = resolved(&origin, "pub").await; - let dynamic = origin.dynamic_route("media", Default::default()).unwrap(); + let dynamic = origin.dynamic("media").unwrap(); let binding = source.bind(Some(&Relative::new("media")), None).unwrap(); let request = dynamic.requested_broadcast().await.unwrap(); @@ -498,9 +490,9 @@ mod tests { _ = std::future::ready(()) => {} } - let producer = moq_net::broadcast::Info::default().produce(); - let (first, second, ()) = tokio::join!(binding.broadcast(), binding.broadcast(), async { - request.accept(producer.consume()); + let prepared = request.prepare(moq_net::Epoch::mint()).unwrap(); + let (first, second, producer) = tokio::join!(binding.broadcast(), binding.broadcast(), async { + prepared.publish().unwrap() }); let first = first.unwrap(); assert!(!first.is_closed()); From 979624de742e13a65ded490320cf5697e3af1462 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 23 Oct 2026 17:01:23 -0700 Subject: [PATCH 06/15] test: move hls, gst, and cli tests off the peer-route hatches Co-Authored-By: Claude Opus 5.5 --- rs/moq-cli/src/play/media.rs | 51 ++++++++++++----------- rs/moq-gst/src/source/imp.rs | 27 +++++-------- rs/moq-hls/src/export/mod.rs | 78 ++++++++++++------------------------ 3 files changed, 64 insertions(+), 92 deletions(-) diff --git a/rs/moq-cli/src/play/media.rs b/rs/moq-cli/src/play/media.rs index d638cba7f6a..80503198799 100644 --- a/rs/moq-cli/src/play/media.rs +++ b/rs/moq-cli/src/play/media.rs @@ -1402,6 +1402,26 @@ mod tests { (broadcast, catalog, audio) } + /// Answer the next request through `claim` with one `audio` rendition, under `route`'s epoch. + async fn answer( + claim: &moq_net::origin::Dynamic, + route: &moq_net::origin::Route, + ) -> ( + moq_net::broadcast::Producer, + catalog::Producer, + moq_mux::container::Producer, + ) { + let request = tokio::time::timeout(Duration::from_secs(5), claim.requested_broadcast()) + .await + .expect("the claim is asked") + .unwrap(); + let prepared = request.prepare(route.epoch.clone().expect("an epoch")).unwrap(); + let mut broadcast = prepared.broadcast().clone(); + let catalog = catalog::Producer::new(&mut broadcast, Default::default()).unwrap(); + let audio = rendition(&broadcast, &catalog, "audio"); + (prepared.publish().unwrap(), catalog, audio) + } + /// Write `count` packets of `sample` in real time, its timeline starting at 0. async fn write(audio: &mut moq_mux::container::Producer, count: u64, sample: f32) { for index in 0..count { @@ -1616,17 +1636,9 @@ mod tests { // The broadcast ends while its route stands, so the player goes idle. tokio::time::sleep(Duration::from_secs(60)).await; - let mut served = moq_net::broadcast::Info::new().produce(); - let served_catalog = catalog::Producer::new(&mut served, Default::default()).unwrap(); - let mut new = rendition(&served, &served_catalog, "audio"); - let pool = origin.dynamic_route("", route).unwrap(); + let pool = origin.dynamic("").unwrap(); drop(exact); - let consumer = served.consume(); - tokio::spawn(async move { - while let Ok(request) = pool.requested_broadcast().await { - request.accept(consumer.clone()); - } - }); + let (served, served_catalog, mut new) = answer(&pool, &route).await; write(&mut new, 10, NEW).await; settle(player, &recorder).await; @@ -1635,7 +1647,7 @@ mod tests { PACKET_DURATION * 10, "the idle player never played the covering route" ); - drop((served, served_catalog)); + drop((served, served_catalog, pool)); } /// The exact route goes while the old run still holds its delay, and a covering @@ -1651,27 +1663,18 @@ mod tests { let origin = moq_tokio::origin::spawn(); let route = moq_net::origin::Route::default().with_epoch(moq_net::Epoch::mint()); - // One instance, served by the exact route and mirrored behind a covering one. + // One instance, served by the exact route and then answered again behind a covering one. let (exact, exact_catalog, mut old) = publish(&origin, route.clone()); - let mut mirror = moq_net::broadcast::Info::new().produce(); - let mirror_catalog = catalog::Producer::new(&mut mirror, Default::default()).unwrap(); - let mut copy = rendition(&mirror, &mirror_catalog, "audio"); let recorder = Recorder::default(); let player = tokio::spawn(media(&origin, Duration::from_millis(500), recorder.clone()).run()); for index in 0..50 { old.write(packet(index, OLD)).unwrap(); - copy.write(packet(index, OLD)).unwrap(); tokio::time::sleep(PACKET_DURATION).await; } exact.unannounce(); - let pool = origin.dynamic_route("", route).unwrap(); - let consumer = mirror.consume(); - tokio::spawn(async move { - while let Ok(request) = pool.requested_broadcast().await { - request.accept(consumer.clone()); - } - }); + let pool = origin.dynamic("").unwrap(); + let (mirror, mirror_catalog, mut copy) = answer(&pool, &route).await; for index in 50..60 { copy.write(packet(index, NEW)).unwrap(); tokio::time::sleep(PACKET_DURATION).await; @@ -1682,7 +1685,7 @@ mod tests { played(&recorder, NEW).0 > Duration::ZERO, "the player never reached the covering route" ); - drop((mirror, mirror_catalog, copy, exact, exact_catalog, old)); + drop((mirror, mirror_catalog, copy, pool, exact, exact_catalog, old)); } /// Re-pricing the same instance is an update, which playback rides through on diff --git a/rs/moq-gst/src/source/imp.rs b/rs/moq-gst/src/source/imp.rs index 0650391255c..d18c042216c 100644 --- a/rs/moq-gst/src/source/imp.rs +++ b/rs/moq-gst/src/source/imp.rs @@ -3048,11 +3048,6 @@ mod session_tests { publisher } - /// A broadcast at no path, for a covering route to serve. - fn standalone(config: VideoConfig) -> Self { - Self::build(moq_net::broadcast::Info::new().produce(), config) - } - fn build(mut broadcast: moq_net::broadcast::Producer, config: VideoConfig) -> Self { let mut catalog = moq_mux::catalog::Producer::new(&mut broadcast, moq_mux::catalog::Config::default()).unwrap(); @@ -3446,8 +3441,8 @@ mod session_tests { restart(moq_net::origin::Route::default); } - /// The route serving the path can be a prefix covering it, here the root, and its restart - /// switches too. + /// The route serving the path can be a prefix covering it, here the root, and a restart of + /// its answer switches too. #[test] fn a_covering_route_restart_switches_on_the_same_pad() { let _pad_ids = pad_ids(); @@ -3455,22 +3450,22 @@ mod session_tests { let recorder = Recorder::attach(&element); let origin = origin(); - let mut old = Publisher::standalone(video_rendition()); - let serving = std::sync::Arc::new(Mutex::new(old.broadcast.consume())); - let pool = std::sync::Arc::new(origin.dynamic_route("", epoch()).unwrap()); - let (handler, answer) = (pool.clone(), serving.clone()); + let claim = origin.dynamic("").unwrap(); + let (answers, answered) = std::sync::mpsc::channel(); let handler = super::RUNTIME.spawn(async move { - while let Ok(request) = handler.requested_broadcast().await { - request.accept(answer.lock().unwrap().clone()); + while let Ok(request) = claim.requested_broadcast().await { + let prepared = request.prepare(moq_net::Epoch::mint()).unwrap(); + let answer = Publisher::build(prepared.broadcast().clone(), video_rendition()); + prepared.publish().unwrap(); + answers.send(answer).unwrap(); } }); let session = follow(&element, &origin); + let mut old = answered.recv_timeout(Duration::from_secs(5)).expect("the claim answered"); let pad = recorder.written(&mut old, 1); - let mut new = Publisher::standalone(video_rendition()); - *serving.lock().unwrap() = new.broadcast.consume(); - pool.update_route(epoch()).unwrap(); + let mut new = Publisher::new(&origin, epoch(), video_rendition()); assert_eq!(recorder.written(&mut new, 2), pad, "the restart moved to another pad"); stop(session); diff --git a/rs/moq-hls/src/export/mod.rs b/rs/moq-hls/src/export/mod.rs index 7aa0d99ead7..24ea3214bb9 100644 --- a/rs/moq-hls/src/export/mod.rs +++ b/rs/moq-hls/src/export/mod.rs @@ -3545,8 +3545,7 @@ mod tests { moq_net::origin::Route::default().with_hops(hops(&[first])) } - /// A standalone media publisher (not an origin leaf), so the sibling is served through a - /// routed front. + /// Write three keyframes of `payload` into `broadcast`'s `video0` track, recording each. fn write_routed_media( broadcast: &mut moq_net::broadcast::Producer, payload: &'static [u8], @@ -3569,14 +3568,6 @@ mod tests { media } - async fn accept_sibling(server: &moq_net::origin::Dynamic, broadcast: &moq_net::broadcast::Producer) { - let request = tokio::time::timeout(Duration::from_secs(5), server.requested_broadcast()) - .await - .expect("the sibling path is requested") - .expect("the handler is open"); - request.accept(broadcast); - } - async fn until_finished(rendition: &Rendition) { let deadline = tokio::time::Instant::now() + Duration::from_secs(5); loop { @@ -3605,9 +3596,8 @@ mod tests { let catalog = moq_mux::catalog::Producer::new(&mut live, moq_mux::catalog::Config::default()).unwrap(); let recorder = catalog.enroll_test("video0").unwrap(); - let mut old_media = moq_net::broadcast::Info::new().produce(); + let mut old_media = origin.create_broadcast("media").unwrap(); let _old_track = write_routed_media(&mut old_media, OLD, recorder, 0); - let old_server = origin.dynamic_route("media", sibling_route(10)).unwrap(); settle().await; let upstream = crate::resolved(&origin, "live").await; @@ -3630,7 +3620,7 @@ mod tests { false, )); let rendition = renditions.get(Kind::Video, "video0").unwrap(); - accept_sibling(&old_server, &old_media).await; + old_media.announce(sibling_route(10)).unwrap(); tokio::time::timeout(Duration::from_secs(5), rendition.playable()) .await .expect("the timeline reaches the rendition"); @@ -3644,7 +3634,7 @@ mod tests { let mut early = rendition.segments(); assert_eq!(early.next().await.unwrap().unwrap().discontinuity, 0); - drop((old_server, old_media, _old_track)); + drop((old_media, _old_track)); until_finished(&rendition).await; let served = rendition .listed_segment(0) @@ -3658,24 +3648,18 @@ mod tests { } // A publisher serving the path afterwards, with rows recorded for it, is never bound. - let new_server = origin.dynamic_route("media", sibling_route(11)).unwrap(); + let mut new_media = origin.create_broadcast("media").unwrap(); + new_media.announce(sibling_route(11)).unwrap(); let _ = rendition.snapshot(); - assert!( - tokio::time::timeout(Duration::from_secs(1), new_server.requested_broadcast()) - .await - .is_err(), - "the replacement is never requested" - ); let recorder = catalog.enroll_test("video0").unwrap(); fanout.skip(None); - let mut new_media = moq_net::broadcast::Info::new().produce(); let _new_track = write_routed_media(&mut new_media, NEW, recorder, 6_000_000); tokio::time::sleep(Duration::from_secs(1)).await; let snapshot = rendition.snapshot(); assert!(snapshot.segments.is_empty(), "nothing of the replacement is listed"); assert!(snapshot.finished); watcher.abort(); - drop((new_media, _new_track, new_server, catalog, live)); + drop((new_media, _new_track, catalog, live)); } // A restart can land while a segment request waits for its media broadcast. The init @@ -3692,9 +3676,8 @@ mod tests { let catalog = moq_mux::catalog::Producer::new(&mut live, moq_mux::catalog::Config::default()).unwrap(); let recorder = catalog.enroll_test("video0").unwrap(); - let mut media = moq_net::broadcast::Info::new().produce(); + let mut media = origin.create_broadcast("media").unwrap(); let _track = write_routed_media(&mut media, KEYFRAME, recorder, 0); - let server = origin.dynamic_route("media", sibling_route(10)).unwrap(); settle().await; let upstream = crate::resolved(&origin, "live").await; @@ -3721,7 +3704,7 @@ mod tests { .await .expect("the timeline reaches the rendition"); - // The sibling request is unanswered, so the segment waits for its media broadcast. + // The sibling is unannounced, so the segment waits for its media broadcast. let mut segment = std::pin::pin!(rendition.listed_segment(0)); assert!( tokio::time::timeout(Duration::from_millis(10), segment.as_mut()) @@ -3731,7 +3714,7 @@ mod tests { fanout.set_generation(Some("run-1".into())); fanout.set_generation(Some("run-2".into())); - accept_sibling(&server, &media).await; + media.announce(sibling_route(10)).unwrap(); segment .await .unwrap() @@ -3755,9 +3738,9 @@ mod tests { let catalog = moq_mux::catalog::Producer::new(&mut live, moq_mux::catalog::Config::default()).unwrap(); let recorder = catalog.enroll_test("video0").unwrap(); - let mut media = moq_net::broadcast::Info::new().produce(); + let mut media = origin.create_broadcast("media").unwrap(); let _track = write_routed_media(&mut media, NEW, recorder, 0); - let server = origin.dynamic_route("media", sibling_route(11)).unwrap(); + media.announce(sibling_route(11)).unwrap(); settle().await; let upstream = crate::resolved(&origin, "live").await; @@ -3765,7 +3748,6 @@ mod tests { config.broadcast = Some(moq_net::path::Relative::new("media").to_owned()); let (rendition, watcher) = export(&upstream, &config); - accept_sibling(&server, &media).await; tokio::time::timeout(Duration::from_secs(5), rendition.playable()) .await .expect("the timeline reaches the rendition"); @@ -3776,7 +3758,7 @@ mod tests { .expect("the sibling present at export start is servable"); assert!(contains(&served, NEW)); watcher.abort(); - drop((media, _track, server, catalog, live)); + drop((media, _track, catalog, live)); } // The replacement is often announced after the incumbent is already gone. Nothing serves @@ -3794,9 +3776,8 @@ mod tests { let catalog = moq_mux::catalog::Producer::new(&mut live, moq_mux::catalog::Config::default()).unwrap(); let recorder = catalog.enroll_test("video0").unwrap(); - let mut old_media = moq_net::broadcast::Info::new().produce(); + let mut old_media = origin.create_broadcast("media").unwrap(); let _old_track = write_routed_media(&mut old_media, OLD, recorder, 0); - let old_server = origin.dynamic_route("media", sibling_route(10)).unwrap(); settle().await; let upstream = crate::resolved(&origin, "live").await; @@ -3804,13 +3785,13 @@ mod tests { config.broadcast = Some(moq_net::path::Relative::new("media").to_owned()); let (rendition, watcher) = export(&upstream, &config); - accept_sibling(&old_server, &old_media).await; + old_media.announce(sibling_route(10)).unwrap(); tokio::time::timeout(Duration::from_secs(5), rendition.playable()) .await .expect("the timeline reaches the rendition"); assert!(rendition.listed_segment(0).await.unwrap().is_some()); - drop((old_server, old_media, _old_track)); + drop((old_media, _old_track)); until_finished(&rendition).await; let before = rendition.snapshot().segments.len(); assert!(before > 0); @@ -3821,17 +3802,12 @@ mod tests { .expect("the old retained segment drains"); assert!(contains(&served, OLD)); - let new_server = origin.dynamic_route("media", sibling_route(11)).unwrap(); - let mut new_media = moq_net::broadcast::Info::new().produce(); + let mut new_media = origin.create_broadcast("media").unwrap(); let recorder = catalog.enroll_test("video0").unwrap(); let _new_track = write_routed_media(&mut new_media, NEW, recorder, 6_000_000); + new_media.announce(sibling_route(11)).unwrap(); let _ = rendition.snapshot(); - assert!( - tokio::time::timeout(Duration::from_secs(1), new_server.requested_broadcast()) - .await - .is_err(), - "the replacement is never requested" - ); + tokio::time::sleep(Duration::from_secs(1)).await; let snapshot = rendition.snapshot(); assert_eq!(snapshot.segments.len(), before, "only the old rows remain listed"); assert!(snapshot.finished, "the playlist ended with its sibling"); @@ -3843,7 +3819,7 @@ mod tests { assert!(contains(&served, OLD)); assert!(!contains(&served, NEW)); watcher.abort(); - drop((new_media, _new_track, new_server, catalog, live)); + drop((new_media, _new_track, catalog, live)); } // A recorder waiting at the live edge ends when the bound sibling ends, with nothing else @@ -3858,16 +3834,15 @@ mod tests { live.announce(Default::default()).expect("publish allowed"); let catalog = moq_mux::catalog::Producer::new(&mut live, moq_mux::catalog::Config::default()).unwrap(); let recorder = catalog.enroll_test("video0").unwrap(); - let mut media = moq_net::broadcast::Info::new().produce(); + let mut media = origin.create_broadcast("media").unwrap(); let track = write_routed_media(&mut media, OLD, recorder, 0); - let server = origin.dynamic_route("media", sibling_route(10)).unwrap(); settle().await; let upstream = crate::resolved(&origin, "live").await; let mut config = video_config(); config.broadcast = Some(moq_net::path::Relative::new("media").to_owned()); let (rendition, watcher) = export(&upstream, &config); - accept_sibling(&server, &media).await; + media.announce(sibling_route(10)).unwrap(); tokio::time::timeout(Duration::from_secs(5), rendition.playable()) .await .expect("the timeline reaches the rendition"); @@ -3885,7 +3860,7 @@ mod tests { } // Only the sibling broadcast goes: its recorder stays, so the timeline stays quiet. - drop((server, media)); + drop(media); let end = tokio::time::timeout(Duration::from_secs(5), cursor.next()) .await .expect("the cursor wakes when its sibling ends") @@ -3907,7 +3882,7 @@ mod tests { live.announce(Default::default()).expect("publish allowed"); let catalog = moq_mux::catalog::Producer::new(&mut live, moq_mux::catalog::Config::default()).unwrap(); let recorder = catalog.enroll_test("video0").unwrap(); - let mut media = moq_net::broadcast::Info::new().produce(); + let mut media = origin.create_broadcast("media").unwrap(); let _track = write_routed_media(&mut media, OLD, recorder, 0); settle().await; @@ -3920,9 +3895,8 @@ mod tests { .await .expect("the catalog's rows are listed before the sibling resolves"); - let server = origin.dynamic_route("media", sibling_route(10)).unwrap(); let _ = rendition.snapshot(); - accept_sibling(&server, &media).await; + media.announce(sibling_route(10)).unwrap(); let served = rendition .listed_segment(0) .await @@ -3930,7 +3904,7 @@ mod tests { .expect("the late sibling is servable"); assert!(contains(&served, OLD)); watcher.abort(); - drop((media, _track, server, catalog, live)); + drop((media, _track, catalog, live)); } // A rendition the catalog drops must end its segment cursors. The cursor holds an From cde5a7d6e0d5b1063b6d57656f232a286863f816 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 23 Oct 2026 17:11:28 -0700 Subject: [PATCH 07/15] test(c,ffi): answer claims through prepare and publish Co-Authored-By: Claude Opus 5.5 --- rs/moq-c/src/test.rs | 36 ++++++++++++---------------------- rs/moq-ffi/src/test.rs | 25 +++++++++++------------ rs/moq-net/src/model/origin.rs | 3 +-- 3 files changed, 24 insertions(+), 40 deletions(-) diff --git a/rs/moq-c/src/test.rs b/rs/moq-c/src/test.rs index 393fc8c9ed6..756c29575e2 100644 --- a/rs/moq-c/src/test.rs +++ b/rs/moq-c/src/test.rs @@ -3675,13 +3675,7 @@ fn consume_announced_delivers_a_takeover_once(dynamic_at: &str) { let dynamic = { let _runtime = crate::ffi::RUNTIME.enter(); let origin = crate::Id::try_from(origin).unwrap(); - let route = moq_net::origin::Route::default(); - State::lock() - .origin - .get(origin) - .unwrap() - .dynamic_route(dynamic_at, route) - .unwrap() + State::lock().origin.get(origin).unwrap().dynamic(dynamic_at).unwrap() }; let cb = Callback::new(); let path = b"live"; @@ -3721,16 +3715,11 @@ fn consume_announced_delivers_a_held_takeover_once() { consume_announced_delivers_a_takeover_once("live"); } -/// Serve `prefix` on `origin` dynamically through `route`. -fn serve_dynamic(origin: u32, prefix: &str, route: moq_net::origin::Route) -> moq_net::origin::Dynamic { +/// Serve `prefix` on `origin` dynamically. +fn serve_dynamic(origin: u32, prefix: &str) -> moq_net::origin::Dynamic { let _runtime = crate::ffi::RUNTIME.enter(); let origin = crate::Id::try_from(origin).unwrap(); - State::lock() - .origin - .get(origin) - .unwrap() - .dynamic_route(prefix, route) - .unwrap() + State::lock().origin.get(origin).unwrap().dynamic(prefix).unwrap() } /// Wait for the broadcast at `path` on `origin`, delivering each instance through `cb`. @@ -3762,9 +3751,9 @@ extern "C" fn gated_callback(user_data: *mut c_void, code: i32) { } } -/// An end is a gap that ended every request on the old route, even when a route above the path -/// with the same epoch carries the instance on. So the instance serving the path afterwards is -/// delivered again, though the caller still holds its handle. +/// An end is a gap that ended every request on the old route, even when a claim above the path +/// answers under the same epoch. So the instance serving the path afterwards is delivered again, +/// though the caller still holds a handle to its epoch. #[test] fn consume_announced_delivers_the_instance_again_after_an_end() { let origin = id(moq_origin_create()); @@ -3792,7 +3781,7 @@ fn consume_announced_delivers_the_instance_again_after_an_end() { // Hold the wait inside that delivery so both changes are on hand when it next looks. Seeing // the route above stand before the path's own route left would prove no gap: an update. - let dynamic = serve_dynamic(origin, "", route); + let dynamic = serve_dynamic(origin, ""); { let _runtime = crate::ffi::RUNTIME.enter(); run.unannounce(); @@ -3801,9 +3790,8 @@ fn consume_announced_delivers_the_instance_again_after_an_end() { let request = crate::ffi::RUNTIME.block_on(dynamic.requested_broadcast()).unwrap(); let served = { let _runtime = crate::ffi::RUNTIME.enter(); - let served = moq_net::broadcast::Info::new().produce(); - request.accept(served.consume()); - served + let epoch = route.epoch.clone().unwrap(); + request.prepare(epoch).unwrap().publish().unwrap() }; let again = id(recv()); @@ -3820,7 +3808,7 @@ fn consume_announced_delivers_the_instance_again_after_an_end() { #[test] fn consume_announced_delivers_a_freed_takeover_again() { let origin = id(moq_origin_create()); - let dynamic = serve_dynamic(origin, "live", moq_net::origin::Route::default()); + let dynamic = serve_dynamic(origin, "live"); let cb = Callback::new(); let task = consume_announced(origin, b"live", &cb); @@ -3843,7 +3831,7 @@ fn consume_announced_delivers_a_freed_takeover_again() { #[test] fn consume_announced_delivers_a_gap_while_resolving_once() { let origin = id(moq_origin_create()); - let dynamic = serve_dynamic(origin, "", moq_net::origin::Route::default()); + let dynamic = serve_dynamic(origin, ""); let cb = Callback::new(); let task = consume_announced(origin, b"live", &cb); diff --git a/rs/moq-ffi/src/test.rs b/rs/moq-ffi/src/test.rs index c22756bb216..cc135005d4c 100644 --- a/rs/moq-ffi/src/test.rs +++ b/rs/moq-ffi/src/test.rs @@ -5178,9 +5178,9 @@ async fn announced_broadcast_hands_out_a_held_takeover_once() { announced_broadcast_hands_out_a_takeover_once("live").await; } -/// An end is a gap that ended every request on the old route, even when a route above the path -/// with the same epoch carries the instance on. So the instance serving the path afterwards is -/// handed out again, though it is the one the caller still holds. +/// An end is a gap that ended every request on the old route, even when a claim above the path +/// answers under the same epoch. So the instance serving the path afterwards is handed out +/// again, though its epoch is the one the caller still holds. #[tokio::test] async fn announced_broadcast_hands_out_the_instance_again_after_an_end() { let origin = MoqOriginProducer::new(MoqOriginConfig::default()); @@ -5196,29 +5196,26 @@ async fn announced_broadcast_hands_out_the_instance_again_after_an_end() { .expect("timed out waiting for the broadcast") .unwrap(); - // A peer's covering route under the same epoch, which the public claim cannot express. - let dynamic = origin - .inner() - .inner - .dynamic_route("", route.clone().try_into().unwrap()) - .unwrap(); + // A covering claim answering under the same epoch. + let dynamic = origin.inner().inner.dynamic("").unwrap(); broadcast.unannounce().unwrap(); let again = tokio::spawn(async move { followed.available().await }); let request = tokio::time::timeout(TIMEOUT, dynamic.requested_broadcast()) .await .expect("the instance never asked the route above the path") .unwrap(); - let served = moq_net::broadcast::Info::new().produce(); - request.accept(served.consume()); + let epoch = route.epoch.clone().unwrap().parse().unwrap(); + let served = request.prepare(epoch).unwrap().publish().unwrap(); let again = tokio::time::timeout(TIMEOUT, again) .await .expect("the instance was never handed out again") .unwrap() .unwrap(); - assert!( - again.inner().is_clone(first.inner()), - "the route above did not carry the instance on" + assert_eq!( + again.inner().info().epoch, + first.inner().info().epoch, + "the claim above did not answer the same instance" ); drop((served, dynamic)); broadcast.close().unwrap(); diff --git a/rs/moq-net/src/model/origin.rs b/rs/moq-net/src/model/origin.rs index 712ec1e836b..6a5dec9777c 100644 --- a/rs/moq-net/src/model/origin.rs +++ b/rs/moq-net/src/model/origin.rs @@ -5890,8 +5890,7 @@ impl kio::Task for Routing { /// Derive a read view from a handle. /// /// Lets APIs accept either a producer or a consumer (e.g. -/// `Client::with_publisher`, -/// [`Request::accept`]). The blanket `&T` impl means you can +/// `Client::with_publisher`). The blanket `&T` impl means you can /// pass by value (`foo(x)`) to hand off ownership, or by reference (`foo(&x)`) /// to keep it, without spelling out `.consume()`. pub trait Consume { From c15c64f51f070f69101ebd80e8554bf0e4e7a879 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 23 Oct 2026 17:22:37 -0700 Subject: [PATCH 08/15] feat(net)!: only sessions accept a JS origin request `Request.accept` becomes the private `#accept`, reached by the built-in session forwarder through an internal hook, so an application claim's request answers only through `prepare` and `publish`. A `@ts-expect-error` line replaces the runtime throw and its test. js/watch tests move onto `dynamic` and `prepare`, and the finished quest is deleted. Co-Authored-By: Claude Opus 5.5 --- js/net/MIGRATE.md | 2 +- js/net/bench/broadcasts.ts | 6 +- js/net/bench/requests.ts | 6 +- js/net/src/connection/forward.ts | 4 +- js/net/src/ietf/publisher.test.ts | 5 +- js/net/src/integration.test.ts | 3 +- js/net/src/internal.ts | 9 +++ js/net/src/lite/publisher.test.ts | 6 +- js/net/src/lite/tracks.test.ts | 7 +- js/net/src/origin-demand.test.ts | 41 ++++++----- js/net/src/origin-request.test.ts | 18 +++-- js/net/src/origin.test.ts | 64 ++++++++-------- js/net/src/origin.ts | 30 +++----- js/net/src/resume.test.ts | 4 +- js/net/src/untimed.test.ts | 3 +- js/watch/src/broadcast.test.ts | 93 +++++++++++------------- quest/m1/api-audit/README.md | 2 - quest/m1/api-audit/announce-owner.md | 4 - quest/m1/api-audit/claim-request-type.md | 33 --------- quest/m1/api-audit/js-prepared.md | 4 - quest/m1/api-audit/requests.md | 6 +- rs/moq-net/MIGRATE.md | 2 +- 22 files changed, 156 insertions(+), 196 deletions(-) delete mode 100644 quest/m1/api-audit/claim-request-type.md diff --git a/js/net/MIGRATE.md b/js/net/MIGRATE.md index 696b79e8349..6ee89b6ee15 100644 --- a/js/net/MIGRATE.md +++ b/js/net/MIGRATE.md @@ -44,7 +44,7 @@ Treat a rejected group read as the loss of that group and keep reading the track Answer each `Origin.Request` with `request.prepare(epoch)` (a fresh `Epoch.mint()` per output run), fill `prepared.broadcast`, then `prepared.publish()`, which announces the broadcast at the exact path under that epoch and returns its producer. Only `publish` announces it: `prepared.broadcast.announce()` throws. Call `prepared.reject(err)` to refuse instead; JS has no destructor, so an abandoned `Prepared` holds its request open. -`Dynamic.update` and `Request.accept` are no longer part of the public API, and `accept` throws on a request from `dynamic`. +`Dynamic.update` and `Request.accept` are gone: only the built-in sessions serve a broadcast they already hold, and an application answers through `prepare`. Close the claim and claim again to change it, and close an answer's producer to end that output. ### `largest()` reports within a reader's `setGroups` bounds diff --git a/js/net/bench/broadcasts.ts b/js/net/bench/broadcasts.ts index e491fe7e48b..e500038b24d 100644 --- a/js/net/bench/broadcasts.ts +++ b/js/net/bench/broadcasts.ts @@ -6,6 +6,7 @@ import { Producer as BroadcastProducer } from "../src/broadcast.ts"; import { BroadcastCache } from "../src/consume.ts"; import * as Epoch from "../src/epoch.ts"; import { HopSchema, Route } from "../src/hop.ts"; +import { hooks } from "../src/internal.ts"; import { AnnounceRequest } from "../src/lite/announce.ts"; import { Publisher } from "../src/lite/publisher.ts"; import { Subscriber } from "../src/lite/subscriber.ts"; @@ -252,7 +253,10 @@ for (const pathCount of routeCounts) { Array.from({ length: readerCount }, () => origin.consume().request(Path.from(`room/${index}`))), ).flat(); const broadcasts = Array.from({ length: pathCount }, () => new BroadcastProducer()); - for (const broadcast of broadcasts) (await pending.next()).value?.accept(broadcast); + for (const broadcast of broadcasts) { + const request = (await pending.next()).value; + if (request) hooks.acceptRequest(request, broadcast); + } await Promise.resolve(); const before = requests.map((request) => request.active.peek()); const start = performance.now(); diff --git a/js/net/bench/requests.ts b/js/net/bench/requests.ts index 32aa2666a3f..4afeb144fa2 100644 --- a/js/net/bench/requests.ts +++ b/js/net/bench/requests.ts @@ -10,6 +10,7 @@ import { SubscribeNamespaceEntryDone, } from "../src/ietf/subscribe_namespace"; import { ALPN, Version } from "../src/ietf/version"; +import { hooks } from "../src/internal"; import { createMockTransportPair } from "../src/mock"; import { Producer } from "../src/origin"; import * as Path from "../src/path"; @@ -111,7 +112,8 @@ for (const failovers of [0, 1, 16, 128]) const held = origin.request(path); const source = new Broadcast(); sources.push(source); - (await ar.next()).value?.accept(source); + const answer = (await ar.next()).value; + if (answer) hooks.acceptRequest(answer, source); await settle(); entries.push({ primary, ar, br, held }); } @@ -148,7 +150,7 @@ for (const failovers of [0, 1, 16, 128]) for (let i = 0; i < entries.length; i++) { const source = new Broadcast(); sources.push(source); - pending[i].accept(source); + hooks.acceptRequest(pending[i], source); await settle(); if (!readsFrom(entries[i].held.active.peek(), source)) throw new Error("lost failover"); } diff --git a/js/net/src/connection/forward.ts b/js/net/src/connection/forward.ts index 684482c7977..7a3a91ab78b 100644 --- a/js/net/src/connection/forward.ts +++ b/js/net/src/connection/forward.ts @@ -8,7 +8,7 @@ import { Milli, Nano } from "@moq/clock"; import { type Dispose, race } from "@moq/signals"; import type * as Announce from "../announced.ts"; import { type Route, routesEqual } from "../hop.ts"; -import { coveringPrefixes } from "../internal.ts"; +import { coveringPrefixes, hooks } from "../internal.ts"; import type { Dynamic, Producer as OriginProducer, RequestSlot } from "../origin.ts"; import * as Path from "../path.ts"; import { after } from "../util/timer.ts"; @@ -276,7 +276,7 @@ async function drive(handle: Dynamic, conn: Established): Promise { const session = wireOf(conn); try { for await (const request of handle.requested()) { - request.accept(session.consume(request.path)); + hooks.acceptRequest(request, session.consume(request.path)); } } catch { handle.close(); diff --git a/js/net/src/ietf/publisher.test.ts b/js/net/src/ietf/publisher.test.ts index 35ef3a24553..628797cae6a 100644 --- a/js/net/src/ietf/publisher.test.ts +++ b/js/net/src/ietf/publisher.test.ts @@ -7,6 +7,7 @@ import * as Epoch from "../epoch.ts"; import { error, ProtocolViolation, SessionCode, SessionError, StreamCode, unauthorized } from "../error.ts"; import { Producer as GroupProducer, MAX_GROUP_FRAMES } from "../group.ts"; import { type Hop, HopSchema } from "../hop.ts"; +import { hooks } from "../internal.ts"; import { createMockTransportPair } from "../mock.ts"; import { Producer as OriginProducer } from "../origin.ts"; import * as Path from "../path.ts"; @@ -429,7 +430,7 @@ test("a cancelled TRACK_STATUS releases its demand on every draft", async () => expect(early.written).toEqual([]); // Withdrawn, so a late answer is closed rather than served. const answer = new BroadcastProducer().consume(); - pending?.accept(answer); + hooks.acceptRequest(pending, answer); expect(answer.closed.peek()).toBeNull(); // The broadcast resolves, but its track is never answered. @@ -439,7 +440,7 @@ test("a cancelled TRACK_STATUS releases its demand on every draft", async () => if (!request) throw new Error("no request for room/late"); const served = new BroadcastProducer(); const trackRequest = wireOf(served).requested(); - request.accept(served); + hooks.acceptRequest(request, served); expect((await trackRequest)?.name).toBe("video"); expect(served.demand().used.peek()).toBe(true); late.cancel(); diff --git a/js/net/src/integration.test.ts b/js/net/src/integration.test.ts index a82ce591650..2698a8135ea 100644 --- a/js/net/src/integration.test.ts +++ b/js/net/src/integration.test.ts @@ -19,6 +19,7 @@ import { Parameters } from "./ietf/parameters.ts"; import { Publisher as IetfPublisher } from "./ietf/publisher.ts"; import { RequestOk } from "./ietf/request.ts"; import { SubscribeTracks } from "./ietf/subscribe_tracks.ts"; +import { hooks } from "./internal.ts"; import * as Lite from "./lite/index.ts"; import { Publisher as LitePublisher } from "./lite/publisher.ts"; import { createMockTransportPair, textFrame } from "./mock.ts"; @@ -2737,7 +2738,7 @@ test("a handle serves a request under live/** over the wire", async () => { for await (const request of handle.requested()) { const produced = new BroadcastProducer(); produced.createTrack("chat", { timescale: Timescale.MILLI }).writeFrame(textFrame("hello")); - request.accept(produced); + hooks.acceptRequest(request, produced); } })(); diff --git a/js/net/src/internal.ts b/js/net/src/internal.ts index 4b9a02bd2f2..0a4b6d3b5c3 100644 --- a/js/net/src/internal.ts +++ b/js/net/src/internal.ts @@ -14,6 +14,7 @@ import type * as Epoch from "./epoch.ts"; import { toStreamCode } from "./error.ts"; import type { Frame, Consumer as GroupConsumer, Producer as GroupProducer } from "./group.ts"; import { isAnonymous, type Route } from "./hop.ts"; +import type { Request as OriginRequest } from "./origin.ts"; import * as Path from "./path.ts"; import type { Timestamp } from "./time.ts"; import type { Groups, Info, Location, Producer, Request, Subscriber, Subscription } from "./track.ts"; @@ -562,6 +563,11 @@ export const hooks: { stampEpoch: (target: BroadcastConsumer, epoch: Epoch.Valid | undefined) => void; /** Mark a broadcast handle served through a route claiming a shorter prefix: it lists nothing. */ stampUnlisted: (target: BroadcastConsumer) => void; + /** + * Resolve a session-forwarded request with its peer's routed broadcast. An application + * claim answers through `prepare` and `publish` instead. Assigned by `origin.ts`. + */ + acceptRequest: (request: OriginRequest, source: BroadcastProducer | BroadcastConsumer) => void; } = { endTrack: () => { throw new Error("track.ts not loaded"); @@ -743,6 +749,9 @@ export const hooks: { stampUnlisted: () => { throw new Error("broadcast.ts not loaded"); }, + acceptRequest: () => { + throw new Error("origin.ts not loaded"); + }, }; /** diff --git a/js/net/src/lite/publisher.test.ts b/js/net/src/lite/publisher.test.ts index 0af30562f78..078c7b33ccb 100644 --- a/js/net/src/lite/publisher.test.ts +++ b/js/net/src/lite/publisher.test.ts @@ -2497,7 +2497,7 @@ test("lite draft-06: a grant that shrinks while a TRACK resolves refuses it", as const request = await requests.next(); if (request.done) throw new Error("the handler never saw the request"); grant.set(grantOf("other")); - request.value.accept(produced); + hooks.acceptRequest(request.value, produced); await serving; expect(unauthorizedResets(resets)).toContain("unauthorized: live/cam"); @@ -2553,7 +2553,7 @@ test("lite draft-06: a grant that shrinks while TRACK_INFO is blocked resets it" const serving = publisher.runTrackInfo(new TrackMessage(Path.from("live/cam"), "video"), stream); const request = await requests.next(); if (request.done) throw new Error("the handler never saw the request"); - request.value.accept(produced); + hooks.acceptRequest(request.value, produced); await writing; grant.set(grantOf("other")); @@ -2602,7 +2602,7 @@ test("lite draft-06: a grant that shrinks while the broadcast resolves resets th if (request.done) throw new Error("the handler never saw the request"); grant.set(grantOf("other")); await Promise.resolve(); - request.value.accept(produced); + hooks.acceptRequest(request.value, produced); await serving; expect(unauthorizedResets(resets)).toContain("unauthorized: live/cam"); diff --git a/js/net/src/lite/tracks.test.ts b/js/net/src/lite/tracks.test.ts index 78d3fbc1901..f66cc0e62b6 100644 --- a/js/net/src/lite/tracks.test.ts +++ b/js/net/src/lite/tracks.test.ts @@ -7,6 +7,7 @@ import { accept, connect } from "../connection/index.ts"; import * as Epoch from "../epoch.ts"; import { fromTransport, NotFound, SessionCode, StreamCode, StreamError } from "../error.ts"; import { randomHop } from "../hop.ts"; +import { hooks } from "../internal.ts"; import { createMockTransportPair } from "../mock.ts"; import { Producer as OriginProducer } from "../origin.ts"; import * as Path from "../path.ts"; @@ -412,7 +413,7 @@ test("a path served through a covering prefix route is NOT_FOUND", async () => { for await (const request of dynamic.requested()) { const served = new BroadcastProducer(); served.createTrack("video"); - request.accept(served); + hooks.acceptRequest(request, served); } })(); const { list } = publisher(origin); @@ -429,7 +430,7 @@ test("a local read through a covering prefix route is NOT_FOUND, and through its for await (const request of dynamic.requested()) { const served = new BroadcastProducer(); served.createTrack("video"); - request.accept(served); + hooks.acceptRequest(request, served); } })(); } @@ -529,7 +530,7 @@ test("an epoch-pinned front served through a covering prefix route is NOT_FOUND" for await (const request of dynamic.requested()) { const served = new BroadcastProducer(); served.createTrack("video"); - request.accept(served); + hooks.acceptRequest(request, served); } })(); const request = origin.consume().request(Path.from("room/alice")); diff --git a/js/net/src/origin-demand.test.ts b/js/net/src/origin-demand.test.ts index 887af3e9288..89d2f5e207c 100644 --- a/js/net/src/origin-demand.test.ts +++ b/js/net/src/origin-demand.test.ts @@ -6,6 +6,7 @@ import { NativeSession } from "./ietf/adapter.ts"; import { Publisher as IetfPublisher } from "./ietf/publisher.ts"; import { Subscribe as IetfSubscribe } from "./ietf/subscribe.ts"; import { Version as IetfVersion } from "./ietf/version.ts"; +import { hooks } from "./internal.ts"; import { Fetch } from "./lite/fetch.ts"; import { Publisher as LitePublisher } from "./lite/publisher.ts"; import { Subscribe as LiteSubscribe } from "./lite/subscribe.ts"; @@ -128,11 +129,11 @@ test("last cancellation finishes the internal wait and discards a late consumer" const keeper = source.consume(); const late = source.consume(); const close = spyOn(late, "close"); - old.accept(late); + hooks.acceptRequest(old, late); expect(close).toHaveBeenCalledTimes(1); close.mockRestore(); expect(source.closed.peek()).toBeUndefined(); - fresh.accept(source); + hooks.acceptRequest(fresh, source); await settle(); expect(next.active.peek()?.closed).toBe(source.closed); next.close(); @@ -171,7 +172,7 @@ test.each([true, false])( await settle(); const late = new Broadcast(); const front = late.consume(); - old.accept(front); + hooks.acceptRequest(old, front); expect(late.closed.peek()).toBeNull(); const next = origin.request(path); const fresh = (await requests.next()).value; @@ -210,7 +211,7 @@ test.each([ try { hold.abort(); await first; - request.accept(source); + hooks.acceptRequest(request, source); await settle(); expect(other === "peer" ? (await second)?.closed : local?.active.peek()?.closed).toBe(source.closed); } finally { @@ -257,7 +258,7 @@ test.each(["accept", "reject", "cancel"] as const)( const request = (await requests.next()).value; if (!request) throw new Error("expected a pending request"); try { - if (action === "accept") request.accept(source); + if (action === "accept") hooks.acceptRequest(request, source); if (action === "reject") request.reject(new Error("denied")); if (action === "cancel") hold.abort(); const result = await wait; @@ -265,9 +266,9 @@ test.each(["accept", "reject", "cancel"] as const)( hold.abort(); if (action === "accept") { expect(result?.closed).toBe(source.closed); - request.accept(source); + hooks.acceptRequest(request, source); request.reject(new Error("late")); - } else request.accept(source); + } else hooks.acceptRequest(request, source); expect(source.closed.peek()).toBeUndefined(); } finally { remove.mockRestore(); @@ -295,9 +296,9 @@ test("a pending local request changing routes withdraws the old route's work", a const late = source.consume(); const close = spyOn(late, "close"); try { - old.accept(late); + hooks.acceptRequest(old, late); expect(close).toHaveBeenCalledTimes(1); - fresh.accept(source); + hooks.acceptRequest(fresh, source); await settle(); expect(local.active.peek()?.closed).toBe(source.closed); } finally { @@ -331,7 +332,7 @@ test("cancelling an old epoch waiter does not decrement a new epoch's demand", a const source = new Broadcast(); try { old.reject(new Error("obsolete")); - fresh.accept(source); + hooks.acceptRequest(fresh, source); expect((await after)?.closed).toBe(source.closed); } finally { origin.close(); @@ -352,7 +353,7 @@ test.each(["answer", "release"] as const)( const br = b.requested(); const held = origin.request(path); const source = new Broadcast(); - (await ar.next()).value?.accept(source); + hooks.acceptRequest((await ar.next()).value, source); await settle(); // Another instance wins new requests. The old handle stays on its own epoch. const c = origin.dynamicRoute(prefix, { epoch: Epoch.mint(), cost: 0n }); @@ -377,7 +378,7 @@ test.each(["answer", "release"] as const)( const front = late.consume(); const close = spyOn(front, "close"); try { - pending.accept(front); + hooks.acceptRequest(pending, front); await settle(); if (action === "release") expect(close).toHaveBeenCalledTimes(1); else { @@ -412,7 +413,7 @@ test.each([0, 1])( const held = origin.request(path); const source = new Broadcast(); sources.push(source); - (await ar.next()).value?.accept(source); + hooks.acceptRequest((await ar.next()).value, source); await settle(); entries.push({ primary, ar, br, held }); } @@ -436,9 +437,9 @@ test.each([0, 1])( // The slot is still detached until its microtask runs, but no longer owns demand. entries[released].held.close(); hold.abort(); - pending[released].accept(front); + hooks.acceptRequest(pending[released], front); expect(close).toHaveBeenCalledTimes(1); - pending[1 - released].accept(replacement); + hooks.acceptRequest(pending[1 - released], replacement); await wait; expect(readsFrom(entries[1 - released].held.active.peek(), replacement)).toBe(true); } finally { @@ -511,7 +512,7 @@ test.each(["accept", "reject"] as const)( const denied = new Error("denied"); try { hold.abort(cancelled); - if (answer === "accept") request.accept(source); + if (answer === "accept") hooks.acceptRequest(request, source); else request.reject(denied); expect(await first).toBe(cancelled); const result = await second; @@ -541,7 +542,7 @@ test.each([true, false])("last waiter cancellation and accept in one turn (cance const close = spyOn(front, "close"); try { if (cancelFirst) hold.abort(cancelled); - request.accept(front); + hooks.acceptRequest(request, front); if (!cancelFirst) hold.abort(cancelled); expect(await wait).toBe(cancelled); expect(close).toHaveBeenCalledTimes(cancelFirst ? 1 : 0); @@ -551,7 +552,7 @@ test.each([true, false])("last waiter cancellation and accept in one turn (cance const fresh = (await requests.next()).value; expect(fresh).not.toBe(request); if (!fresh) throw new Error("expected fresh request"); - fresh.accept(source); + hooks.acceptRequest(fresh, source); } const answer = await next; expect(answer?.closed).toBe(source.closed); @@ -586,7 +587,7 @@ test.each([true, false])("local demand survives same-turn cancellation only when const replacement = rejoin ? origin.request(path) : undefined; try { await settle(); - request.accept(front); + hooks.acceptRequest(request, front); expect(close).toHaveBeenCalledTimes(rejoin ? 0 : 1); if (replacement) { await settle(); @@ -596,7 +597,7 @@ test.each([true, false])("local demand survives same-turn cancellation only when const next = (await requests.next()).value; expect(next).not.toBe(request); if (!next) throw new Error("expected fresh request"); - next.accept(source); + hooks.acceptRequest(next, source); await settle(); expect(fresh.active.peek()?.closed).toBe(source.closed); fresh.close(); diff --git a/js/net/src/origin-request.test.ts b/js/net/src/origin-request.test.ts index 4a92d9b7113..173cab1f6b6 100644 --- a/js/net/src/origin-request.test.ts +++ b/js/net/src/origin-request.test.ts @@ -2,7 +2,8 @@ import { expect, test } from "bun:test"; import { Producer as BroadcastProducer } from "./broadcast"; import * as Epoch from "./epoch"; import { Route } from "./hop"; -import { Producer } from "./origin"; +import { hooks } from "./internal.ts"; +import { Producer, type Request } from "./origin"; import * as Path from "./path"; import { wireOf } from "./wire"; @@ -142,7 +143,7 @@ test("announced requests follow the serving prefix, ignoring descendants and re- const keeper = source.consume(); const prefix = origin.dynamicRoute(Path.from("pool")); const serving = (async () => { - for await (const request of prefix.requested()) request.accept(source.consume()); + for await (const request of prefix.requested()) hooks.acceptRequest(request, source.consume()); })(); const request = origin.request(Path.from("pool/job"), { announced: true }); await settle(); @@ -195,7 +196,7 @@ test("an announced request follows an epochless route replaced by an identical o const serve = (source: BroadcastProducer) => { const route = origin.dynamicRoute(path); void (async () => { - for await (const request of route.requested()) request.accept(source.consume()); + for await (const request of route.requested()) hooks.acceptRequest(request, source.consume()); })(); return route; }; @@ -226,7 +227,7 @@ test("an origin closing settles an announced request a served route resolved", a const source = new BroadcastProducer(); const route = origin.dynamicRoute(path); void (async () => { - for await (const request of route.requested()) request.accept(source.consume()); + for await (const request of route.requested()) hooks.acceptRequest(request, source.consume()); })(); const request = origin.request(path, { announced: true }); await settle(); @@ -276,7 +277,7 @@ for (const received of [false, true]) { const source = new BroadcastProducer(); const route = received ? wireOf(origin).receive(path, Route.default) : origin.dynamicRoute(path); void (async () => { - for await (const request of route.requested()) request.accept(source.consume()); + for await (const request of route.requested()) hooks.acceptRequest(request, source.consume()); })(); const request = origin.request(path); @@ -341,14 +342,15 @@ test("a prepared answer cannot announce itself before publish", async () => { origin.close(); }); -test("an application claim refuses an arbitrary broadcast", async () => { +test("an application claim cannot accept an arbitrary broadcast", async () => { const origin = new Producer(); const claim = origin.dynamic(Path.from("room")); const requests = claim.requested(); const request = origin.request(Path.from("room/alice")); - const pending = (await requests.next()).value; + const pending: Request | undefined = (await requests.next()).value; if (!pending) throw new Error("expected a request"); - expect(() => pending.accept(new BroadcastProducer())).toThrow("prepared and published"); + // @ts-expect-error Only sessions accept a request; an application prepares and publishes. + expect(pending.accept).toBeUndefined(); request.close(); claim.close(); origin.close(); diff --git a/js/net/src/origin.test.ts b/js/net/src/origin.test.ts index e266ffa6733..f7c059208a9 100644 --- a/js/net/src/origin.test.ts +++ b/js/net/src/origin.test.ts @@ -6,7 +6,7 @@ import * as Epoch from "./epoch.ts"; import { StreamCode, StreamError } from "./error.ts"; import { Producer as GroupProducer } from "./group.ts"; import { HopSchema, Route } from "./hop.ts"; -import { spreadHash } from "./internal.ts"; +import { hooks, spreadHash } from "./internal.ts"; import type { Consumer, Table } from "./origin.ts"; import { Preference, Producer } from "./origin.ts"; import * as Path from "./path.ts"; @@ -31,7 +31,7 @@ function serve(origin: Producer, prefix: Path.Valid, consume: () => BroadcastCon void (async () => { try { for await (const request of handle.requested()) { - request.accept(consume()); + hooks.acceptRequest(request, consume()); } } catch { handle.close(); @@ -1267,7 +1267,7 @@ test("a handle serves a request under live", async () => { const produced = new BroadcastProducer(); produced.createTrack("video", { timescale: Timescale.MILLI }); - req?.accept(produced); + hooks.acceptRequest(req, produced); await settle(); expect(request.active.peek()).toBeDefined(); @@ -1292,11 +1292,11 @@ test("an originated route refuses a broadcast a session delivered", async () => // What lite and IETF do when a session hands back a consumed broadcast. const foreign = new BroadcastProducer().consume(); overrideBroadcastWire(foreign, {}); - expect(() => req.accept(foreign)).toThrow("origin cannot serve a broadcast it did not produce"); + expect(() => hooks.acceptRequest(req, foreign)).toThrow("origin cannot serve a broadcast it did not produce"); const produced = new BroadcastProducer(); produced.createTrack("video"); - req.accept(produced.consume()); + hooks.acceptRequest(req, produced.consume()); expect(await pending).toBeDefined(); handle.close(); @@ -1347,7 +1347,7 @@ test("an accepted dynamic broadcast is retired when it closes", async () => { const first = await it.next(); const produced = new BroadcastProducer(); produced.createTrack("video", { timescale: Timescale.MILLI }); - first.value?.accept(produced); + hooks.acceptRequest(first.value, produced); await settle(); expect(request.active.peek()?.track("video").subscribe()).toBeDefined(); @@ -1359,7 +1359,7 @@ test("an accepted dynamic broadcast is retired when it closes", async () => { expect(second.value?.path).toBe(path); const replacement = new BroadcastProducer(); replacement.createTrack("video", { timescale: Timescale.MILLI }); - second.value?.accept(replacement); + hooks.acceptRequest(second.value, replacement); await settle(); expect(request.active.peek()?.track("video").subscribe()).toBeDefined(); @@ -1397,7 +1397,7 @@ test("a peer is offered and served the cheapest originated route", async () => { const pending = wireOf(consumer).demand(Path.from("live/cam")); const { value: req } = await cheap.requested().next(); const upstream = new BroadcastProducer(); - req?.accept(upstream.consume()); + hooks.acceptRequest(req, upstream.consume()); expect(await pending).toBeDefined(); // An exact-path local broadcast competes with them on cost too. @@ -1456,7 +1456,7 @@ test("a request refusal does not keep demand from asking again", async () => { const { value: second } = await it.next(); expect(second?.path).toBe(Path.from("live/cam")); const produced = new BroadcastProducer(); - second?.accept(produced); + hooks.acceptRequest(second, produced); await expect(pending).resolves.toBeDefined(); handle.close(); @@ -1480,7 +1480,7 @@ test("advancing requested without settling rejects the previous request", async await expect(firstDemand).rejects.toMatchObject({ code: StreamCode.Unroutable }); const produced = new BroadcastProducer(); - second.value?.accept(produced); + hooks.acceptRequest(second.value, produced); await expect(secondDemand).resolves.toBeDefined(); handle.close(); @@ -1547,7 +1547,7 @@ test("an outranked route keeps serving until its replacement answers", async () // Once it answers, the request's tracks read it, under the same broadcast. const replacement = new BroadcastProducer(); const { value: req } = await asked; - req?.accept(replacement.consume()); + hooks.acceptRequest(req, replacement.consume()); await settle(); expect(request.active.peek()).toBe(first); expect(readsFrom(first, replacement)).toBe(true); @@ -1883,7 +1883,7 @@ test("a refusal from a superseded route does not end the request", async () => { expect(request.closed.peek()).toBeUndefined(); const produced = new BroadcastProducer(); - req?.accept(produced); + hooks.acceptRequest(req, produced); await settle(); expect(request.active.peek()).toBeDefined(); @@ -1935,7 +1935,7 @@ test("close rejects queued requests as unroutable", async () => { expect((await handle.requested().next()).done).toBe(true); expect(request.active.peek()).toBeUndefined(); - req?.accept(new BroadcastProducer()); + hooks.acceptRequest(req, new BroadcastProducer()); await settle(); expect(request.active.peek()).toBeUndefined(); @@ -1976,7 +1976,7 @@ test("an equal-cost pool spreads its paths the same way on every node", async () void (async () => { for await (const request of handle.requested()) { served.set(request.path, hop); - request.accept(new BroadcastProducer()); + hooks.acceptRequest(request, new BroadcastProducer()); } })(); return handle; @@ -2015,7 +2015,7 @@ test("an equal-cost replacement at its own path wins whatever its hop", async () void (async () => { for await (const request of handle.requested()) { served.push(hop); - request.accept(new BroadcastProducer()); + hooks.acceptRequest(request, new BroadcastProducer()); } })(); return handle; @@ -2120,7 +2120,7 @@ test("a scoped dynamic only serves allowed paths and presents relative requests" await new Promise((resolve) => setImmediate(resolve)); expect(pending.value?.path).toBe(Path.from("room/alice/chat")); const broadcast = new BroadcastProducer(); - pending.value?.accept(broadcast); + hooks.acceptRequest(pending.value, broadcast); await new Promise((resolve) => setImmediate(resolve)); expect(request.active.peek()).toBeDefined(); dynamic.updateRoute({ cost: 2n }); @@ -2254,7 +2254,7 @@ test("broadcast handles carry the path they were created or requested at", async const request = origin.request(Path.from("live/bob")); const pending = await dynamic.requested().next(); const served = new BroadcastProducer(); - pending.value?.accept(served); + hooks.acceptRequest(pending.value, served); expect(request.active.peek()?.path).toBe(Path.from("live/bob")); clone?.close(); @@ -2301,12 +2301,12 @@ test("demand held across an epoch change asks again, unless it named the old epo // Another instance now serves the route: the old handler's answer is for the old one. handle.updateRoute({ ...handle.route, epoch: NEWER }); - req?.accept(new BroadcastProducer().consume()); + hooks.acceptRequest(req, new BroadcastProducer().consume()); await expect(pinned).rejects.toThrow("unroutable"); const next = new BroadcastProducer(); const track = next.createTrack("video"); - (await it.next()).value?.accept(next); + hooks.acceptRequest((await it.next()).value, next); const subscription = (await unpinned)?.track("video").subscribe(); track.writeGroup(new GroupProducer(7)); expect((await subscription?.recvGroup())?.sequence).toBe(7); @@ -2350,11 +2350,11 @@ test("demand held across an epoch round trip still asks again", async () => { // Released by the first change; the epoch coming back does not undo that. handle.updateRoute({ ...handle.route, epoch: NEWER }); handle.updateRoute({ ...handle.route, epoch: EPOCH }); - req?.accept(new BroadcastProducer().consume()); + hooks.acceptRequest(req, new BroadcastProducer().consume()); const next = new BroadcastProducer(); const track = next.createTrack("video"); - (await it.next()).value?.accept(next); + hooks.acceptRequest((await it.next()).value, next); const subscription = (await demanded)?.track("video").subscribe(); track.writeGroup(new GroupProducer(7)); expect((await subscription?.recvGroup())?.sequence).toBe(7); @@ -2443,14 +2443,14 @@ test("a request held across an epoch change asks again and never takes the old h const old = (await requests.next()).value; dynamic.updateRoute({ ...dynamic.route, epoch: NEWER }); const current = origin.consume().request(path, { epoch: NEWER }); - old?.accept(new BroadcastProducer().consume()); + hooks.acceptRequest(old, new BroadcastProducer().consume()); await settle(); expect(pinned.active.peek()).toBeUndefined(); expect(pinned.unroutable.peek()).toBe(true); expect(unpinned.active.peek()).toBeUndefined(); expect(current.active.peek()).toBeUndefined(); const next = (await requests.next()).value; - next?.accept(new BroadcastProducer().consume()); + hooks.acceptRequest(next, new BroadcastProducer().consume()); await settle(); expect(unpinned.active.peek()?.epoch).toBe(NEWER); expect(current.active.peek()?.epoch).toBe(NEWER); @@ -2479,7 +2479,7 @@ test.each([ const held = origin.consume().request(path); const old = new BroadcastProducer(); const oldTrack = old.createTrack("video"); - (await requests.next()).value?.accept(old); + hooks.acceptRequest((await requests.next()).value, old); await settle(); const resolved = held.active.peek(); expect(resolved?.epoch).toBe(from); @@ -2518,7 +2518,7 @@ test.each([ const demanded = wireOf(origin.consume()).demand(path); const next = new BroadcastProducer(); const nextTrack = next.createTrack("video"); - (await requests.next()).value?.accept(next); + hooks.acceptRequest((await requests.next()).value, next); await settle(); expect(fresh.active.peek()?.epoch).toBe(to); const subscription = fresh.active.peek()?.track("video").subscribe(); @@ -2559,7 +2559,7 @@ test("an epoch update does not retain an answer that already closed", async () = const requests = dynamic.requested(); const held = origin.consume().request(path); const old = new BroadcastProducer(); - (await requests.next()).value?.accept(old); + hooks.acceptRequest((await requests.next()).value, old); old.close(); // The server's closure callback has not removed the answer yet. dynamic.updateRoute({ epoch: NEWER }); @@ -2568,7 +2568,7 @@ test("an epoch update does not retain an answer that already closed", async () = expect(held.active.peek()).toBeUndefined(); const fresh = origin.consume().request(path); const next = new BroadcastProducer(); - (await requests.next()).value?.accept(next); + hooks.acceptRequest((await requests.next()).value, next); expect(fresh.active.peek()?.epoch).toBe(NEWER); fresh.close(); next.close(); @@ -2587,7 +2587,7 @@ test("an unread resolved request retains its broadcast until its last handle clo const requests = dynamic.requested(); const held = origin.consume().request(path); const old = new BroadcastProducer(); - (await requests.next()).value?.accept(old); + hooks.acceptRequest((await requests.next()).value, old); // Change epochs before the active getter or its subscription can clone the answer. dynamic.updateRoute({ epoch: NEWER }); expect(old.closed.peek()).toBeUndefined(); @@ -2610,14 +2610,14 @@ test("a session's re-request after an epoch update asks the handler again", asyn const before = wire.demand(path); const old = new BroadcastProducer(); - (await requests.next()).value?.accept(old); + hooks.acceptRequest((await requests.next()).value, old); expect(await before).toBeDefined(); dynamic.updateRoute({ ...dynamic.route, epoch: NEWER }); const after = wire.demand(path); const next = new BroadcastProducer(); const track = next.createTrack("video"); - (await requests.next()).value?.accept(next); + hooks.acceptRequest((await requests.next()).value, next); const subscription = (await after)?.track("video").subscribe(); track.writeGroup(new GroupProducer(7)); expect((await subscription?.recvGroup())?.sequence).toBe(7); @@ -2641,7 +2641,7 @@ test("an update does not override route selection: another claim at the epoch ke const held = origin.consume().request(path); const answer = new BroadcastProducer(); - (await firstRequests.next()).value?.accept(answer); + hooks.acceptRequest((await firstRequests.next()).value, answer); await settle(); expect(held.active.peek()?.epoch).toBe(EPOCH); @@ -2651,7 +2651,7 @@ test("an update does not override route selection: another claim at the epoch ke const fresh = origin.consume().request(path); const replacement = new BroadcastProducer(); const track = replacement.createTrack("video"); - (await secondRequests.next()).value?.accept(replacement); + hooks.acceptRequest((await secondRequests.next()).value, replacement); await settle(); expect(held.active.peek()?.epoch).toBe(EPOCH); expect(held.unroutable.peek()).toBe(false); diff --git a/js/net/src/origin.ts b/js/net/src/origin.ts index c1160f4b679..9165cab71be 100644 --- a/js/net/src/origin.ts +++ b/js/net/src/origin.ts @@ -1305,7 +1305,7 @@ export class Producer implements Table { return this.#insertRoute(prefix, Route.default, true, true); } - /** Insert an originated claim with route metadata that answers through `accept`, for tests and benches. @internal */ + /** Insert an originated claim with route metadata that answers through `hooks.acceptRequest`, for tests and benches. @internal */ dynamicRoute( prefix: Path.Valid, route: @@ -2354,9 +2354,9 @@ export class Dynamic { /** * A pending request for a broadcast to be served on demand. * - * Yielded by {@link Dynamic.requested}. {@link accept} resolves it with a live - * broadcast; {@link reject} resolves it with an error. Advancing the iterator or - * closing it without either rejects the request. + * Yielded by {@link Dynamic.requested}. {@link prepare} then {@link Prepared.publish} + * resolves it with a new exact-path broadcast; {@link reject} resolves it with an error. + * Advancing the iterator or closing it without either rejects the request. * * @public */ @@ -2382,6 +2382,7 @@ export class Request { request.#withdrawn = true; void err; }; + hooks.acceptRequest = (request, source) => request.#accept(source); } /** Start answering as a new publisher instance, hidden until published. */ @@ -2418,27 +2419,18 @@ export class Request { this.#server.reject(this, err); } - /** - * Resolve a session-forwarded request with its peer's routed broadcast. - * - * The caller keeps producing into `broadcast`; repeat requests for the path share - * it for as long as it stays live. - * - * A JS app does not proxy: a broadcast a session delivered would go out labeled with - * this origin's hop. To serve upstream content, copy its tracks into a prepared answer - * and publish it. - * - * @throws Error when handed a broadcast a session delivered. The request stays open. - * @internal - */ - accept(source: broadcast.Producer | broadcast.Consumer): void { + // Resolve a session-forwarded request with its peer's routed broadcast, through + // `hooks.acceptRequest`. The caller keeps producing into `source`; repeat requests for + // the path share it for as long as it stays live. Throws when handed a broadcast a + // session delivered to an originated claim, leaving the request open: it would go out + // labeled with this origin's hop. + #accept(source: broadcast.Producer | broadcast.Consumer): void { if (this.#done) { // A withdrawn request takes no answer. Only the handed-off consumer is ours; // the application still owns its producer and any other consumers. if (this.#withdrawn && source instanceof broadcast.Consumer) source.close(); return; } - if (this.publisher) throw new Error("claim answers must be prepared and published"); if (this.#server.originated && !(source instanceof broadcast.Producer) && wireOf(source).fromSession) { throw new Error("origin cannot serve a broadcast it did not produce"); } diff --git a/js/net/src/resume.test.ts b/js/net/src/resume.test.ts index 44d6bebbedf..5b5d4d3c2f2 100644 --- a/js/net/src/resume.test.ts +++ b/js/net/src/resume.test.ts @@ -40,7 +40,7 @@ async function isPending(promise: Promise): Promise { function serve(origin: Producer, source: BroadcastProducer, cost: bigint) { const handle = wireOf(origin).receive(path, Route.normalize({ epoch: EPOCH, cost })); void (async () => { - for await (const request of handle.requested()) request.accept(source.consume()); + for await (const request of handle.requested()) hooks.acceptRequest(request, source.consume()); })(); return () => handle.close(); } @@ -823,7 +823,7 @@ test("between routes a reader keeps reading the copy it has", async () => { expect(drain(sub)).toEqual([1]); const { value: pending } = await asked.next(); - pending?.accept(b.consume()); + hooks.acceptRequest(pending, b.consume()); await settle(); await settle(); tb.write(1); diff --git a/js/net/src/untimed.test.ts b/js/net/src/untimed.test.ts index 921a0284739..3946c2f11b0 100644 --- a/js/net/src/untimed.test.ts +++ b/js/net/src/untimed.test.ts @@ -4,6 +4,7 @@ import { expect, test } from "bun:test"; import { type Consumer as BroadcastConsumer, Producer as BroadcastProducer } from "./broadcast.ts"; import { accept, connect } from "./connection/index.ts"; import * as Ietf from "./ietf/index.ts"; +import { hooks } from "./internal.ts"; import * as Lite from "./lite/index.ts"; import { createMockTransportPair } from "./mock.ts"; import { Producer as OriginProducer } from "./origin.ts"; @@ -133,7 +134,7 @@ test("a forwarded untimed track stays untimed", async () => { for await (const request of dynamic.requested()) { const input = wireOf(ingest).consume(request.path); inputs.push(input); - request.accept(await republish(input)); + hooks.acceptRequest(request, await republish(input)); } })(); diff --git a/js/watch/src/broadcast.test.ts b/js/watch/src/broadcast.test.ts index 6e1d34cdf19..0deccde227c 100644 --- a/js/watch/src/broadcast.test.ts +++ b/js/watch/src/broadcast.test.ts @@ -34,6 +34,16 @@ function broadcast(name: string, paths: string[] = [name]): { source: Broadcast; return { source, owner }; } +// Answer every request `claim` receives with a new exact broadcast, returning the answers. +function serve(claim: Origin.Dynamic, epoch?: Moq.Epoch.Valid): Moq.Broadcast.Producer[] { + const answers: Moq.Broadcast.Producer[] = []; + void (async () => { + for await (const request of claim.requested()) + answers.push(request.prepare(epoch ?? Moq.Epoch.mint()).publish()); + })(); + return answers; +} + function withoutWarnings(fn: () => T): T { const warn = console.warn; console.warn = () => {}; @@ -476,13 +486,12 @@ describe("blind resolution", () => { await settle(); - // Stand in for a dynamic route answering the request. - const upstream = new Moq.Broadcast.Producer(); - const route = owner.dynamicRoute(Path.from("blind.hang")); + // Stand in for a claim answering the request. + const route = owner.dynamic(Path.from("blind.hang")); const requests = route.requested(); const next = await requests.next(); - expect(next.done).toBe(false); - next.value?.accept(upstream); + if (!next.value) throw new Error("expected a request"); + const upstream = next.value.prepare(Moq.Epoch.mint()).publish(); await settle(); const active = source.out.active.peek(); @@ -495,6 +504,7 @@ describe("blind resolution", () => { expect(upstream.closed.peek()).toBeUndefined(); source.close(); + upstream.close(); route.close(); owner.close(); await settle(); @@ -590,18 +600,14 @@ describe("restart", () => { it("moves to a more specific announcement of another instance", async () => { const owner = new Origin.Producer(); const name = Path.from("pool/job.hang"); - const pool = owner.dynamicRoute(Path.from("pool"), { epoch: Moq.Epoch.mint() }); - const requests = pool.requested(); - const upstream = new Moq.Broadcast.Producer(); - void (async () => { - for await (const request of requests) request.accept(upstream); - })(); + const pool = owner.dynamic(Path.from("pool")); + const answers = serve(pool); const source = new Broadcast({ origin: owner, name, enabled: true, announced: true, catalogFormat: "manual" }); await settle(); const old = source.out.active.peek(); expect(old).toBeDefined(); - // A newer publisher announces the exact path, which now serves the name: a restart. + // A newer publisher announces the path, which now serves the name: a restart. const exact = owner.createBroadcast(name); exact.announce({ epoch: Moq.Epoch.mint() }); await settle(); @@ -616,7 +622,7 @@ describe("restart", () => { expect(source.out.error.peek()).toBeUndefined(); exact.close(); - upstream.close(); + for (const answer of answers) answer.close(); source.close(); owner.close(); await settle(); @@ -633,22 +639,18 @@ describe("restart", () => { const old = source.out.active.peek(); expect(old).toBeDefined(); - // The exact route goes, which ends the request on it, and the prefix arrives under the same + // The exact route goes, which ends the request on it, and a prefix answers under the same // epoch before the table is read again. The player still has to ask again. exact.close(); - const pool = owner.dynamicRoute(Path.from("pool"), { epoch }); - const requests = pool.requested(); - const upstream = new Moq.Broadcast.Producer(); - void (async () => { - for await (const request of requests) request.accept(upstream); - })(); + const pool = owner.dynamic(Path.from("pool")); + const answers = serve(pool, epoch); await settle(); const resumed = source.out.active.peek(); expect(resumed).toBeDefined(); expect(resumed).not.toBe(old); expect(source.out.error.peek()).toBeUndefined(); - upstream.close(); + for (const answer of answers) answer.close(); pool.close(); source.close(); owner.close(); @@ -658,12 +660,8 @@ describe("restart", () => { it("falls back to a covering prefix when the exact route ends", async () => { const owner = new Origin.Producer(); const name = Path.from("pool/job.hang"); - const pool = owner.dynamicRoute(Path.from("pool")); - const requests = pool.requested(); - const upstream = new Moq.Broadcast.Producer(); - void (async () => { - for await (const request of requests) request.accept(upstream); - })(); + const pool = owner.dynamic(Path.from("pool")); + const answers = serve(pool); const exact = publish(owner, name); const source = new Broadcast({ origin: owner, name, enabled: true, announced: true, catalogFormat: "manual" }); await settle(); @@ -680,11 +678,11 @@ describe("restart", () => { // Nothing covers the name any more: offline, not an error. pool.close(); + for (const answer of answers) answer.close(); await settle(); expect(source.out.active.peek()).toBeUndefined(); expect(source.out.error.peek()).toBeUndefined(); - upstream.close(); source.close(); owner.close(); await settle(); @@ -695,18 +693,16 @@ describe("refusal", () => { it("reports an unroutable refusal of a restarted publisher", async () => { const owner = new Origin.Producer(); const name = Path.from("room/live.hang"); - const upstream = new Moq.Broadcast.Producer(); - const first = owner.dynamicRoute(Path.from("room"), { epoch: Moq.Epoch.mint() }); - const firstRequests = first.requested(); - void (async () => { - for await (const request of firstRequests) request.accept(upstream); - })(); + const first = owner.dynamic(Path.from("room")); + const answers = serve(first); const source = new Broadcast({ origin: owner, name, enabled: true, announced: true, catalogFormat: "manual" }); await settle(); expect(source.out.active.peek()).toBeDefined(); - // The restarted publisher refuses with the same code an ended instance reports. - const second = owner.dynamicRoute(Path.from("room"), { epoch: Moq.Epoch.mint() }); + // The publisher restarts, and refuses with the same code an ended instance reports. + first.close(); + for (const answer of answers) answer.close(); + const second = owner.dynamic(Path.from("room")); const refused = await second.requested().next(); refused.value?.reject(new Moq.Error.Stream(Moq.StreamCode.Unroutable, { message: "refused" })); await settle(); @@ -714,8 +710,6 @@ describe("refusal", () => { expect(source.out.status.peek()).toBe("error"); second.close(); - first.close(); - upstream.close(); source.close(); owner.close(); await settle(); @@ -744,7 +738,7 @@ describe("refusal", () => { for (const announced of [true, false]) { it(`reports a refusal until a fresh request (announced: ${announced})`, async () => { const owner = new Origin.Producer(); - const route = owner.dynamicRoute(Path.from("room")); + const route = owner.dynamic(Path.from("room")); const requests = route.requested(); const name = new Signal(Path.from("room/refused.hang")); const enabled = new Signal(true); @@ -1026,7 +1020,7 @@ describe("wildcard renditions", () => { await settle(); expect(videoRenditions(source)).toEqual(["source"]); - const worker = owner.dynamicRoute(Path.from(".pro/transcode")); + const worker = owner.dynamic(Path.from(".pro/transcode")); const requests = worker.requested(); await settle(); expect(videoRenditions(source)).toEqual(["source", "transcode"]); @@ -1040,25 +1034,22 @@ describe("wildcard renditions", () => { expect(request?.path).toBe(derived); expect(active).toBeUndefined(); - const produced = new Moq.Broadcast.Producer(); - produced + if (!request) throw new Error("expected a request"); + const prepared = request.prepare(Moq.Epoch.mint()); + prepared.broadcast .createTrack("video", { timescale: Moq.Time.Timescale.MILLI }) .writeFrame({ payload: new TextEncoder().encode("frame"), timestamp: Moq.Time.Timestamp.now() }); - request?.accept(produced); + const produced = prepared.publish(); await settle(); expect(active).toBeDefined(); const track = active?.track("video").subscribe().ordered(); expect(await track?.readString()).toBe("frame"); track?.close(); - // The worker announcing the path it now serves leaves the rendition where it was. - const concrete = owner.createBroadcast(derived); - concrete.announce(); - await settle(); + // The answer announcing the path it now serves leaves the rendition where it was. expect(videoRenditions(source)).toEqual(["source", "transcode"]); - expect(active).toBeDefined(); - concrete.close(); + produced.close(); worker.close(); } finally { effect.close(); @@ -1075,8 +1066,8 @@ describe("wildcard renditions", () => { try { // A catch-all (an archive) and a narrower pool both cover the derived path. - const archive = owner.dynamicRoute(Path.empty()); - const worker = owner.dynamicRoute(Path.from(".pro/transcode")); + const archive = owner.dynamic(Path.empty()); + const worker = owner.dynamic(Path.from(".pro/transcode")); await settle(); expect(videoRenditions(source)).toEqual(["source", "transcode"]); diff --git a/quest/m1/api-audit/README.md b/quest/m1/api-audit/README.md index c2ff4761432..4f68511c2d4 100644 --- a/quest/m1/api-audit/README.md +++ b/quest/m1/api-audit/README.md @@ -89,7 +89,6 @@ Rejected in planning: - [Relay config](/quest/m1/api-audit/relay-config.md) - moq-relay and its embedders load config through one path that refuses old spellings, under names that do not collide - [Auth and clock names](/quest/m1/api-audit/auth-clock-names.md) - refusal and reason types have distinct names, and the Rust and JS clocks mirror each other - [Session-only items stay private](/quest/m1/api-audit/model-private.md) - model items only the built-in sessions use are private -- [Only sessions accept a request](/quest/m1/api-audit/claim-request-type.md) - accepting an application claim's request fails to compile, and the peer-route hatches are crate-private - [One range setter](/quest/m1/api-audit/serve.md) - `with_range` replaces seven cursor setters and nothing else of the serving cursor goes public - [A request's source enforces its validity](/quest/m1/api-audit/answer.md) - a stale source refuses new track requests by itself, with no public additions - [moq-transport stays private](/quest/m1/api-audit/ietf-private.md) - no public moq-net item exists only for moq-transport, and a client names its request target once as a URL @@ -99,7 +98,6 @@ Rejected in planning: ## Related -- [Claim request type](/quest/m1/api-audit/claim-request-type.md) - removes `accept` from application requests; [the origin announces](/quest/m1/api-audit/announce-owner.md) narrows `Prepared` beside it - [Readers follow restarts](/quest/m1/sink-restart-follow.md) - moves readers onto the follow operation that [two broadcast resolutions](/quest/m1/api-audit/resolve.md) names - [API-diff check on release PRs](/quest/m1/api-diff.md) - the backstop that catches breaks this epic misses - [Main is no slower than release](/quest/m1/release-perf/README.md) - the other release-readiness check diff --git a/quest/m1/api-audit/announce-owner.md b/quest/m1/api-audit/announce-owner.md index 28c7f9c6f79..5c4f27dad68 100644 --- a/quest/m1/api-audit/announce-owner.md +++ b/quest/m1/api-audit/announce-owner.md @@ -25,7 +25,3 @@ error. Public API: breaking in moq-net (and `@moq/net`/moq-ffi if they mirror the trap). Wire: none. - -## Related - -- [Claim request type](/quest/m1/api-audit/claim-request-type.md) - splits `accept` off application requests in the same file; coordinate the order diff --git a/quest/m1/api-audit/claim-request-type.md b/quest/m1/api-audit/claim-request-type.md deleted file mode 100644 index 07112d9ecaf..00000000000 --- a/quest/m1/api-audit/claim-request-type.md +++ /dev/null @@ -1,33 +0,0 @@ -# [S] Only sessions accept a request - -## Goal - -Answering an application claim's request with `accept` fails to compile in -Rust and to typecheck in TypeScript instead of panicking or throwing at -runtime. The peer-route hatches are crate-private. - -## Plan - -Decided 2026-10-21 (maintainer), after a downstream application's handler -called `request.accept(..)` on a `dynamic()` claim, which compiled and would -have panicked on the first request. Reshaped 2026-10-23 after the -public-model goal was dropped -([session-only items stay private](/quest/m1/api-audit/model-private.md)), -for [moq-dev/moq#6496](https://github.com/moq-dev/moq/pull/6496): - -- `origin::Producer::dynamic_route`, `origin::Dynamic::update_route`, and - `origin::Request::accept` become `pub(crate)` (JS `dynamicRoute`, - `updateRoute`, and `Request.accept` are already `@internal`). An - application answers with `prepare` then `publish`, or `reject`. -- Move the out-of-crate tests and benches that call them (moq-mux, moq-hls, - moq-cli, moq-gst, moq-c, moq-ffi, moq-net integration, js/watch) onto - `prepare`/`publish`. -- No `Forward`/`ForwardRequest` split: visibility already makes the call - unreachable. Delete the runtime assert and its test; a compile-fail - doctest and a `@ts-expect-error` line replace them. - -Kept from [moq-dev/moq#5678](https://github.com/moq-dev/moq/pull/5678): -prepare and publish, one epoch per answer, and the cold-start reset until -[announce takeover](/quest/m1/announce-takeover.md) lands. - -Public API: moq-net breaking (three hidden items go private). Wire: none. diff --git a/quest/m1/api-audit/js-prepared.md b/quest/m1/api-audit/js-prepared.md index 47810bb8681..e0835038c6f 100644 --- a/quest/m1/api-audit/js-prepared.md +++ b/quest/m1/api-audit/js-prepared.md @@ -30,7 +30,3 @@ Test: a handler that throws after `prepare()` leaves the path answerable by the next request. moq.pro has no JS claim handlers. Public API: `@moq/net` breaking (`Prepared.reject` to `close`). Wire: none. - -## Related - -- [Claim request type](/quest/m1/api-audit/claim-request-type.md) - the JS `accept` half lives there diff --git a/quest/m1/api-audit/requests.md b/quest/m1/api-audit/requests.md index fdad1be8405..8e75a362ec6 100644 --- a/quest/m1/api-audit/requests.md +++ b/quest/m1/api-audit/requests.md @@ -15,9 +15,8 @@ mechanism; `auth::Handle::requests() -> auth::Requests` is the precedent. `track::Request::dynamic()`) to `requests()`, and each `Dynamic` type to `Requests`. JS `dynamic()`/`Dynamic` mirror it. moq-ffi and its wrappers follow; the frozen hand-written moq-c ABI stays as it is. -- The peer-route hatches (`dynamic_route`, `update_route`) are crate-private - after [claim request type](/quest/m1/api-audit/claim-request-type.md); - rename them with the rest. +- Rename the crate-private peer-route hatches (`dynamic_route`, + `update_route`) with the rest. - Quests that say `Dynamic` (subscribe ranges, archive) switch to the new name in this PR. @@ -27,5 +26,4 @@ Public API: moq-net, `@moq/net`, moq-ffi and wrappers renamed. Wire: none. ## Related -- [Claim request type](/quest/m1/api-audit/claim-request-type.md) - reshapes the origin claim this renames - [Dynamic range requests](/quest/m1/subscribe-ranges/dynamic.md) - adds `requested_fill` to the track handle this renames diff --git a/rs/moq-net/MIGRATE.md b/rs/moq-net/MIGRATE.md index fb52968088d..053c8cdb50d 100644 --- a/rs/moq-net/MIGRATE.md +++ b/rs/moq-net/MIGRATE.md @@ -151,7 +151,7 @@ Treat a failed group read as the loss of that group and keep reading the track, Answer each `origin::Request` with `request.prepare(epoch)?` (a fresh `Epoch::mint()` per output run), fill `prepared.broadcast()`, then `prepared.publish()?`, which announces the broadcast at the exact path under that epoch and returns its producer. Only `publish` announces it: `prepared.broadcast().announce(..)` fails with `Error::Closed`. `Dynamic::update` is gone: drop the claim and claim again to change it, and close an answer's producer to end that output. -`Request::accept` is no longer part of the public API and panics on a request from `dynamic`. +`Request::accept`, `Producer::dynamic_route`, and `Dynamic::update_route` are gone: only the built-in sessions serve a broadcast they already hold or carry a peer's route metadata, and an application answers through `prepare`. Dropping a `Prepared` unpublished refuses the request, as dropping a `Request` does, and `publish` fails with `Error::Unroutable` once the claim went away. ### Anchors can name a time (#6074) From 63b2cd9c705347e744525770d2421a9c17c5948c Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 23 Oct 2026 17:38:35 -0700 Subject: [PATCH 09/15] style: format the reshaped gst test and origin bench Co-Authored-By: Claude Opus 5.5 --- rs/moq-gst/src/source/imp.rs | 4 +++- rs/moq-net/benches/origin.rs | 28 ++++++++++++++++++---------- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/rs/moq-gst/src/source/imp.rs b/rs/moq-gst/src/source/imp.rs index d18c042216c..a92856404f2 100644 --- a/rs/moq-gst/src/source/imp.rs +++ b/rs/moq-gst/src/source/imp.rs @@ -3462,7 +3462,9 @@ mod session_tests { }); let session = follow(&element, &origin); - let mut old = answered.recv_timeout(Duration::from_secs(5)).expect("the claim answered"); + let mut old = answered + .recv_timeout(Duration::from_secs(5)) + .expect("the claim answered"); let pad = recorder.written(&mut old, 1); let mut new = Publisher::new(&origin, epoch(), video_rendition()); diff --git a/rs/moq-net/benches/origin.rs b/rs/moq-net/benches/origin.rs index 6bb41eb1079..fb6286fbcdb 100644 --- a/rs/moq-net/benches/origin.rs +++ b/rs/moq-net/benches/origin.rs @@ -167,7 +167,9 @@ fn bench_withdraw(c: &mut Criterion) { .poll(moq_net::time::Instant::now(), &kio::Waiter::noop()) .unwrap(); let routes: Vec<_> = (0..publishers) - .map(|i| moq_net::fuzz::dynamic_route(&producer, format!("room/{i}"), peer_route(9, 1)).unwrap()) + .map(|i| { + moq_net::fuzz::dynamic_route(&producer, format!("room/{i}"), peer_route(9, 1)).unwrap() + }) .collect(); let mut cursors: Vec<_> = (0..subscribers).map(|_| producer.consume().announced()).collect(); for cursor in &mut cursors { @@ -221,10 +223,14 @@ fn bench_withdraw_wave(c: &mut Criterion) { .poll(moq_net::time::Instant::now(), &kio::Waiter::noop()) .unwrap(); let near: Vec<_> = (0..broadcasts) - .map(|i| moq_net::fuzz::dynamic_route(&producer, format!("room/{i}"), route(&[9, 2])).unwrap()) + .map(|i| { + moq_net::fuzz::dynamic_route(&producer, format!("room/{i}"), route(&[9, 2])).unwrap() + }) .collect(); let far: Vec<_> = (0..broadcasts) - .map(|i| moq_net::fuzz::dynamic_route(&producer, format!("room/{i}"), route(&[9, 3, 4])).unwrap()) + .map(|i| { + moq_net::fuzz::dynamic_route(&producer, format!("room/{i}"), route(&[9, 3, 4])).unwrap() + }) .collect(); let mut cursors: Vec<_> = (0..subscribers).map(|_| producer.consume().announced()).collect(); for cursor in &mut cursors { @@ -410,9 +416,12 @@ fn bench_announce_duplicate(c: &mut Criterion) { } b.iter(|| { - let handle = - moq_net::fuzz::dynamic_route(&producer, PATH, peer_route(duplicates as u64 + 1, INCUMBENT_COST - 1)) - .unwrap(); + let handle = moq_net::fuzz::dynamic_route( + &producer, + PATH, + peer_route(duplicates as u64 + 1, INCUMBENT_COST - 1), + ) + .unwrap(); for cursor in &mut cursors { cursor.next().now_or_never().flatten().expect("announce delivered"); } @@ -553,9 +562,7 @@ fn bench_serve_idle(c: &mut Criterion) { let (producer, _driver) = origin::Producer::new(origin::Config::default()); // One route per announced path, exactly as a session lands a peer's. let dynamics: Vec<_> = (0..routes) - .map(|i| { - producer.dynamic(format!(".stats/p{}/node/edge{i}", i % 8)).unwrap() - }) + .map(|i| producer.dynamic(format!(".stats/p{}/node/edge{i}", i % 8)).unwrap()) .collect(); b.iter(|| { // A fresh waiter per sweep, so every poll pays a real registration: a @@ -711,7 +718,8 @@ fn bench_unroutable_churn(c: &mut Criterion) { let (producer, mut driver) = origin::Producer::new(origin::Config::default()); let consumer = producer.consume(); let server = moq_net::fuzz::dynamic_route(&producer, "room", origin::Route::default()).unwrap(); - let unrelated = moq_net::fuzz::dynamic_route(&producer, "other", origin::Route::default()).unwrap(); + let unrelated = + moq_net::fuzz::dynamic_route(&producer, "other", origin::Route::default()).unwrap(); let mut waiting = Waiting::new( (0..requesters) .map(|i| { From 70161e898c91f332129ef0cfd30d6119afa15132 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 24 Oct 2026 21:27:06 -0700 Subject: [PATCH 10/15] fix: move main's new callers off the private claim hatches wide_hop, moq-hls rendition, moq-cli play media, and the moq-mux export bench answer or advertise through dynamic() or create_broadcast + announce. The moq-gst pending-request test refuses its stale claim request late instead of accepting a pathless broadcast. Co-Authored-By: Claude Opus 5.5 --- rs/moq-cli/src/play/media.rs | 2 +- rs/moq-gst/src/source/imp.rs | 11 ++++------- rs/moq-hls/src/export/rendition.rs | 4 ++-- rs/moq-mux/benches/export.rs | 15 ++++----------- rs/moq-net/tests/integration/wide_hop.rs | 2 +- 5 files changed, 12 insertions(+), 22 deletions(-) diff --git a/rs/moq-cli/src/play/media.rs b/rs/moq-cli/src/play/media.rs index ef520aaee95..1ea1566605d 100644 --- a/rs/moq-cli/src/play/media.rs +++ b/rs/moq-cli/src/play/media.rs @@ -1677,7 +1677,7 @@ mod tests { #[tokio::test(start_paused = true)] async fn a_refused_broadcast_ends_the_player() { let origin = moq_tokio::origin::spawn(); - let handler = origin.dynamic_route("", Default::default()).unwrap(); + let handler = origin.dynamic("").unwrap(); let recorder = Recorder::default(); let player = tokio::spawn(media(&origin, Duration::from_millis(50), recorder.clone()).run()); let request = handler.requested_broadcast().await.unwrap(); diff --git a/rs/moq-gst/src/source/imp.rs b/rs/moq-gst/src/source/imp.rs index efdfac15ab6..876d1ddffca 100644 --- a/rs/moq-gst/src/source/imp.rs +++ b/rs/moq-gst/src/source/imp.rs @@ -3508,15 +3508,15 @@ mod session_tests { recorder.assert_one_pad_held(); } - /// A route that never answers must not hold the reader on a stale resolution once a - /// replacement serves the path. An answer arriving late cannot replace the chosen run. + /// A claim that never answers must not hold the reader on a stale resolution once a + /// replacement serves the path. Its late refusal cannot end the chosen run. #[test] fn a_replacement_supersedes_a_pending_broadcast_request() { let _pad_ids = pad_ids(); let element = element(); let recorder = Recorder::attach(&element); let origin = origin(); - let pool = origin.dynamic_route("", epoch()).unwrap(); + let pool = origin.dynamic("").unwrap(); let session = follow(&element, &origin); let pending = super::RUNTIME.block_on(async { tokio::time::timeout(Duration::from_secs(10), pool.requested_broadcast()) @@ -3526,12 +3526,9 @@ mod session_tests { }); let mut new = Publisher::new(&origin, epoch(), video_rendition()); recorder.written(&mut new, 2); - let mut old = Publisher::standalone(video_rendition()); - pending.accept(old.broadcast.consume()); - old.write(1); + pending.reject(moq_net::Error::Unauthorized); recorder.written(&mut new, 3); stop(session); - assert!(!recorder.seen().iter().any(|seen| matches!(seen, Seen::Buffer(_, 1)))); recorder.assert_one_pad_held(); } diff --git a/rs/moq-hls/src/export/rendition.rs b/rs/moq-hls/src/export/rendition.rs index d4fddf57491..e6630cd7ec5 100644 --- a/rs/moq-hls/src/export/rendition.rs +++ b/rs/moq-hls/src/export/rendition.rs @@ -1359,13 +1359,13 @@ mod tests { async fn pending_reads_preserve_a_later_authorization_refusal() { let origin = moq_tokio::origin::spawn(); let broadcast = origin.create_broadcast("room/live").unwrap(); - let dynamic = origin.dynamic_route("room/secret", Default::default()).unwrap(); + let dynamic = origin.dynamic("room/secret").unwrap(); let source = moq_mux::Source::new(origin.consume(), broadcast.consume()); let mut config = VideoConfig::new(hang::catalog::VideoCodec::VP8); config.broadcast = Some(moq_net::path::Relative::new("./secret").to_owned()); let rendition = Arc::new(Rendition::video("video".into(), &config, &source, Archive::default(), None).unwrap()); let request = dynamic.requested_broadcast().await.unwrap(); - let other = origin.dynamic_route("room/hidden", Default::default()).unwrap(); + let other = origin.dynamic("room/hidden").unwrap(); config.broadcast = Some(moq_net::path::Relative::new("./hidden").to_owned()); let resolving = Arc::new(Rendition::video("video".into(), &config, &source, Archive::default(), None).unwrap()); let other_request = other.requested_broadcast().await.unwrap(); diff --git a/rs/moq-mux/benches/export.rs b/rs/moq-mux/benches/export.rs index 65989cc1b9c..7c6ad8d0097 100644 --- a/rs/moq-mux/benches/export.rs +++ b/rs/moq-mux/benches/export.rs @@ -167,8 +167,9 @@ async fn prepare_video( size: usize, pattern: &str, ) -> (moq_mux::Source, moq_mux::catalog::Consumer, Fixture) { - let mut broadcast = moq_net::broadcast::Info::new().produce(); - let consumer = broadcast.consume(); + let (origin, driver) = moq_net::origin::Producer::new(Default::default()); + let driver = tokio::spawn(moq_net::time::run(driver)); + let mut broadcast = origin.create_broadcast("bench").unwrap(); let mut catalog = moq_mux::catalog::Producer::new(&mut broadcast, Default::default()).unwrap(); let stream = moq_mux::catalog::Consumer::from(catalog.consume().unwrap()); let track = broadcast @@ -211,14 +212,7 @@ async fn prepare_video( } video.finish().unwrap(); catalog.finish().unwrap(); - let (origin, driver) = moq_net::origin::Producer::new(Default::default()); - let driver = tokio::spawn(moq_net::time::run(driver)); - let dynamic = origin.dynamic_route("", Default::default()).unwrap(); - let requests = tokio::spawn(async move { - while let Ok(request) = dynamic.requested_broadcast().await { - request.accept(consumer.clone()); - } - }); + broadcast.announce(Default::default()).unwrap(); let resolved = origin.consume().request_broadcast("bench", None).await.unwrap(); let source = moq_mux::Source::new(origin.consume(), resolved); ( @@ -228,7 +222,6 @@ async fn prepare_video( _published: Box::new((broadcast, catalog, video)), _origin: origin, driver, - requests, }, ) } diff --git a/rs/moq-net/tests/integration/wide_hop.rs b/rs/moq-net/tests/integration/wide_hop.rs index c1d2872dea2..ccb7e4ac848 100644 --- a/rs/moq-net/tests/integration/wide_hop.rs +++ b/rs/moq-net/tests/integration/wide_hop.rs @@ -28,7 +28,7 @@ async fn widest_hop_crosses_each_varint_form() { let upstream: Version = "moq-transport-18".parse().unwrap(); let _upstream = peer(upstream, &source, &relay).await; let _downstream = peer(downstream.parse().unwrap(), &relay, &sink).await; - let _route = source.dynamic_route("room", origin::Route::default()).unwrap(); + let _route = source.dynamic("room").unwrap(); moq_clock::sim::idle().await; let ids = |route: origin::Route| route.hops.iter().map(|hop| hop.id()).collect::>(); From 376ad61d58ca72b2b09e3c8d80cabced6de33989 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 24 Oct 2026 21:29:57 -0700 Subject: [PATCH 11/15] test(net): answer main's new SUBSCRIBE-withdrawal test through the session hook Co-Authored-By: Claude Opus 5.5 --- js/net/src/ietf/publisher.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/js/net/src/ietf/publisher.test.ts b/js/net/src/ietf/publisher.test.ts index 1ebce72ff84..d125d687742 100644 --- a/js/net/src/ietf/publisher.test.ts +++ b/js/net/src/ietf/publisher.test.ts @@ -486,7 +486,7 @@ test("a SUBSCRIBE withdrawn during setup releases its demand on every draft", as expect(early.written).toEqual([]); // Withdrawn, so a late answer is closed rather than served. const answer = new BroadcastProducer().consume(); - pending?.accept(answer); + if (pending) hooks.acceptRequest(pending, answer); expect(answer.closed.peek()).toBeNull(); // The broadcast resolves, but its track is never answered. @@ -496,7 +496,7 @@ test("a SUBSCRIBE withdrawn during setup releases its demand on every draft", as if (!request) throw new Error("no request for room/late"); const served = new BroadcastProducer(); const trackRequest = wireOf(served).requested(); - request.accept(served); + hooks.acceptRequest(request, served); expect((await trackRequest)?.name).toBe("video"); expect(served.demand().used.peek()).toBe(true); late.withdraw(); From b351be0e19e2f44e1caef6c5b3f37a9084f4b681 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 24 Oct 2026 21:37:46 -0700 Subject: [PATCH 12/15] test(net): land the Moxygen conformance claim through the fuzz hatch Moxygen answers namespaces under its prefix without announcing their exact paths, which only a session's peer claim does. prepare/publish announced the exact path, so the test stopped covering prefix routing and hit the cold-start reset. Co-Authored-By: Claude Opus 5.5 --- .../tests/integration/ietf_conformance.rs | 25 +++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/rs/moq-net/tests/integration/ietf_conformance.rs b/rs/moq-net/tests/integration/ietf_conformance.rs index bc471683437..e6dfff2ce69 100644 --- a/rs/moq-net/tests/integration/ietf_conformance.rs +++ b/rs/moq-net/tests/integration/ietf_conformance.rs @@ -1,10 +1,13 @@ -#![cfg(feature = "async")] +#![cfg(feature = "fuzz")] //! Moxygen announces one prefix, then serves its sixteen-field test namespaces. +//! +//! It answers each namespace without announcing its exact path, as only a session's peer +//! claim does, so the claim lands through the hidden `fuzz` module. use crate::support::harness::{MockConnectOptions, connect_mock, spawn}; -use moq_net::{Epoch, Hop, Version, origin}; -use std::{cell::RefCell, rc::Rc}; +use moq_net::{Hop, Version, broadcast, origin}; +use std::sync::Arc; // Moxygen c27ae6358df62a1195da479b2e07c50dd8174279, moqtest/Utils.cpp. const PREFIX: &str = "moq-test-00"; @@ -21,18 +24,15 @@ async fn routing(version: &str, cluster: bool) { let publisher = origin(1); let relay = origin(2); let reader = origin(4); - let prefix = Rc::new(publisher.dynamic(PREFIX).unwrap()); - let answered = Rc::new(RefCell::new(None)); - let (serving, answer) = (prefix.clone(), answered.clone()); + let prefix = Arc::new(moq_net::fuzz::dynamic_route(&publisher, PREFIX, Default::default()).unwrap()); + let broadcast = broadcast::Info::new().produce(); + let track = broadcast.create_track("test", moq_net::track::Info::default()).unwrap(); + let serving = prefix.clone(); + let answer = broadcast.consume(); moq_clock::sim::spawn(async move { let request = serving.requested_broadcast().await.unwrap(); assert_eq!(request.path().as_str(), NAMESPACE); - let prepared = request.prepare(Epoch::mint()).unwrap(); - let track = prepared - .broadcast() - .create_track("test", moq_net::track::Info::default()) - .unwrap(); - *answer.borrow_mut() = Some((prepared.publish().unwrap(), track)); + moq_net::fuzz::accept(request, answer); }); let mut options = MockConnectOptions::new(version); @@ -59,7 +59,6 @@ async fn routing(version: &str, cluster: bool) { reader.consume().routed(NAMESPACE).await.unwrap(); let remote = reader.consume().request_broadcast(NAMESPACE, None).await.unwrap(); let mut subscription = remote.track("test").unwrap().subscribe(None).await.unwrap(); - let (broadcast, track) = answered.borrow_mut().take().expect("the claim answered"); crate::support::harness::subscribed(&track).await; let mut group = track.append_group().unwrap(); group.write_frame(None, &b"conformance"[..]).unwrap(); From a3af45bec653cac19722fda34871256e90c98a10 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 24 Oct 2026 21:48:25 -0700 Subject: [PATCH 13/15] docs(net): MIGRATE says accept is crate-private, not a runtime failure Co-Authored-By: Claude Opus 5.5 --- js/net/MIGRATE.md | 2 +- rs/moq-net/MIGRATE.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/js/net/MIGRATE.md b/js/net/MIGRATE.md index 77990b90505..2bdc084a66e 100644 --- a/js/net/MIGRATE.md +++ b/js/net/MIGRATE.md @@ -52,7 +52,7 @@ Only `publish` announces it: `prepared.broadcast.announce()` throws. `Prepared.reject(err)` is replaced by idempotent `close()` and `[Symbol.dispose]()`; either refuses the unpublished answer as `Unroutable` and closes its broadcast. Closing that broadcast also refuses the request. `using` handles normal scope exit and exceptions; after `publish()`, disposal leaves the returned producer live. Use `request.reject(err)` to refuse before preparing. A retained, unclosed `Prepared` keeps its request pending; garbage collection provides no prompt cleanup guarantee. Construct a `Prepared` only with `request.prepare()`. Its constructor and the `Request.publisher`, `publishPrepared`, and `rejectPrepared` completion hooks are no longer public. -`Dynamic.update` and `Request.accept` are no longer part of the public API, and `accept` throws on a request from `dynamic`. +`Dynamic.update` is no longer part of the public API, and `Request` has no `accept` (#6496): only the built-in sessions answer with a broadcast they already hold. Close the claim and claim again to change it, and close an answer's producer to end that output. ### `largest()` reports within a reader's `setGroups` bounds diff --git a/rs/moq-net/MIGRATE.md b/rs/moq-net/MIGRATE.md index c1203ef085d..9dea3a51305 100644 --- a/rs/moq-net/MIGRATE.md +++ b/rs/moq-net/MIGRATE.md @@ -174,7 +174,7 @@ Treat a failed group read as the loss of that group and keep reading the track, Answer each `origin::Request` with `request.prepare(epoch)?` (a fresh `Epoch::mint()` per output run), fill `prepared.broadcast()`, then `prepared.publish()?`, which announces the broadcast at the exact path under that epoch and returns its producer. Only `publish` announces it: `prepared.broadcast().announce(..)` fails with `Error::Closed`. `Dynamic::update` is gone: drop the claim and claim again to change it, and close an answer's producer to end that output. -`Request::accept` is no longer part of the public API and panics on a request from `dynamic`. +`Request::accept`, `Producer::dynamic_route`, and `Dynamic::update_route` are crate-private (#6496): only the built-in sessions answer with a broadcast they already hold. Dropping a `Prepared` unpublished refuses the request, as dropping a `Request` does, and `publish` fails with `Error::Unroutable` once the claim went away. ### Anchors can name a time (#6074) From 062dcca710ace85803c3604ee67a3a4d6e08ab03 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 24 Oct 2026 22:13:42 -0700 Subject: [PATCH 14/15] test(net): keep the peer-claim tests in the merge gate A self dev-dependency enables the hidden fuzz module for moq-net's own test builds, as moq-quic does for mux, so the restart, drained-claim, and Moxygen conformance tests run on default features instead of nightly only. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/Cargo.toml | 3 +++ rs/moq-net/tests/integration/ietf_conformance.rs | 2 +- rs/moq-net/tests/integration/restart.rs | 12 ------------ 3 files changed, 4 insertions(+), 13 deletions(-) diff --git a/rs/moq-net/Cargo.toml b/rs/moq-net/Cargo.toml index fcd182fb587..0cea98dba35 100644 --- a/rs/moq-net/Cargo.toml +++ b/rs/moq-net/Cargo.toml @@ -56,6 +56,9 @@ futures = { workspace = true } # The tests' executor, with simulated time. moq-clock = { path = "../moq-clock", features = ["sim"] } moq-json = { path = "../moq-json" } +# The integration tests that land peer claims through the hidden `fuzz` module run in the +# default merge gate. +moq-net = { path = ".", features = ["fuzz"] } serde_json = { workspace = true } # The benches run on tokio. test-util (tokio::time::pause/advance) is test-only and # is NOT supported on wasm, so it must not leak into the normal dependency feature set. diff --git a/rs/moq-net/tests/integration/ietf_conformance.rs b/rs/moq-net/tests/integration/ietf_conformance.rs index e6dfff2ce69..07c18ad865e 100644 --- a/rs/moq-net/tests/integration/ietf_conformance.rs +++ b/rs/moq-net/tests/integration/ietf_conformance.rs @@ -1,4 +1,4 @@ -#![cfg(feature = "fuzz")] +#![cfg(feature = "async")] //! Moxygen announces one prefix, then serves its sixteen-field test namespaces. //! diff --git a/rs/moq-net/tests/integration/restart.rs b/rs/moq-net/tests/integration/restart.rs index 1cd5c46c5c5..cec0496f1a9 100644 --- a/rs/moq-net/tests/integration/restart.rs +++ b/rs/moq-net/tests/integration/restart.rs @@ -80,7 +80,6 @@ async fn expect_replaced(version: Version, announced: &mut announce::Consumer, p } /// Nothing more arrives, past the update hold. -#[cfg(feature = "fuzz")] async fn quiet(version: Version, announced: &mut announce::Consumer) { if let Ok(event) = moq_clock::timeout(Duration::from_secs(1), announced.next()).await { panic!("{version}: expected nothing more, got {event:?}"); @@ -175,14 +174,12 @@ relay_chain_tests! { /// A worker claiming `pool` without an epoch, answering each request with an output /// whose `video` track carries the worker's name. The claim is a peer's, landed through /// the hidden `fuzz` module as a session lands it, so its answers carry no exact route. -#[cfg(feature = "fuzz")] struct Worker { origin: origin::Producer, _claim: Rc, _outputs: Rc>>, } -#[cfg(feature = "fuzz")] impl Worker { fn new(hop: u64) -> Self { let origin = produce_origin(hop); @@ -210,7 +207,6 @@ impl Worker { /// `pool/job-2` from `10` to itself while `11` keeps winning `pool` itself and /// `pool/job-0`, and `10` keeps `pool/job-1` (pinned by the spread hash). `D` restarts /// `pool`, and the next subscribes reach the right workers. -#[cfg(feature = "fuzz")] async fn prefix_pool(version: &str) { let version: Version = version.parse().unwrap(); let upstream = produce_origin(2); @@ -245,7 +241,6 @@ async fn prefix_pool(version: &str) { } } -#[cfg(feature = "fuzz")] #[moq_clock::test] async fn prefix_pool_lite06() { moq_clock::timeout(TEST_TIMEOUT, prefix_pool("moq-lite-06")) @@ -253,7 +248,6 @@ async fn prefix_pool_lite06() { .expect("timed out"); } -#[cfg(feature = "fuzz")] #[moq_clock::test] async fn prefix_pool_lite07() { moq_clock::timeout(TEST_TIMEOUT, prefix_pool("moq-lite-07-wip")) @@ -265,13 +259,11 @@ async fn prefix_pool_lite07() { /// the test can write into it: `outputs[n]` answered the `n`th request. Only a peer's /// claim carries an epoch or re-prices, so it lands through the hidden `fuzz` module as /// a session lands it. -#[cfg(feature = "fuzz")] struct Claim { dynamic: Rc, outputs: Rc>>, } -#[cfg(feature = "fuzz")] impl Claim { fn new(origin: &origin::Producer, epoch: Option) -> Self { let mut route = origin::Route::default(); @@ -315,7 +307,6 @@ impl Claim { /// A same-epoch re-price keeps every handle. Moving to `to` restarts `V`, the /// subscription already open stays on the old answer, and a re-request asks the /// handler again and reads none of the old answer's cached groups. -#[cfg(feature = "fuzz")] async fn dynamic_epoch(version: &str, from: Option, to: Option) { let version: Version = version.parse().unwrap(); let publisher = produce_origin(1); @@ -389,7 +380,6 @@ fn epochs() -> (Epoch, Epoch) { macro_rules! dynamic_epoch_tests { ($($name:ident: $version:literal,)*) => { $( - #[cfg(feature = "fuzz")] #[moq_clock::test] async fn $name() { let (a, b) = epochs(); @@ -413,7 +403,6 @@ dynamic_epoch_tests! { /// An update does not override route selection: with a second claim still at the /// epoch, a claim dropping it hands re-requests to the second, which outranks any /// route without one. -#[cfg(feature = "fuzz")] #[moq_clock::test] async fn dynamic_epoch_second_route_keeps_winning() { let (a, _) = epochs(); @@ -578,7 +567,6 @@ async fn withdrawal_refuses_new_fetches_but_keeps_open_reads() { } } -#[cfg(feature = "fuzz")] #[moq_clock::test] async fn a_more_specific_source_cannot_fill_a_held_covering_track() { for name in [ From 12443fb95bf630b701d19c9df42145bfc9823277 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 24 Oct 2026 22:32:19 -0700 Subject: [PATCH 15/15] chore: lock moq-net's self dev-dependency Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.lock b/Cargo.lock index ee815167cb4..629f6f1f0b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5054,6 +5054,7 @@ dependencies = [ "loom", "moq-clock", "moq-json", + "moq-net", "moq-pattern 0.1.2", "num_enum", "rand 0.10.3",