From 19020324b5506ac9176583c01b25077880634b9d Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 5 Oct 2026 15:32:49 -0700 Subject: [PATCH 1/7] quest: claim m0/wildcard/refusal-final Co-Authored-By: Claude Opus 5.5 From 24a0fe96100af12bfbc3f8eeed715185fc802e0b Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 5 Oct 2026 16:11:32 -0700 Subject: [PATCH 2/7] fix(moq-net): a standing refusal ends the front A refusal from the winning route is now the request's answer in Rust and JS, even while another source serves: no sibling advertiser or shorter prefix is asked instead. The Rust front's `refused` set becomes `excluded`, since only `source_closed` fills it now; JS drops its per-slot set entirely. Co-Authored-By: Claude Opus 5.5 --- js/net/src/origin.test.ts | 22 ++-- js/net/src/origin.ts | 36 ++---- quest/m0/shared-fronts.md | 2 +- quest/m0/wildcard/README.md | 1 - quest/m0/wildcard/refusal-final.md | 40 ------- quest/m1/cluster-routing/selection.md | 5 +- rs/moq-net/src/model/front.rs | 156 ++++++++++---------------- rs/moq-net/src/model/origin.rs | 37 +++++- 8 files changed, 120 insertions(+), 179 deletions(-) delete mode 100644 quest/m0/wildcard/refusal-final.md diff --git a/js/net/src/origin.test.ts b/js/net/src/origin.test.ts index 7f29e48969..9004e6db33 100644 --- a/js/net/src/origin.test.ts +++ b/js/net/src/origin.test.ts @@ -1498,7 +1498,7 @@ test("a refusal is terminal, never falling through to a broader route", async () origin.close(); }); -test("a better route's refusal leaves the serving route in place", async () => { +test("a better route's refusal ends a served request", async () => { const origin = new Producer(); const consumer = origin.consume(); const served = new BroadcastProducer(); @@ -1509,19 +1509,25 @@ test("a better route's refusal leaves the serving route in place", async () => { const before = request.active.peek(); expect(before).toBeDefined(); - // The narrower route is asked while the broad one keeps serving, then says no. - const narrow = origin.dynamic(Path.from("live")); + // The narrower route is asked while the broad one keeps serving, then says no. A + // costlier sibling at the same prefix could serve, but is never asked. + const narrow = origin.dynamic(Path.from("live"), { cost: 1n }); + const sibling = origin.dynamic(Path.from("live"), { cost: 2n }); + const siblingRequests = sibling.requested(); + const siblingAsked = siblingRequests.next(); const { value: req } = await narrow.requested().next(); expect(request.active.peek()).toBe(before); - req?.reject(new Error("unserved")); + const err = new Error("unserved"); + req?.reject(err); await settle(); - expect(request.active.peek()).toBe(before); - expect(before?.closed.peek()).toBeUndefined(); - expect(request.closed.peek()).toBeUndefined(); + expect(await request.closed).toBe(err); + expect(request.active.peek()).toBeUndefined(); + expect(await Promise.race([siblingAsked.then(() => "asked"), settle().then(() => "idle")])).toBe("idle"); request.close(); - expect(request.closed.peek()).toBeNull(); + void siblingRequests.return?.(); + sibling.close(); narrow.close(); disposeWide(); served.close(); diff --git a/js/net/src/origin.ts b/js/net/src/origin.ts index 5a54a34eca..0d149bbd6c 100644 --- a/js/net/src/origin.ts +++ b/js/net/src/origin.ts @@ -165,12 +165,9 @@ class CoveringRoot { * * `handles` holds the `closed` of each open {@link Requesting} on the path. * - * A refusal is terminal. With nothing serving, the slot ends: every handle closes with the - * handler's error and the slot leaves the table, so the next request asks afresh. While - * another source still serves (a better route was asked and said no), the refuser joins - * `refused` and is skipped for as long as it stands (a reconnect is a fresh entry), so the - * current source carries on. A refusal never falls through to a broader prefix or another - * advertiser. + * A refusal is terminal, even while another source still serves: the slot ends, every handle + * closes with the handler's error, and the slot leaves the table, so the next request asks + * afresh. A refusal never falls through to a broader prefix or another advertiser. * * @internal */ @@ -178,7 +175,6 @@ export interface RequestSlot { blind: number; answer?: broadcast.Consumer; readonly handles: Set>; - readonly refused: Set; readonly route: Signal; } @@ -479,21 +475,15 @@ class OriginState { } /** - * `entry` refused `path` with `err`. A request still serving another source skips the - * refuser; one with nothing serving ends with `err`. + * `entry` refused `path` with `err`: the request ends with `err`, even while another + * source serves, since asking the next candidate would turn one refusal into a request + * per candidate. */ refuse(path: Path.Valid, entry: RouteEntry, err: Error): void { const slot = this.requests.peek()?.get(path); if (!slot) return; // Only the route the request is waiting on speaks for it; a superseded one's answer is moot. - if (this.bestEntry(path, (candidate) => slot.refused.has(candidate)) !== entry) return; - - const serving = slot.route.peek(); - if (serving && serving.closed.peek() === undefined) { - slot.refused.add(entry); - slot.route.set(this.route(path, slot)); - return; - } + if (this.bestEntry(path) !== entry) return; this.requests.mutate((map) => { if (map?.get(path) === slot) map.delete(path); @@ -580,10 +570,10 @@ class OriginState { * Materialization is lazy and cached per path: the first request under a route opens * the providing session's subscription and repeats share it. A better route is made * before the old one breaks: the current front keeps serving until the new route - * answers (then swaps) or refuses (then is skipped). A retracted route swaps at once. + * answers (then swaps) or refuses (then the request ends). A retracted route swaps at once. */ - route(path: Path.Valid, slot: Pick): broadcast.Consumer | undefined { - const entry = this.bestEntry(path, (candidate) => slot.refused.has(candidate)); + route(path: Path.Valid, slot: Pick): broadcast.Consumer | undefined { + const entry = this.bestEntry(path); const local = this.local.peek()?.get(path); if (local && this.localWins(path, entry)) { // Nothing reads a remote front the local broadcast replaced, so close its session subscription. @@ -860,8 +850,6 @@ export class Producer implements Table { entries.splice(index, 1); if (entries.length === 0) routes?.delete(prefix); }); - // A retracted entry can never be picked again, so the refusals pinned to it are dead weight. - for (const slot of this.#state.requests.peek()?.values() ?? []) slot.refused.delete(entry); server.close(); this.#state.rebuildOriginated(); this.#state.refreshPrefix(prefix); @@ -1262,12 +1250,10 @@ export class Consumer { // value as the baseline the next change is compared against, and never flushes to // clear it, so a seeded route retracting to undefined would look like no change and // notify nobody. - const refused = new Set(); const created: RequestSlot = { blind: 0, handles: new Set(), - refused, - route: new Signal(this.#state.route(path, { refused })), + route: new Signal(this.#state.route(path, {})), }; slot = created; this.#state.requests.mutate((map) => { diff --git a/quest/m0/shared-fronts.md b/quest/m0/shared-fronts.md index 86aa2af6f9..8462d229bb 100644 --- a/quest/m0/shared-fronts.md +++ b/quest/m0/shared-fronts.md @@ -56,4 +56,4 @@ Public API: none. Wire: none. - [Front deadlines](/quest/m1/front-deadline-index.md) - per-front cost per track - [Front parking](/quest/m1/origin-front-parks.md) - also changes what mints a front -- [Wildcard](/quest/m0/wildcard/README.md) - its line edits `model/origin.rs` resolution, and its [refusal child](/quest/m0/wildcard/refusal-final.md) makes a standing refusal end the front; land after it or rebase onto it +- [Wildcard](/quest/m0/wildcard/README.md) - its line edits `model/origin.rs` resolution; land after it or rebase onto it diff --git a/quest/m0/wildcard/README.md b/quest/m0/wildcard/README.md index b53579c9d3..43289c97e4 100644 --- a/quest/m0/wildcard/README.md +++ b/quest/m0/wildcard/README.md @@ -218,7 +218,6 @@ distinguish recording generations reads the catalog's archive entry ## Required - [Transcoders start at group boundaries](/quest/m0/wildcard/transcode-group-start.md) - two claim workers at one path are one broadcast, so a relay never splices them mid-group -- [A standing refusal ends the front](/quest/m0/wildcard/refusal-final.md) - a refusal from the winning route ends the request instead of re-selecting a sibling or a shorter prefix ## Related diff --git a/quest/m0/wildcard/refusal-final.md b/quest/m0/wildcard/refusal-final.md deleted file mode 100644 index a0fc4e0fa0..0000000000 --- a/quest/m0/wildcard/refusal-final.md +++ /dev/null @@ -1,40 +0,0 @@ -# [S] A standing refusal ends the front - -## Goal - -A refusal from the winning route is the answer. The request ends with that -refusal instead of re-selecting a sibling advertiser of the same prefix or -falling through to a shorter one, as the wildcard line, the lite draft, and -the cluster draft already say. A new route, or the refuser's route changing, -is what a client's ordinary resubscribe resolves against. - -## Plan - -Decided in the 2026-10-05 audit: a refusal is final, with no retry. The -maintainer: "otherwise we'll try all other possible options and cause a -cascade of requests". - -- Since #4741, `main` does the opposite. The front's standing-refusal arm - (`Err(Refusal { standing: true, .. })` in `rs/moq-net/src/model/front.rs`) - records the refuser in its `refused` set and pushes `Reselect`, and - `best_route` (`rs/moq-net/src/model/origin.rs`) skips those entries, which - can reach a sibling or a shorter prefix. `js/net/src/origin.ts` keeps the - same `refused` set per slot. Change only that arm, in both languages: it - ends the front with the refusal's typed code instead of re-selecting. -- Keep the `refused` set itself. `source_closed` also fills it, to exclude a - standing route whose source just ended ("asking it again would re-request - the broadcast that just ended"); deleting the set would re-pick that route - in a loop. Per-track refusal (`redispatch`) is already final, so only - front-level route selection changes. -- Keep resume across routes that did not refuse: a route that goes away - mid-track is not a refusal, and any covering route still resumes it. -- Tests in both languages: a refusal from the only advertiser of the longest - prefix ends the request even when a sibling or a catch-all could serve it. - Replace any test that asserts the fall-through; the tests asserting - `refused_routes()` mix both causes and need splitting. - -Public API: none. Wire: none; the relay matches what the drafts specify. - -## Related - -- [Shared fronts](/quest/m0/shared-fronts.md) - also changes what a front holds diff --git a/quest/m1/cluster-routing/selection.md b/quest/m1/cluster-routing/selection.md index 31d8068804..07b47657dc 100644 --- a/quest/m1/cluster-routing/selection.md +++ b/quest/m1/cluster-routing/selection.md @@ -30,9 +30,8 @@ Recorded in the 2026-10-05 audit: part of this is already built. The wildcard line's `route_order` hash is keyed on the requested path, the HRW spread below, and #4741 made a concrete path one source with mid-group resume. Once [Wildcard](/quest/m0/wildcard/README.md) (#4403) lands, cut this -quest down to what remains: terminal refusal (see its -[refusal child](/quest/m0/wildcard/refusal-final.md)), the HRW split test, -the same-path failover test, and upstream-link spread. +quest down to what remains: the HRW split test, the same-path failover +test, and upstream-link spread. Terminal refusal already landed. Candidate mechanics: diff --git a/rs/moq-net/src/model/front.rs b/rs/moq-net/src/model/front.rs index b7b00c19bd..14b5039f23 100644 --- a/rs/moq-net/src/model/front.rs +++ b/rs/moq-net/src/model/front.rs @@ -172,9 +172,8 @@ pub(super) struct Front { serving_closing: bool, /// The route an upstream request is in flight through. upstream: Option, - /// Routes excluded from selection: they refused the path while another - /// source was serving, or their source ended while still advertised. - refused: HashSet, + /// Routes excluded from selection: their source ended while still advertised. + excluded: HashSet, /// Why the last candidate fell through, reported if the front ends unresolved. last_err: Option, /// Whether the parked requesters were resolved (the first source attached). @@ -198,7 +197,7 @@ impl Front { serving: None, serving_closing: false, upstream: None, - refused: HashSet::new(), + excluded: HashSet::new(), last_err: None, resolved: false, tracks: BTreeMap::new(), @@ -210,13 +209,13 @@ impl Front { } /// The routes excluded from selection; the driver skips them. - pub(super) fn refused_routes(&self) -> &HashSet { - &self.refused + pub(super) fn excluded_routes(&self) -> &HashSet { + &self.excluded } - /// Forget refused routes that left the table (a reconnect is a fresh entry). + /// Forget excluded routes that left the table (a reconnect is a fresh entry). pub(super) fn retain_routes(&mut self, standing: impl Fn(u64) -> bool) { - self.refused.retain(|route| standing(*route)); + self.excluded.retain(|route| standing(*route)); } /// The attached source, if any. @@ -339,14 +338,12 @@ impl Front { self.last_err = Some(Error::Unroutable); actions.push(Action::Reselect); } - // An authoritative refusal of the path: skip the refuser. Another route may - // still serve, and with none left the selection ends the front with this - // error. + // The winning route's answer is final, even while another source serves: + // asking a sibling or a shorter prefix instead would turn one refusal into + // a request per candidate. Err(Refusal { err, standing: true }) => { self.upstream = None; - self.refused.insert(route); - self.last_err = Some(err); - actions.push(Action::Reselect); + self.end(err, actions); } } } @@ -386,7 +383,7 @@ impl Front { // A standing route can outlive the source it produced. Asking it again // would re-request the broadcast that just ended; another route may still // resume it. - self.refused.insert(candidate.route); + self.excluded.insert(candidate.route); self.serving = None; self.serving_closing = false; actions.push(Action::Detach { source }); @@ -801,7 +798,7 @@ mod tests { front.step(Event::SourceClosed { source: 100 }), &[Action::Detach { source: 100 }, Action::Reselect], ); - assert!(front.refused_routes().contains(&1)); + assert_eq!(front.excluded_routes(), &HashSet::from([1])); assert_actions( front.step(Event::Selected { best: Some(remote(3)), @@ -848,83 +845,52 @@ mod tests { ); } - /// A refusal skips the refuser rather than ending the front: another route to the - /// same content may serve. With none left, the front ends with the refusal. + fn refuse(front: &mut Front, route: u64) -> Vec { + front.step(Event::Resolved { + route, + result: Err(Refusal { + err: Error::NotFound, + standing: true, + }), + }) + } + + /// The winning route's refusal is the answer: no other route is asked. #[test] - fn standing_refusal_tries_the_next_route_then_ends() { + fn standing_refusal_ends_the_front() { let mut front = Front::new(LINGER); front.step(Event::Selected { best: Some(remote(1)), serving_closing: false, }); - assert_actions( - front.step(Event::Resolved { - route: 1, - result: Err(Refusal { - err: Error::NotFound, - standing: true, - }), - }), - &[Action::Reselect], - ); - assert_eq!(front.refused_routes(), &HashSet::from([1])); - assert_actions( - front.step(Event::Selected { - best: None, - serving_closing: false, - }), - &[Action::End { err: Error::NotFound }], - ); + assert_actions(refuse(&mut front, 1), &[Action::End { err: Error::NotFound }]); + assert!(front.ended()); } - /// The audit's F2: the serving source died, and the first replacement refuses the - /// path. A second route is still tried. + /// The serving source died and its replacement refuses the path: the front ends with + /// that refusal rather than trying a third route. #[test] - fn standing_refusal_after_the_source_died_tries_another_route() { + fn standing_refusal_after_the_source_died_ends_the_front() { let mut front = serving(remote(1), 100); front.step(Event::SourceClosed { source: 100 }); front.step(Event::Selected { best: Some(remote(2)), serving_closing: false, }); - assert_actions( - front.step(Event::Resolved { - route: 2, - result: Err(Refusal { - err: Error::NotFound, - standing: true, - }), - }), - &[Action::Reselect], - ); - assert_actions( - front.step(Event::Selected { - best: Some(remote(3)), - serving_closing: false, - }), - &[Action::Request { route: 3 }], - ); + assert_actions(refuse(&mut front, 2), &[Action::End { err: Error::NotFound }]); } + /// A better route refusing ends the front even while another source serves: its read + /// tracks finish on the copies they are spliced from, and a new request gets the refusal. #[test] - fn standing_refusal_while_serving_skips_the_refuser() { + fn standing_refusal_while_serving_ends_the_front() { let mut front = serving(remote(1), 100); front.step(Event::Selected { best: Some(remote(2)), serving_closing: false, }); - assert_actions( - front.step(Event::Resolved { - route: 2, - result: Err(Refusal { - err: Error::NotFound, - standing: true, - }), - }), - &[Action::Reselect], - ); - assert!(front.refused_routes().contains(&2)); - assert_eq!(front.serving, Some((100, remote(1)))); + assert_actions(refuse(&mut front, 2), &[Action::End { err: Error::NotFound }]); + assert!(front.excluded_routes().is_empty()); } #[test] @@ -944,7 +910,7 @@ mod tests { }), &[Action::Reselect], ); - assert!(front.refused_routes().is_empty()); + assert!(front.excluded_routes().is_empty()); assert!(!front.ended()); } @@ -1559,35 +1525,33 @@ mod tests { ); } - /// A front that served ends `Dropped` when its route leaves, not with the refusal or - /// retraction of a challenger that never took over. + /// A front that served ends `Dropped` when its route leaves, not with the retraction + /// of a challenger that never took over. #[test] fn a_front_that_served_ends_dropped() { - for standing in [true, false] { - let mut front = serving(remote(1), 100); - front.step(Event::Selected { - best: Some(remote(2)), - serving_closing: false, - }); - front.step(Event::Resolved { - route: 2, - result: Err(Refusal { - err: Error::NotFound, - standing, - }), - }); + let mut front = serving(remote(1), 100); + front.step(Event::Selected { + best: Some(remote(2)), + serving_closing: false, + }); + front.step(Event::Resolved { + route: 2, + result: Err(Refusal { + err: Error::Unroutable, + standing: false, + }), + }); + front.step(Event::Selected { + best: Some(remote(1)), + serving_closing: false, + }); + assert_actions( front.step(Event::Selected { - best: Some(remote(1)), + best: None, serving_closing: false, - }); - assert_actions( - front.step(Event::Selected { - best: None, - serving_closing: false, - }), - &[Action::End { err: Error::Dropped }], - ); - } + }), + &[Action::End { err: Error::Dropped }], + ); } /// The serving source closed and the front reselected, but the pump still reads the diff --git a/rs/moq-net/src/model/origin.rs b/rs/moq-net/src/model/origin.rs index 00cd050446..cb40944a0b 100644 --- a/rs/moq-net/src/model/origin.rs +++ b/rs/moq-net/src/model/origin.rs @@ -2351,7 +2351,7 @@ async fn run_front(task: FrontTask) { *seen = watch.seen(); front.retain_routes(|route| table.routes.covers(&path.as_path(), route)); let best = table - .best_route(&path.as_path(), horizon, front.refused_routes()) + .best_route(&path.as_path(), horizon, front.excluded_routes()) .map(|entry| Candidate { route: entry.id, local: entry.local, @@ -3354,7 +3354,7 @@ impl OriginState { } /// The best served route covering `path` (absolute) for a requester seeing - /// `horizon`, skipping the `refused` entry ids. + /// `horizon`, skipping the `excluded` entry ids. /// /// The most specific covering prefix wins outright, so a narrow advertise-only /// announcement shadows a broad served one: requests under it resolve @@ -3362,10 +3362,10 @@ impl OriginState { /// prefix, the cheapest served one is picked by [`route_order`]. /// /// Only announced routes are candidates: an unannounced broadcast serves - /// nobody, and does not shadow anything either. Routes `refused` for the + /// nobody, and does not shadow anything either. Routes `excluded` for the /// front's path are skipped. A broadcast published on this origin competes /// on cost like any other route and wins a tie. - fn best_route(&self, path: &Path, horizon: Horizon, refused: &HashSet) -> Option<&RouteEntry> { + fn best_route(&self, path: &Path, horizon: Horizon, excluded: &HashSet) -> Option<&RouteEntry> { // Covering prefixes of one path form a chain, so the deepest node with a // candidate holds the unique longest prefix; walking down, the last such // node decides. @@ -3378,7 +3378,7 @@ impl OriginState { .filter(|entry| entry.live()) .filter(|entry| entry.scope.matches(path.as_str())) .filter(|entry| horizon.admits(entry)) - .filter(|entry| !refused.contains(&entry.id)) + .filter(|entry| !excluded.contains(&entry.id)) .peekable(); if candidates.peek().is_some() { best = candidates @@ -6843,6 +6843,33 @@ mod tests { pending.await.expect("resolves"); } + /// A refusal from the longest prefix is the answer: neither a costlier advertiser of + /// that prefix nor a catch-all is asked instead. + #[tokio::test] + async fn refusal_never_falls_through() { + let producer = origin(1).produce(); + let consumer = producer.consume(); + let catch_all = producer.dynamic("", Route::default()).unwrap(); + let winner = producer.dynamic("room", Route::default()).unwrap(); + let sibling = producer.dynamic("room", Route::default().with_cost(2)).unwrap(); + + let mut pending = Box::pin(consumer.request_broadcast("room/alice")); + assert!((&mut pending).now_or_never().is_none()); + queued(&winner).await.reject(Error::NotFound); + let mut result = None; + settle(|| { + result = (&mut pending).now_or_never(); + result.is_some() + }) + .await; + let err = result.unwrap().err().unwrap(); + assert!(matches!(err, Error::NotFound), "unexpected end: {err}"); + + for other in [&sibling, &catch_all] { + assert!(other.poll_requested_broadcast(&kio::Waiter::noop()).is_pending()); + } + } + /// `routed_broadcast` treats a handler's rejection as the table's verdict: /// it waits for the table to move instead of re-asking the same route. #[tokio::test] From 838430b559c6109fe037baa75530f6ed1485e181 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 5 Oct 2026 16:19:24 -0700 Subject: [PATCH 3/7] docs(drafts): a refusal is not retried within the winning tier either Co-Authored-By: Claude Opus 5.5 --- drafts/draft-lcurley-moq-cluster.md | 1 + drafts/draft-lcurley-moq-lite.md | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/drafts/draft-lcurley-moq-cluster.md b/drafts/draft-lcurley-moq-cluster.md index f9ffbf4dd8..24ea874a0f 100644 --- a/drafts/draft-lcurley-moq-cluster.md +++ b/drafts/draft-lcurley-moq-cluster.md @@ -242,6 +242,7 @@ The expected update is a ROUTE_COST change, which is how a relay signals that it # Path Selection {#selection} A receiver resolving a SUBSCRIBE, FETCH, or track-status request consults only the most specific advertisements covering it: the longest prefix. A refusal never falls through to a less specific tier. +Nor is it retried at another advertisement of the same prefix, except for the one re-resolution NO_CAPACITY permits below. Within that tier, a receiver SHOULD prefer a HOP_PATH that contains no 0 entry over one that does, then the lowest ROUTE_COST, breaking ties toward the shorter HOP_PATH and then toward the most recently received. This is advisory: a receiver MAY apply local policy, such as measured RTT, instead. diff --git a/drafts/draft-lcurley-moq-lite.md b/drafts/draft-lcurley-moq-lite.md index 4b6b6ff687..ddf0913119 100644 --- a/drafts/draft-lcurley-moq-lite.md +++ b/drafts/draft-lcurley-moq-lite.md @@ -433,6 +433,7 @@ A SUBSCRIBE, FETCH, or TRACK request names a path, and the receiver resolves it Only the most specific covering routes are consulted: those with the longest prefix. A route strictly inside another's paths always ranks above it, and a broadcast's exact path is the most specific route there is, so a concrete announcement shadows every broader route covering it at any cost; routes over the same prefix form one tier. The winning tier is the whole answer: a refusal from it never falls through to a less specific route, so a service refusing a path does not leak the request to a catch-all, and one unserved path costs one round trip rather than a walk down the candidates. +Nor is a refusal retried at another route of the same prefix, except for the one re-resolution NO_CAPACITY permits below. Within the tier, cost and the tie-breaks of [Routing](#routing) order the routes. @@ -1383,7 +1384,7 @@ The `Message Length` describes the payload size on the wire. - Allowed an empty SETUP `Path` parameter, equivalent to omitting it; both request the server's default path. Previously an empty value was a protocol violation, which made the two ways of asking for the default disagree. - Corrected SUBSCRIBE_END `Group` to an exclusive bound: the first sequence that will never be delivered, with 0 meaning no groups were produced. It was previously specified as the inclusive last group, which could not distinguish an empty track from one whose only group was 0. - Split ANNOUNCE_BROADCAST into three typed messages: ANNOUNCE_START (0x0), ANNOUNCE_END (0x1), and ANNOUNCE_UPDATE (0x2), each prefixed with a Type discriminator like the subscribe stream's responses. -- Specified resolution of a request against the routes covering its path: only the most specific tier, the longest prefix, is consulted, so a concrete path shadows every broader route; a refusal never falls through, and cost and the existing tie-breaks order the tier. A route is always a prefix, and no message narrows one: an advertiser that serves only some of the paths beneath its prefix refuses the rest. A relay never announces a path because it resolved it; the advertiser announces the concrete path once producing. Standby ordering requires enforced deployment bounds on charged link count and cost; 2^32 is a recommended seed only when it exceeds their product. +- Specified resolution of a request against the routes covering its path: only the most specific tier, the longest prefix, is consulted, so a concrete path shadows every broader route; a refusal never falls through or moves to another route of the tier, and cost and the existing tie-breaks order the tier. A route is always a prefix, and no message narrows one: an advertiser that serves only some of the paths beneath its prefix refuses the rest. A relay never announces a path because it resolved it; the advertiser announces the concrete path once producing. Standby ordering requires enforced deployment bounds on charged link count and cost; 2^32 is a recommended seed only when it exceeds their product. - Split the reserved stream error range: 32 through 47 stays reserved, and 48 through 63 is moq-lite's own, assigned by the tables and mapped rather than forwarded across a bridge. Assigned 0x30 NO_CAPACITY there: it permits one re-resolution within the tier excluding the refusing advertiser, and a receiver that has spent or lacks that retry resets downstream with another code. Assigned 0x32 GROUP_TOO_LARGE: a group that grew past the publisher's cache budget is aborted. Every other code is terminal. - Assigned 0x33 NOT_FOUND, 0x34 OLD, and 0x35 EVICTED in the stream error table: a group the publisher cannot serve because it was never here, has been superseded, or was dropped under memory pressure. - Assigned 0x36 UNROUTABLE, 0x37 WRONG_SIZE, 0x38 FRAME_TOO_LARGE, and 0x39 TIMESTAMP_MISMATCH in the stream error table, moving them out of the reserved 32 through 47 range, which no longer carries provisional placeholders. From 72d352e75dffc75f6a7018359720ac73c7bad4c7 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 5 Oct 2026 16:42:26 -0700 Subject: [PATCH 4/7] fix(js/net): ignore a refusal from a route a local broadcast superseded Co-Authored-By: Claude Opus 5.5 --- js/net/src/origin.test.ts | 27 +++++++++++++++++++++++++++ js/net/src/origin.ts | 5 +++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/js/net/src/origin.test.ts b/js/net/src/origin.test.ts index 9004e6db33..3f80f9e29f 100644 --- a/js/net/src/origin.test.ts +++ b/js/net/src/origin.test.ts @@ -1563,6 +1563,33 @@ test("a refusal from a superseded route does not end the request", async () => { origin.close(); }); +test("a refusal from a route a local broadcast superseded does not end the request", async () => { + const origin = new Producer(); + const consumer = origin.consume(); + const wide = origin.dynamic(Path.from("live")); + const wideRequests = wide.requested(); + + const request = consumer.request(Path.from("live/cam")); + const { value: stale } = await wideRequests.next(); + + // The exact local broadcast takes over before the broad route answers. + const local = publish(origin, Path.from("live/cam")); + await settle(); + const active = request.active.peek(); + expect(active).toBeDefined(); + + stale?.reject(new Error("unserved")); + await settle(); + expect(request.closed.peek()).toBeUndefined(); + expect(request.active.peek()).toBe(active); + + request.close(); + void wideRequests.return?.(); + wide.close(); + local.close(); + origin.close(); +}); + test("close rejects queued requests with NoCapacity", async () => { const origin = new Producer(); const handle = origin.dynamic(Path.from("live")); diff --git a/js/net/src/origin.ts b/js/net/src/origin.ts index 0d149bbd6c..fa4a225d7c 100644 --- a/js/net/src/origin.ts +++ b/js/net/src/origin.ts @@ -482,8 +482,9 @@ class OriginState { refuse(path: Path.Valid, entry: RouteEntry, err: Error): void { const slot = this.requests.peek()?.get(path); if (!slot) return; - // Only the route the request is waiting on speaks for it; a superseded one's answer is moot. - if (this.bestEntry(path) !== entry) return; + // Only the route the request is waiting on speaks for it; one superseded by another + // route or a local broadcast has a moot answer. + if (this.bestEntry(path) !== entry || this.localWins(path, entry)) return; this.requests.mutate((map) => { if (map?.get(path) === slot) map.delete(path); From 0a29123e7da800f4c58a14388304ecf3937d437d Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 5 Oct 2026 17:21:34 -0700 Subject: [PATCH 5/7] fix(moq-net): only the current winner's refusal ends the front A refusal from a route that was retracted, or beaten while its request was pending, re-selects instead. Also moves the drafts' changelog entry under moq-lite-07. Co-Authored-By: Claude Opus 5.5 --- drafts/draft-lcurley-moq-lite.md | 3 ++- rs/moq-net/src/model/origin.rs | 46 ++++++++++++++++++++++++++------ 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/drafts/draft-lcurley-moq-lite.md b/drafts/draft-lcurley-moq-lite.md index ddf0913119..d88487b028 100644 --- a/drafts/draft-lcurley-moq-lite.md +++ b/drafts/draft-lcurley-moq-lite.md @@ -1360,6 +1360,7 @@ The `Message Length` describes the payload size on the wire. ## moq-lite-07 - The subscriber FINs its Subscribe Stream after settling its tail; graceful session close waits for that FIN or reset. +- A refusal is not retried at another route of the same prefix either, apart from NO_CAPACITY's one re-resolution. - Made TRACK_INFO Publisher Max Age optional, encoded as milliseconds plus one with zero meaning no limit. - Added `Largest Group` and `Largest Frame` to SUBSCRIBE_OK: the publisher's largest position when it answers, which a subscriber takes as where the live feed is. A publisher MUST answer at once when the requested start is past it. Earlier versions carry no such position, so a subscriber takes the first frame instead. - A path names one Broadcast whichever publisher serves it, and a publisher MUST NOT reuse a path for different content. A relay MAY move a subscription between any routes covering the path, continuing from the first frame the subscriber lacks instead of at a group boundary. Replaces the first-hop identity. @@ -1384,7 +1385,7 @@ The `Message Length` describes the payload size on the wire. - Allowed an empty SETUP `Path` parameter, equivalent to omitting it; both request the server's default path. Previously an empty value was a protocol violation, which made the two ways of asking for the default disagree. - Corrected SUBSCRIBE_END `Group` to an exclusive bound: the first sequence that will never be delivered, with 0 meaning no groups were produced. It was previously specified as the inclusive last group, which could not distinguish an empty track from one whose only group was 0. - Split ANNOUNCE_BROADCAST into three typed messages: ANNOUNCE_START (0x0), ANNOUNCE_END (0x1), and ANNOUNCE_UPDATE (0x2), each prefixed with a Type discriminator like the subscribe stream's responses. -- Specified resolution of a request against the routes covering its path: only the most specific tier, the longest prefix, is consulted, so a concrete path shadows every broader route; a refusal never falls through or moves to another route of the tier, and cost and the existing tie-breaks order the tier. A route is always a prefix, and no message narrows one: an advertiser that serves only some of the paths beneath its prefix refuses the rest. A relay never announces a path because it resolved it; the advertiser announces the concrete path once producing. Standby ordering requires enforced deployment bounds on charged link count and cost; 2^32 is a recommended seed only when it exceeds their product. +- Specified resolution of a request against the routes covering its path: only the most specific tier, the longest prefix, is consulted, so a concrete path shadows every broader route; a refusal never falls through, and cost and the existing tie-breaks order the tier. A route is always a prefix, and no message narrows one: an advertiser that serves only some of the paths beneath its prefix refuses the rest. A relay never announces a path because it resolved it; the advertiser announces the concrete path once producing. Standby ordering requires enforced deployment bounds on charged link count and cost; 2^32 is a recommended seed only when it exceeds their product. - Split the reserved stream error range: 32 through 47 stays reserved, and 48 through 63 is moq-lite's own, assigned by the tables and mapped rather than forwarded across a bridge. Assigned 0x30 NO_CAPACITY there: it permits one re-resolution within the tier excluding the refusing advertiser, and a receiver that has spent or lacks that retry resets downstream with another code. Assigned 0x32 GROUP_TOO_LARGE: a group that grew past the publisher's cache budget is aborted. Every other code is terminal. - Assigned 0x33 NOT_FOUND, 0x34 OLD, and 0x35 EVICTED in the stream error table: a group the publisher cannot serve because it was never here, has been superseded, or was dropped under memory pressure. - Assigned 0x36 UNROUTABLE, 0x37 WRONG_SIZE, 0x38 FRAME_TOO_LARGE, and 0x39 TIMESTAMP_MISMATCH in the stream error table, moving them out of the reserved 32 through 47 range, which no longer carries provisional placeholders. diff --git a/rs/moq-net/src/model/origin.rs b/rs/moq-net/src/model/origin.rs index cb40944a0b..05bbab2d6f 100644 --- a/rs/moq-net/src/model/origin.rs +++ b/rs/moq-net/src/model/origin.rs @@ -2414,13 +2414,15 @@ async fn run_front(task: FrontTask) { let mut serve = server.lock(); if serve.closed { // Retracted under us, or its handler dropped while the - // announcement stands: it cannot serve. + // announcement stands: it cannot serve. A retraction + // leaves the table before it closes the server, so the + // table tells the two apart. drop(serve); events.push_back(Event::Resolved { route, result: Err(Refusal { err: Error::Unroutable, - standing: true, + standing: shared.read().routes.covers(&path.as_path(), route), }), }); continue; @@ -2450,7 +2452,7 @@ async fn run_front(task: FrontTask) { route, result: Err(Refusal { err: Error::Unroutable, - standing: true, + standing: shared.read().routes.covers(&path.as_path(), route), }), }); continue; @@ -2702,11 +2704,13 @@ async fn run_front(task: FrontTask) { Event::Resolved { route, result: Ok(id) } } Err(err) => { - // A retraction and a handler's rejection resolve alike, so - // the table tells them apart: an `Unroutable` from a route - // that still stands is the handler's answer. - let standing = - !matches!(err, Error::Unroutable) || shared.read().routes.covers(&path.as_path(), route); + // Only the current winner's answer is final. A retraction + // resolves like a rejection, and a route beaten while it was + // pending speaks for nobody, so either one re-selects. + let standing = shared + .read() + .best_route(&path.as_path(), horizon, front.excluded_routes()) + .is_some_and(|best| best.id == route); Event::Resolved { route, result: Err(Refusal { err, standing }), @@ -6870,6 +6874,32 @@ mod tests { } } + /// A refusal from a route a local broadcast superseded while it was pending is moot: + /// the request resolves to the local broadcast instead of ending. + #[tokio::test] + async fn superseded_refusal_does_not_end_the_request() { + let producer = origin(1).produce(); + let consumer = producer.consume(); + let stale = producer.dynamic("room", Route::default()).unwrap(); + + let mut pending = Box::pin(consumer.request_broadcast("room/alice")); + assert!((&mut pending).now_or_never().is_none()); + let request = queued(&stale).await; + + // The local broadcast wins before the front sees the rejection. + let local = producer.create_broadcast("room/alice").unwrap(); + local.announce(Route::default()).unwrap(); + request.reject(Error::NotFound); + + let mut result = None; + settle(|| { + result = (&mut pending).now_or_never(); + result.is_some() + }) + .await; + assert!(result.unwrap().is_ok(), "the superseded refusal ended the request"); + } + /// `routed_broadcast` treats a handler's rejection as the table's verdict: /// it waits for the table to move instead of re-asking the same route. #[tokio::test] From c63547f5d2a853cdb9ec18fb8e4d18e21d069040 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 5 Oct 2026 17:53:20 -0700 Subject: [PATCH 6/7] refactor(moq-net): one standing predicate for every route refusal Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/origin.rs | 40 +++++++++++++++++++--------------- 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/rs/moq-net/src/model/origin.rs b/rs/moq-net/src/model/origin.rs index 05bbab2d6f..b1c2610f71 100644 --- a/rs/moq-net/src/model/origin.rs +++ b/rs/moq-net/src/model/origin.rs @@ -2363,6 +2363,16 @@ async fn run_front(task: FrontTask) { Event::Selected { best, serving_closing } }; + // Whether a route's refusal is the answer: only the current winner speaks for the + // path. A retraction resolves like a rejection, and a route beaten while its + // request was pending speaks for nobody, so either one re-selects instead. + let standing = |front: &Front, route: u64| { + shared + .read() + .best_route(&path.as_path(), horizon, front.excluded_routes()) + .is_some_and(|best| best.id == route) + }; + events.push_back(select(&mut front, &sources, &mut seen)); loop { @@ -2406,7 +2416,7 @@ async fn run_front(task: FrontTask) { route, result: Err(Refusal { err: Error::Unroutable, - standing: true, + standing: standing(&front, route), }), }); continue; @@ -2415,14 +2425,14 @@ async fn run_front(task: FrontTask) { if serve.closed { // Retracted under us, or its handler dropped while the // announcement stands: it cannot serve. A retraction - // leaves the table before it closes the server, so the - // table tells the two apart. + // leaves the table before it closes the server, under + // the same lock, so the table tells the two apart. drop(serve); events.push_back(Event::Resolved { route, result: Err(Refusal { err: Error::Unroutable, - standing: shared.read().routes.covers(&path.as_path(), route), + standing: standing(&front, route), }), }); continue; @@ -2452,7 +2462,7 @@ async fn run_front(task: FrontTask) { route, result: Err(Refusal { err: Error::Unroutable, - standing: shared.read().routes.covers(&path.as_path(), route), + standing: standing(&front, route), }), }); continue; @@ -2703,19 +2713,13 @@ async fn run_front(task: FrontTask) { sources.insert(id, source); Event::Resolved { route, result: Ok(id) } } - Err(err) => { - // Only the current winner's answer is final. A retraction - // resolves like a rejection, and a route beaten while it was - // pending speaks for nobody, so either one re-selects. - let standing = shared - .read() - .best_route(&path.as_path(), horizon, front.excluded_routes()) - .is_some_and(|best| best.id == route); - Event::Resolved { - route, - result: Err(Refusal { err, standing }), - } - } + Err(err) => Event::Resolved { + route, + result: Err(Refusal { + err, + standing: standing(&front, route), + }), + }, } } Step::SourceClosed(source) => Event::SourceClosed { source }, From 736982a526f9ce0718ecc579f49ec3bc22c248be Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 5 Oct 2026 19:02:50 -0700 Subject: [PATCH 7/7] test(moq-net): a beaten remote route's refusal asks the new winner Also updates the Refusal docs for the current-winner rule. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/front.rs | 11 ++++++----- rs/moq-net/src/model/origin.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/rs/moq-net/src/model/front.rs b/rs/moq-net/src/model/front.rs index 14b5039f23..327b73a5bc 100644 --- a/rs/moq-net/src/model/front.rs +++ b/rs/moq-net/src/model/front.rs @@ -37,9 +37,9 @@ pub(super) struct Candidate { #[derive(Clone, Debug)] pub(super) struct Refusal { pub err: Error, - /// Whether the route is still in the table. A refusal from a standing route - /// is the handler's answer and is never re-asked; one from a retracted route - /// only means the table moved. + /// Whether the route still wins the path. A refusal from a standing route + /// is the path's answer and ends the front; one from a route that retracted + /// or was beaten while pending only means the table moved. pub standing: bool, } @@ -331,8 +331,9 @@ impl Front { self.upstream = None; self.attach(source, upstream, actions); } - // The route retracted before serving: the table already reflects - // it, so the next selection retries the survivor. + // The route retracted or was beaten before serving: the table already + // reflects it, so the next selection asks the new winner. Its own error + // is moot, so a front left with nothing reports the path unroutable. Err(Refusal { standing: false, .. }) => { self.upstream = None; self.last_err = Some(Error::Unroutable); diff --git a/rs/moq-net/src/model/origin.rs b/rs/moq-net/src/model/origin.rs index b1c2610f71..5d27d10189 100644 --- a/rs/moq-net/src/model/origin.rs +++ b/rs/moq-net/src/model/origin.rs @@ -6904,6 +6904,32 @@ mod tests { assert!(result.unwrap().is_ok(), "the superseded refusal ended the request"); } + /// The same holds for a more specific remote route: the beaten route's refusal is + /// moot, and the new winner is asked instead. + #[tokio::test] + async fn refusal_from_a_beaten_route_asks_the_new_winner() { + let producer = origin(1).produce(); + let consumer = producer.consume(); + let stale = producer.dynamic("room", Route::default()).unwrap(); + + let mut pending = Box::pin(consumer.request_broadcast("room/alice")); + assert!((&mut pending).now_or_never().is_none()); + let request = queued(&stale).await; + + let winner = producer.dynamic("room/alice", Route::default()).unwrap(); + request.reject(Error::NotFound); + let source = broadcast::Info::new().produce(); + queued(&winner).await.accept(&source); + + let mut result = None; + settle(|| { + result = (&mut pending).now_or_never(); + result.is_some() + }) + .await; + assert!(result.unwrap().is_ok(), "the beaten route's refusal ended the request"); + } + /// `routed_broadcast` treats a handler's rejection as the table's verdict: /// it waits for the table to move instead of re-asking the same route. #[tokio::test]