Skip to content

feat(net)!: announcements are prefix routes - #3225

Merged
kixelated merged 17 commits into
devfrom
claude/prefix-routing-refactor-5c22fd
Sep 1, 2026
Merged

feat(net)!: announcements are prefix routes#3225
kixelated merged 17 commits into
devfrom
claude/prefix-routing-refactor-5c22fd

Conversation

@kixelated

@kixelated kixelated commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

What

IETF moq-transport's *_NAMESPACE messages are specified as prefix routes, but we treated
them as per-broadcast announcements. This embraces the route model everywhere: an
announcement is now a route, a claim that paths under a prefix can be served, and it is
up to the application to decide that a path is a broadcast and request it.

Core model (moq-net):

  • origin::Producer::announce(Route) -> Announcement: advertise a prefix explicitly. The
    Announcement guard keeps it advertised; update() re-prices it (hops/cost); dropping it
    retracts. origin::Route { prefix, hops, cost } is the new announced unit.
  • origin::Consumer::announced() yields announce::Update { route, active }, never a
    broadcast::Consumer. Resolution is explicit: request_broadcast(path) walks local
    broadcasts, then the most specific covering route (served on demand by the session that
    announced it), then a dynamic() fallback.
  • create_broadcast(path) no longer takes a Route and no longer announces. The
    convenience publish_broadcast(path) -> (broadcast::Producer, Announcement) does both,
    and is what the FFI's create_broadcast maps to (so wrappers keep announcing exact paths
    by convention). Consumer::routed(path) awaits a covering route (replaces
    announced_broadcast in the Rust API; the FFI keeps announced_broadcast as
    routed-then-request).
  • broadcast::Broadcast goes back to being a simple path: Route, Cost, epoch, and all
    the set_route/route_changed/routes() machinery are gone from it. Cost/MAX_COST/
    DRAIN_COST move to origin.
  • origin::Producer::dynamic() now works with Rust consumers: announce a short prefix (for
    example .dash) and serve whatever is requested beneath it.

Wire: no byte-format change to the lite-06 announce messages. The suffix is
reinterpreted as a route prefix, and the drafts are updated to say so (Epoch, which was
spec-only and never implemented, is removed; draft-lcurley-moq-broadcast is retired).
IETF PUBLISH_NAMESPACE/SUBSCRIBE_NAMESPACE now map naturally: each namespace is a route.

Applied to every language: Rust workspace, JS (@moq/net route table with lazy
materialization), Python, Swift, Kotlin, Go, C (libmoq), and wasm, plus the docs and the
IETF drafts.

Deliberate semantic changes

  • Failover is abort-and-resubscribe, not a transparent splice. Cross-session splicing
    relied on content identity (epoch / first hop) that per-broadcast announces carried; with
    epoch gone the splice is unsound, so a subscription through a dead route ends and the
    consumer resubscribes through the best remaining route (matching "refusals are
    authoritative; abort so the decoder resubscribes"). Local fronts (two create_broadcast
    at one path in the same origin) still splice, newest source wins.
  • The per-broadcast warm-cost discount is dropped (COST_LINGER, the handover hold, the
    reselect wait). The wire keeps the warm/cold pair and the draft keeps the discount as a
    MAY, but the relay forwards accumulated costs only for now. Back to the drawing board
    with route-level identity.
  • Takeover is per-request: new requests resolve through the best current route (newest
    wins ties, and identical-meta reannounces are invisible), but a new announce no longer
    ends the incumbent's existing subscriptions; those end when their session does.
  • Known follow-ups: moq-hls loses its warm-track linger (it re-resolves per fetch); JS has
    no producer-side announce(prefix) yet (it consumes prefix routes; publishing still
    announces exact paths).

API consistency round (post-review)

  • Prefix newtype: what a route covers is its own opaque type with covers() and
    per-segment matching, so wildcard patterns can land later without a breaking change.
  • Route { hops, cost }: the prefix is no longer part of the route; it is what the
    route covers. announce(prefix, route), Update { prefix, route, active }, and
    update(route) can no longer express a prefix change at all (the earlier runtime
    NotFound check and its test are deleted as unrepresentable).
  • announce::Producer is the advertisement guard (was origin::Announcement),
    completing the split-handle symmetry with announce::Consumer/announce::Update. The
    old announce::Producer, a consumer factory with one method, is deleted.
  • publish_broadcast removed: announcing before a broadcast has tracks is usually a
    bug, so the blessed order is create, populate, announce. moq-cli's Publish attaches
    its announcement after constructing the catalog for the same reason.
  • JS parity: insertRoute is now Producer.announce(prefix, provider) (the fused
    announce+dynamic: serve a subtree from one provider), and the announce Event carries
    prefix rather than path. FFI + all wrappers take announce(prefix, route) with
    MoqRoute reduced to hops and cost.

Wildcards (planned ahead)

Prefix is opaque with new/covers/as_path, and matching is segment-wise
intersection in one place, so a future pattern type (prefix + suffix, per the moq.pro
wildcard quest) extends Prefix internally without touching Route or any signature.

Cross-Package Sync

  • rs/moq-net wire/API -> js/net, doc/concept, drafts/draft-lcurley-moq-lite.md: done.
  • rs/moq-ffi -> rs/libmoq, py/, swift/, kt/, go/wrapper, doc/lib/*: done.
  • rs/moq-relay -> doc/bin/relay/ (cluster costs, 1+1): done.
  • drafts: moq-lite rewritten (announce = routes, Epoch removed), moq-cluster updated
    (metadata-only updates, no content identity), moq-broadcast retired.

Bugs found while validating

  • Lost wakeup in the lite announce loop (AnnouncePrefix): the Run arm served
    route requests before parking on the announce decode, so a request that arrived
    after a route attached in the same pass had no registered waiter and nothing else
    re-polled the machine. Deterministic on the internal unix listener (fast in-process
    connects), hidden by latency elsewhere. Fixed by draining buffered announces first
    and registering on every route's request queue before parking.
  • Fetch-gate gap in ServeLoop::new: track::Request::accept releases the
    request's built-in fetch handler before the relay's own dynamic() registered, so a
    cache-miss fetch_group issued during the TRACK_INFO round trip drained as
    NotFound. Fixed by grabbing the dynamic from the request before accepting
    (regression covered by broadcast_moq_lite_05_fetch_webtransport).
  • Dropped announcement guards: publish_broadcast returns (Producer, Announcement), and three callers held the producer but let the guard die early,
    silently unannouncing: moq-cli's Publish (stdin/capture publishing was
    unannounced the moment setup returned), moq-bench's per-connection publish loop,
    and several tests. Publish::new/capture now take the pair.

Testing

  • Full Rust workspace: cargo nextest run --workspace (all pass) + clippy -D warnings,
    fmt, rustdoc.
  • The goaway cluster tests were reworked to the new failover contract: the draining
    session serves through the handover window, the subscription ends when it closes,
    and a resubscribe carries on; route re-pricing surfaces as metadata updates, never a
    retraction. The diamond test also stops expecting live-edge catch-up: on lite-05 Max
    Age is a staleness tolerance, so a fresh subscription starts at the live edge (dev's
    feat(net)!: resolve the subscribe start from max age, with Group Start as an absolute floor #3158 semantics, unchanged here).
  • JS: full typecheck + package tests.
  • just drafts check.
  • Smoke matrix: just test smoke-full (wire + ffi change).

🤖 Generated with Claude Code

(Written by Claude Fable 5)

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Aug 31, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-01T05:32:57.523775Z e0189ad New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

💡 Codex Review


P1 Badge Remove the generated fuzz corpus from this commit

This unrelated change adds 24,037 generated files under fuzz/seeds and fuzz/corpus, plus a crash artifact, making every clone and worktree materialize tens of thousands of extra files and slowing repository-wide status, diff, and packaging operations. Keep only a minimal regression input if the crash is relevant to this routing change, rather than committing the generated fuzzing output.


if self.origin.routed(&self.path).await.is_none() {
return Err(MoqError::Closed);
}
let broadcast = self.origin.request_broadcast(&self.path).await?;

P2 Badge Retry when the observed route disappears before resolution

When a route is retracted or replaced after routed() returns but before request_broadcast() acquires the route, this advertised-wait API terminates with Unroutable instead of continuing to wait for the next covering route, despite documenting Closed as its only non-successful terminal condition. This makes reconnect and failover churn visible as a spurious terminal failure; the same two-step race is duplicated in rs/libmoq/src/origin.rs:173-177, so both FFI surfaces need to keep the wait and resolution in one retrying operation.

AGENTS.md reference: AGENTS.md:L196-L200


pub fn update(&self, route: Route) -> Result<(), Error> {

P2 Badge Reject a mismatched prefix when updating a route

If a caller passes a Route whose prefix differs from the original announcement, this method silently ignores that prefix, applies its hops and cost to the old prefix, and returns Ok(()). The caller can therefore believe it updated the advertised route while consumers still see the original prefix; validate the invariant or expose an update type that contains only the mutable metadata.

AGENTS.md reference: AGENTS.md:L165-L170

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

kixelated and others added 9 commits August 31, 2026 09:19
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…uite

The announce Run arm now drains buffered announces before serving route
requests, so a route attached in the same poll registers its request-queue
waiter before the machine parks. Serving first left a request that raced the
attach with no registered waiter and nothing else to re-poll the machine,
which deadlocked the internal unix listener deterministically.

TrackServe registers its fetch handler before accepting the track request:
accept releases the request's built-in fetch gate, so a cache-miss fetch
queued during the TRACK_INFO round trip was drained as NotFound in the gap.

moq-cli's Publish and moq-bench's publish loop now hold the announcement
alongside the producer; both were dropping the guard early, silently
unannouncing the broadcast. The goaway cluster tests are reworked to the new
failover contract: the drained session ends its subscriptions and the
subscriber resubscribes; re-pricing surfaces as metadata updates, never a
retraction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The announced-broadcast wait in moq-ffi and libmoq resolved routed() and
request_broadcast() as two steps, so a covering route that retracted in
between (failover churn), or one that covered the path while nothing served
it, surfaced as a spurious Unroutable instead of continued waiting. Both now
loop, waiting for the path's coverage to change before retrying. The announce
cursor dedupe also keys on servability, so a served route replacing an
advertise-only one under identical metadata is delivered rather than hidden,
which is what wakes such a waiter.

Announcement::update silently ignored a Route naming a different prefix,
applying the metadata under the old one while the caller believed the new
prefix was advertised. The prefix is now pinned at announce time and a
mismatch returns NotFound.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@kixelated
kixelated force-pushed the claude/prefix-routing-refactor-5c22fd branch from 14ceaca to 37b0230 Compare August 31, 2026 17:02
@kixelated

Copy link
Copy Markdown
Collaborator Author

All three addressed in the force-pushed branch (now also rebased onto the latest dev):

  • Fuzz corpus: the 24k generated files under fuzz/seeds/fuzz/corpus and the crash artifact were untracked local state swept in by the first commit; the branch history was rewritten to exclude them entirely (they were also what broke CI: the changed-file list blew past ARG_MAX in the diff-aware just recipes, exit 126).
  • Two-step routed/request race: both moq-ffi's announced_broadcast and libmoq's consume_announced now loop, waiting for the path's coverage to change before retrying an Unroutable resolution, so retraction churn keeps the wait alive instead of surfacing a spurious terminal failure. The announce-cursor dedupe now also keys on servability so a served route replacing an advertise-only one under identical metadata wakes such a waiter. Regression test: announced_broadcast_survives_an_unservable_route. One known corner remains: a local announce(P) cover plus a later local create_broadcast(P) with identical metadata is invisible to announce cursors (the broadcast lands in the trie, not the route table), so a waiter parked on exactly that path under its own cover stays parked; noted as a follow-up rather than special-cased here.
  • Mismatched update prefix: the prefix is now pinned on the Announcement at announce time and update returns NotFound for any other prefix instead of silently applying the metadata under the old one. Regression test: update_rejects_a_mismatched_prefix.

(Written by Claude Fable 5)

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 37b0230e63

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread js/net/src/origin.ts Outdated
Comment on lines +730 to +732
for (const [path, providers] of remote ?? []) {
const suffix = Path.stripPrefix(prefix, path);
if (suffix !== null && fronts[0]) next.set(suffix, fronts[0]);
if (suffix !== null && providers[0]) next.set(suffix, providers[0]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Clamp covering routes into scoped announcement streams

When the origin contains a broad route such as room, calling announced("room/alice") drops it because Path.stripPrefix("room/alice", "room") returns null. This means the origin-backed Announce.Broadcast used by @moq/watch never receives the empty-suffix active event and waits indefinitely, even though request("room/alice") can resolve through that route. Include routes that cover the requested prefix and present them as an empty suffix, matching the Rust origin and wire behavior.

AGENTS.md reference: AGENTS.md:L194-L200

Useful? React with 👍 / 👎.

Comment thread rs/moq-stats/src/aggregate.rs Outdated
Comment on lines +255 to +258
if update.active {
let node = Node {
reader: Reader::Resolving(self.origin.request_broadcast(&path)),
last: None,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Re-resolve stats nodes after an invisible route failover

With redundant routes that have the same prefix, hops, and cost, the origin intentionally suppresses an announcement when the winning route entry changes because its visible metadata is identical. If the session serving this newly created reader then ends, advance transitions the node to Reader::Ended; the backup remains announced, so no new active update reaches this branch and the node is permanently omitted from the aggregate. Re-request the broadcast when a reader terminates while its route is still active, rather than relying exclusively on another announcement.

Useful? React with 👍 / 👎.

Comment thread rs/moq-stats/src/aggregate.rs Outdated
Comment on lines +255 to +262
if update.active {
let node = Node {
reader: Reader::Resolving(self.origin.request_broadcast(&path)),
last: None,
};
// A replacement (failover) drops the old value until the new
// subscription catches up.
self.nodes.insert(absolute, node).is_some_and(|old| old.last.is_some())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve stats readers across metadata-only route updates

A repeated active update for an existing prefix can be only a hops or cost change, but this branch always replaces the live node reader and clears its last cumulative snapshot. During routine route repricing the next merged result therefore temporarily excludes that node, producing a counter regression that downstream interprets as a fresh segment, before a new subscription restores the values. Keep the existing reader for metadata-only active updates instead of treating every active event as a replacement.

Useful? React with 👍 / 👎.

…der in the retire test

The tsc outputs under js/common are emitted in place by a root tsc -b and were
swept into the branch, tripping biome in CI. The retired-route test also handed
the table the producer's only consumer handle, so releasing the materialized
front closed the broadcast; a session provider holds its own handle for the
session's lifetime and lends out clones, which is what the test now models.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

let announce_init = lite::AnnounceInit { suffixes: init };
stream.writer.buffer(&announce_init)?;

P1 Badge Track routes sent in the initial legacy snapshot

When a Lite01/Lite02 announce stream starts with active routes, these suffixes are encoded into ANNOUNCE_INIT but never inserted into self.live. If one of those routes is later retracted, retract() treats it as never sent and emits no Ended, so the peer retains a stale route until the entire announce stream closes. Populate self.live from the final initial set before sending the snapshot. (Written by GPT-5.6 Sol)

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread rs/moq-net/src/model/origin.rs Outdated
Comment on lines +2379 to +2383
let best = routes
.iter()
.filter(|entry| cursor.visible(entry))
.filter(|entry| cursor.presented(&entry.prefix.as_path()).contains(presented))
.min_by_key(|entry| route_order(&presented.as_path(), entry));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Prefer the most specific route after scope clamping

When a cursor is scoped below a broad route, that route is clamped to the cursor root and can collide with a genuinely narrower route at the same presented prefix. This selection compares only cost and hops, so it may advertise the cheaper broad route's metadata, while best_server() resolves requests through the most-specific original prefix. A scoped relay can therefore forward costs and hops for one upstream but serve the traffic through another; retain original-prefix specificity as the first selection key so announcements match request routing. (Written by GPT-5.6 Sol)

Useful? React with 👍 / 👎.

runtime_workers/runtime_uring (Linux-only), the moq-uring tests and bench
(Linux-only), and moq-cli's play/transcode verbs (off-by-default features)
never compile on the macOS host, so the announced_broadcast and announce
Update call sites in them survived the refactor until CI's Linux build.
Waits become routed() and resolution goes through request_broadcast, same as
every other migrated site.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7d99bc3b8b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread rs/moq-cli/src/transcode.rs Outdated
Comment on lines 130 to 132
routed = consumer.routed(&source_path) => {
routed.context("origin closed before the source broadcast was announced")?;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Retry source resolution across route churn

If the covering route retracts after routed() completes but before the following request_broadcast() snapshots the table, such as during a reconnect or upstream failover, the request returns Unroutable and the transcode command exits even when a replacement route is about to arrive. Keep watching this path's announcement cursor and retry resolution after coverage changes instead of treating this transient gap as terminal.

Useful? React with 👍 / 👎.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…etric

An advertisement is now a Prefix (what it covers) paired with a Route (the
hops it took and what using it costs). Prefix is opaque so richer matching
(wildcard patterns) can land without a breaking change, and it owns the
coverage questions (covers, per-segment matching). announce(prefix, route)
returns announce::Producer, the write half of the announce channel: hold it
to stay advertised, update(route) to re-price, drop to retract. The old
announce::Producer, a consumer factory with a misleading name, is deleted,
and update() can no longer name a prefix at all, so metadata landing under a
stale prefix is inexpressible rather than checked at runtime.

publish_broadcast is removed: announcing before a broadcast has tracks is
usually a bug, so the blessed order is create_broadcast, populate, announce.
moq-cli's Publish takes the broadcast and attaches the announcement after
construction for the same reason.

The JS origin matches: insertRoute is now announce(prefix, provider), the
announce Event carries prefix rather than path, and the docs bless it as the
way to serve a subtree. FFI and every wrapper take announce(prefix, route)
with MoqRoute reduced to hops and cost.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 95a7ed3c26

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +675 to +676
// First announce, a stale announce being replaced, or a metadata update.
None | Some(PendingUpdate::Announce(_)) => PendingUpdate::Announce(meta),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve retractions after queued metadata updates

When a consumer has already received the active route, a metadata change queues another PendingUpdate::Announce, so this variant no longer necessarily represents an unseen initial announcement. If that route is then retracted before the consumer drains the metadata update, apply_unannounce treats the queued announce as unseen and cancels both events. The consumer never receives active: false and can continue advertising or using a permanently stale route after the upstream session has disappeared.

Useful? React with 👍 / 👎.

kixelated and others added 4 commits August 31, 2026 17:49
…ouncement

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Carries dev's Hop rename (#3252) through the prefix-route refactor. Conflicts
were resolved by keeping this branch's route model and re-applying the rename:
Origin -> Hop, OriginList -> Hops, InvalidOrigin -> InvalidHop::Range,
with_peer_origin -> with_peer_hop, cluster Peer.origin -> Peer.hop,
origin::Dynamic::info() -> hop(), the --origin flag and SETUP parameter
terminology, and moq-tokio's server::Config.

Also lands the review fixes:

- announce cursors track delivered prefixes, so a retraction arriving after an
  undelivered metadata update still reaches the consumer instead of cancelling
  with it (regression test included)
- sync_cursor filters to the most specific covering prefix like best_server, so
  a scoped cursor advertises the metadata of the route a request resolves
- new origin::Consumer::routed_broadcast composes routed + request_broadcast
  with the coverage-churn retry every caller needs; moq-ffi, libmoq, moq-cli
  transcode, moq-rtmp, moq-gst, and moq-relay web now share it instead of
  hand-rolling (or missing) the loop
- moq-stats keeps a node's live reader across metadata-only route updates and
  re-resolves when a subscription ends, so an invisible identical-route
  failover no longer drops the node from the aggregate permanently
- js announced() clamps a broader covering route to the root suffix, matching
  request() resolution and the Rust prefix intersection (test included)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An identical standby takes over without any announce update, so a request
that queued on the dying route and then failed Unroutable must retry through
the already-updated table instead of parking on the announce stream (or, in
moq-stats, going permanently Ended). An instant Unroutable still parks or
ends: it means nothing serves the path right now, and each immediate retry
consumed a real retraction, so neither path can spin.

The stats re-arm also drops its dead reader before re-requesting, so its own
handle no longer keeps a dying served broadcast resolvable from the cache.

Regression test: routed_broadcast_survives_serving_route_retraction, with two
back-to-back retractions so the announce stream's initial coverage replay
cannot mask a missing retry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…timing

Requesting::is_queued reports whether the request was handed to a serving
route, fixed at request time under the table lock. routed_broadcast and the
stats re-arm use it instead of observing whether the first poll was Pending,
which misclassified a request whose route retracted between creation and
first poll as an instant Unroutable and parked on an announce update that an
identical standby never sends.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@kixelated
kixelated enabled auto-merge (squash) September 1, 2026 05:28

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e0189add8d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +3238 to +3241
// The route was retracted between the lookup and its lock; fall
// through to the fallback handler.
drop(serve);
state = self.shared.lock();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Recheck the route table after a retraction race

When the selected route retracts between best_server() and server.lock(), this branch reacquires the table but proceeds directly to the fallback queue. If another served route already covers the path, such as an identical standby promoted without an announcement update, a direct request_broadcast() therefore returns Unroutable or uses the fallback despite the live replacement. Rerun route selection after reacquiring the table so this lower-layer race is handled before falling back.

AGENTS.md reference: AGENTS.md:L141-L145

Useful? React with 👍 / 👎.

Comment thread doc/lib/rs/env/native.md
Comment on lines +120 to +124
if !update.active {
tracing::info!(prefix = %update.route.prefix, "route ended");
continue;
};
}
let broadcast = consumer.request_broadcast(&update.route.prefix).await?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Read the prefix from the announcement update

The new example accesses update.route.prefix, but origin::Route now contains only hops and cost while the prefix is the separate update.prefix field, so this documented subscription loop does not compile. The same invalid access was added to doc/lib/rs/index.md; update both examples to use update.prefix so the Rust documentation matches the new API.

AGENTS.md reference: AGENTS.md:L194-L199

Useful? React with 👍 / 👎.

@kixelated
kixelated merged commit d26f22e into dev Sep 1, 2026
11 checks passed
@kixelated
kixelated deleted the claude/prefix-routing-refactor-5c22fd branch September 1, 2026 05:48
kixelated added a commit that referenced this pull request Sep 1, 2026
Two verified corrections from the review pass.

Adoption and resume need different hops. The pre-#3225 front kept both:
FrontState.publisher was hops.iter().next() (the publisher, for splice
decisions) while handover_allowed and the hold keyed on hops.iter().last()
(the adjacent carrier), with the rank hash over that last hop. rank.md
pointed at the first hop, which every alternate route to one publisher
shares by construction, so two distinct carriers would have compared equal
and the gate could have permitted the mutual adoption the hold exists to
prevent.

2985: two of its three js/net sites are already fixed on dev by keying on
the routing front (lite runAnnounce diffs ended-then-active; ietf keeps an
offered map that clears a refusal on republish). Only #resolveTrackInfo is
still path-and-track keyed. Rescoped to that, and dropped the route-resume
coupling, which the remaining cache bug does not need.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
kixelated added a commit that referenced this pull request Sep 1, 2026
#3225 removed the actively-carrying warm discount, its ceiling exemption,
and the (cold, hash) adoption rank with its re-parenting delay; the relay
forwards accumulated costs only. The draft still specified all of it
normatively, which misleads anyone implementing against it. Trim the text
to what dev does, keeping the Warm and Cold fields and the selection
order, and record the removal in the moq-lite-06 changelog.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
kixelated added a commit that referenced this pull request Sep 1, 2026
…g tier

route-resume: the abort-and-resubscribe behavior was specified by #3225,
not lost by accident, so the quest now states the reversal as a decision:
routes keep hops/cost, a relay resumes/stitches across routes with the
same non-zero first hop, Epoch stays dead, and the implementing PR amends
the draft's no-splice paragraph in the same change. Resized to XL since
dev has no per-path front spanning routes to hang the identity on.

wildcard: specificity-first already decides concrete-versus-wildcard, so
the pricing bullet no longer claims there is no such rule. Cost orders
within a tier; the seed floor's work is among equal-specificity claims
(standby vs running concrete, warm-advertise's exact-path routes), and
the shadow a live concrete claim casts over the pool is documented as an
accepted consequence bounded by the claiming session.

lite-draft-routing: dropped; the work is already open as #3278, so the
quest would land completed. References now point at the PR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
kixelated added a commit that referenced this pull request Sep 2, 2026
A subscription whose serving route dies now resumes through the next
best covering route when both routes name the same non-zero first hop,
re-splicing at a group boundary. Different or unknown (0) first hops
still end the subscription, so one publisher's subscribers are never
spliced onto another's frames and two anonymous peers never pass for one
reconnecting. This restores the seamless failover that #3225 removed,
keyed on the routing identity that stayed on the wire.

request_broadcast now resolves a routed path to a per-(path, exclusion)
front: a spliced broadcast whose watcher materializes it from the best
covering route through the existing per-route request queues, re-selects
when the table changes (a drain reprice migrates before the session
dies; a metadata-only reprice changes nothing), and retries a retracted
route's failure through the survivor. Fronts are keyed per split-horizon
exclusion so a failover can never adopt a route flowing back through one
of its own readers. The spliced-track machinery (resume.rs, serve_track)
is reused unchanged.

The draft's no-splice paragraph becomes the first-hop rule, recorded in
the moq-lite-06 changelog. The goaway cluster and route migration tests
return to their pre-#3225 seamless expectations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
kixelated added a commit that referenced this pull request Sep 2, 2026
A subscription whose serving route dies now resumes through the next
best covering route when both routes name the same non-zero first hop,
re-splicing at a group boundary. Different or unknown (0) first hops
still end the subscription, so one publisher's subscribers are never
spliced onto another's frames and two anonymous peers never pass for one
reconnecting. This restores the seamless failover that #3225 removed,
keyed on the routing identity that stayed on the wire.

request_broadcast now resolves a routed path to a per-(path, exclusion)
front: a spliced broadcast whose watcher materializes it from the best
covering route through the existing per-route request queues, re-selects
when the table changes (a drain reprice migrates before the session
dies; a metadata-only reprice changes nothing), and retries a retracted
route's failure through the survivor. Fronts are keyed per split-horizon
exclusion so a failover can never adopt a route flowing back through one
of its own readers. The spliced-track machinery (resume.rs, serve_track)
is reused unchanged.

The draft's no-splice paragraph becomes the first-hop rule, recorded in
the moq-lite-06 changelog. The goaway cluster and route migration tests
return to their pre-#3225 seamless expectations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
kixelated added a commit that referenced this pull request Sep 4, 2026
Three independent pieces of fallout from `Merge main into dev` (6947217),
where a conflict-free textual merge was not a semantic one. Each was
independently fatal to `just check`, and the second and third only surfaced
once the first stopped failing the build.

1. `rs/moq-tokio/tests/broadcast.rs` did not compile. The merge brought
   main's `broadcast_moq_transport_20_current_group_join` in verbatim, but
   on main that test lives in `rs/moq-native/tests/broadcast.rs` and
   predates both #3225 and the moq-native to moq-tokio split, so it used
   `Origin::random()`, `create_broadcast(path, Route)`,
   `moq_native::{Server,Client}Config`, and `Update::broadcast`. Ported onto
   dev's API, matching the neighbouring tests. The merge had also glued
   `next_announce`'s doc comment onto the new test's.

2. `cargo shear`: `rs/moq-cli` declared `humantime` with no uses. The
   usage-rs migration (#3030) replaced clap's
   `value_parser = humantime::parse_duration` and dropped the dependency in
   the same commit; the merge took main's side of the manifest.

3. `cargo sort`: `moq-sock` sat above `moq-rtc` and `moq-rtmp` in the root
   `[workspace.dependencies]`.

The assertions the ported test exists for are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant