Skip to content

feat(net)!: js/net speaks lite-07's route layer - #8443

Open
kixelated wants to merge 10 commits into
mainfrom
quest/m2/cluster-routing/routes/js
Open

kixelated wants to merge 10 commits into
mainfrom
quest/m2/cluster-routing/routes/js

Conversation

@kixelated

@kixelated kixelated commented Nov 10, 2026 •

Copy link
Copy Markdown
Collaborator

js/net now speaks lite-07's route layer as Rust does. It reads and writes ROUTE messages and ANNOUNCE_FLOOR, applies each route's claims a batch at a time, echoes every ROUTE_END, and pins each request to the origin node it selected. A browser stays non-transit: it advertises its own origin's claims under one route of its own and forwards nothing.

Quest: deletes quest/m2/cluster-routing/routes/js.md and its links in the routes epic README and in acceptance.md, whose Required section it emptied.

Decisions

  • Design checkpoint after review round 3, which found announcement and consume selection disagreeing after a re-price created a tie, the same class as rounds 1 and 2: ✅ break ties as rs/moq-net does, in every JS selector (maintainer, 2026-11-10; supersedes an earlier pick of B, rethink; rejected: A, reorder byPath only for a new claim or restart; C, merge as is).
    • bestCandidate in js/net/src/internal.ts ports best_route. Only an instance's highest epoch is eligible. Routes rank by route_order, through compareRouteCandidates: the rendezvous hash of each lite-07 claim's origin node, or the instance and chain hashes. The newest claim wins a full tie, as Rust's final Reverse(entry.id).
    • Rust skips the hash for a route that entered on a client session (Link::Local) at its own path, which is every route a browser holds. There the newest claim decides. A claim counts as new when it starts or restarts, and a re-price keeps its place, as a Rust entry keeps its id through update.
    • The announcements, BroadcastCache.instance, and BroadcastCache.node all rank through it.
    • Tests: lite07_ties_match_js in rs/moq-net pins the node hash and both tie rules. "a lite-07 tie at a claim's path goes to the newest claim, a re-price keeping its place" checks that the announcement and the consume agree, and fails on a817f15db1. "lite-07 children of a prefix two origins claim spread by node" asserts Rust's pinned hashes and pick.

Changes

Codec (lite/announce.ts, lite/instance.ts, the request messages)

  • New messages: ROUTE_START, ROUTE_UPDATE, ROUTE_HOLD, ROUTE_END, and ANNOUNCE_FLOOR. On the reply side, ROUTE_REQUEST and the ROUTE_END echo.
  • Refused on both sides: the last seqno, Node ID 0, and any other reply type.
  • An ANNOUNCE_START names its Route ID in place of the hop chain, ANNOUNCE_RESTART carries no hops, and ANNOUNCE_OK is empty.
  • Every TRACK, TRACKS, SUBSCRIBE, FETCH, and DATAGRAMS request carries a Node ID right after its instance pin; 0 lets the publisher choose.
  • version.ts renames hasNodeId to hasRoutes. On lite-07, SETUP's 0x5 parameter carries the publishing origin's Node ID.

Publisher (lite/publisher.ts, new route.ts)

  • Each origin has one route identity (Own): a random 63-bit Node ID shared by its sessions, a seqno, and a revision.
    • The seqno advances when a stream is answered or a ROUTE_REQUEST asks for a newer one.
    • The revision advances when the origin's claims change. A value that would reach 2^64-1 rotates the Node ID instead.
  • The answer follows publisher/routed.rs:
    • ROUTE_START goes out before the first claim.
    • Each batch is closed by a floor. The floor certifies the revision only while neither the grant nor the handle's scope leaves anything out of the requested prefix.
    • After the last claim is withdrawn: its batch, then the floor, then ROUTE_END. The route restarts only after the echo.
    • SYNCED is sent once every route has a floor.
    • An echo for a route that is not ending is a protocol violation.
    • A graceful close ends the route.
  • A request that names another origin node is refused Unroutable.

Subscriber (lite/subscriber.ts)

  • #receiveRouted tracks each route and its claims.
    • Each route and each claim holds one announce slot.
    • A second route to one node, or a second claim at one path under one route, is a violation. Both are map lookups.
    • Each route's pending changes are applied whole at its floor.
  • For each path it reports the best claim using compareRouteCandidates, with the newest winning a tie.
    • Cost is the claim's cost plus the route's metric plus a link cost of 1, matching Rust's default.
    • available is false while the route is held.
  • Events:
    • ROUTE_HOLD and ROUTE_UPDATE report update, flipping RouteInfo.available.
    • ROUTE_END takes the route's claims offline (not end), unless another route still serves the path. The subscriber then echoes it.
    • GOAWAY drains every reported path.
  • A consume records the selected route's node, and its SUBSCRIBE, TRACK, TRACKS, FETCH, and DATAGRAMS requests name it.

Origin (origin.ts): applies the 2026-10-27 maintainer decision on moq-dev/moq#6947. A detached or pinned request now follows its instance's cheapest route, as Rust's does, instead of staying on the route it first resolved while that route still serves. pick and failover are merged into one follow.

Interop

  • rs/moq-net/fixtures/lite.json gains 13 lite-07 route cases, replayed by both corpus.rs and corpus.test.ts, with a new announce_reply message kind.
  • announce.test.ts decodes Rust's lite::compress GOLDEN, and its encoder writes JS_LITERAL byte for byte. Rust's compress.rs comments now point at that test.
  • The JS lite-07 lanes are restored:
    • JS_LITE07_SKIP, js_lite07, and the needs_js version guard are removed from interop.sh and socket.sh. Native JS prints its grant again.
    • The lite-07 catalog reader is back.
    • tracks_interop is back in just test interop.
    • The lite-07 legs of max_age_relay_javascript and relay_fill_javascript are back.
    • datagram_tracks_javascript has its original ignore reason, and its test-inventory entries are gone.
    • The Datagram tracks interop workflow step and test drain in the nightly matrix are back.
    • The replacement media quarantine is removed. The Firefox audio-tone quarantine stays.
    • Review fixes: the publisher applies announce replies as they arrive, with no queue. The subscriber files every route's claim in the consume cache under its own owner, so each consume ranks every node's claim for its own path, epochs included. The cache gives a tie at the prefix itself to the newest stream. A consume names the node of its own instance's best route.
    • Ties break as in Rust, through bestCandidate; see Decisions. The announce stream moves a claim to the newest only when it starts or restarts, as the consume cache does.
    • compareRouteCandidates keys each candidate on its own, as route_order does: a lite-07 claim by the rendezvous hash of its node, any other by its instance and chain hashes. The origin's announcement order counts a lite-07 claim as one hop (routeHops), as requests already did.
    • The origin bridge carries every claim. routes() takes an internal claims option (RouteOptions). With it, a lite-07 stream reports each origin node's claim at a path as its own route, as Rust's routed.rs forwards each claim into its origin. forwardAnnounced asks for it and keys its routes by path and node. The origin then spreads a prefix's children by node and resolves a request pinned to a claim that does not win. The public announced() still reports one route per path.
    • A request through a received route consumes the session pinned to that route's instance (Established.consume(path, instance), BroadcastCache.pinned). A handover to a report of another instance restarts the route. Either way, one instance's content is never served under another's identity.
    • The publisher starts no route until every ended one is echoed, so a peer that keeps rotating our identity with a ROUTE_REQUEST for the last seqno, and withholds its echoes, holds one ended route rather than one per rotation. A closed publishing origin ends its claims and route, and its answer finishes, as the pre-lite-07 path does. Both were findings from a review of the earlier, unpushed B head.
    • The datagram_tracks relay is now a proxy, as max_age's became in moq-dev/moq#8023.

Choices made without asking

  • Floor merge is per stream. Each stream applies its own batches at its own floors. The BroadcastCache and the origin then rank streams and sessions against each other, as before.
    • The contract's cross-stream rule is not implemented: the highest certifying revision decides whether a claim exists.
    • Today a browser hears an origin through one upstream. Overlapping requests on one session come from the same relay and agree within one write.
    • A lagging stream can therefore keep a withdrawn claim only briefly, and a request through it fails Unroutable and is retried.
    • See Follow-ups.
  • A subscriber never sends ROUTE_REQUEST. It holds no feasibility distance, since it forwards nothing.
  • A peer declaring our own Node ID in SETUP is not refused. The draft's SHOULD protects a node that forwards. A browser cannot loop.
  • hops on a lite-07 route is empty, with node set. compareRouteCandidates counts such a route as one hop and spreads ties by the rendezvous hash of its node, as route_order does.
  • A graceful close's lite-07 claims go offline, not end. The publisher sends ROUTE_END, as Rust's does, and ROUTE_END withdraws nothing. close.test.ts now expects offline for both protocols.
  • The pre-lite-07 legacy publisher path no longer carries lite-07-only branches (restart in place, the instance on active), since lite-07 always takes the routed path.

Caused by

  • moq-dev/moq#5087 - kept a detached request on the route it first resolved while that route served, rather than following its instance's cheapest route.

Impact

  • Wire: moq-lite-07-wip only, which is unpublished. js/net moves from the pre-route lite-07 wire to the route layer Rust already speaks. Published versions are unchanged.
  • Public API: none. @moq/net's exported surface (api/index.api.md) is unchanged. The lite codec types and Own are internal.
  • Rust: test-only. Adds corpus cases and the announce_reply corpus kind, restores interop legs, and updates comments.

Checks

Run locally on d448e32e79 unless noted:

  • just check passes: 12936 Rust tests and the js/net suite. The pre-existing check c failure did not show up.
    • On aa4a00df0b and a817f15db1, after review rounds 1 and 2: just check passes again, with 3230 js/net tests.
  • just fix leaves nothing to change.
  • The Rust/JS interop suites from just test interop pass, including the restored lite_tracks_interop.
  • interop.sh --publishers rust,js --subscribers rust,js,js-native-node,js-native-bun passes all 79 cases. That covers native JS on lite-07: claims, track replacement, sockets, compress, the compressed catalog with its lite-07 reader, and the drop and tail legs.
    • Skipped: the Chromium route-key lane (Chromium 156 cannot prove), and finish (already quarantined).
  • just test max-age, relay-fill, and datagram-tracks pass, each now including its JS lite-07 leg.
    • datagram-tracks needed one fix: its relay is now a proxy, as max_age's already was. A lite-07 relay forwards a peer's claims only as a proxy.
  • Not run: the Python, Go, C++, C, and GStreamer clients of interop --all. They speak through moq-ffi, which this PR does not touch.
  • Not run: just test drain and the media replacement case. CI's nightly and media jobs cover them.
  • On 5c8f391f24, after the tie decision, the review fixes, and merging main: just check passes (12938 Rust tests, 3240 js/net tests), and so does quest check --base origin/main. Each new test fails without its fix.
  • CI: see the checks on the head commit.

Follow-ups

  • Cross-stream floor merge in js/net. Recommendation: plan it alongside P2P or multi-upstream browsers, when one origin's claims first reach a tab over several sessions. Until then it adds per-node withdrawal state for no observable gain.

Models

Size: L
Implementer: Claude Code, claude-opus-5-5, high effort
Implementer: Claude Code, claude-opus-5-5, medium effort (iterate, M)

(Written by Claude Opus 5.5 with high effort)

🤖 Generated with Claude Code

Review

Rewritten by quest review after each round.

  • Reviewer: Codex, gpt-6.1-sol, high effort
  • Session: 01a1c6ba-c521-72f0-be1d-303ad4312ee3, round 2
  • Head: 5c8f391
  • Base: origin/main at 27fb3eb
  • Score: 9/10, The merge preserves both earlier fixes, and source inspection found no new actionable regressions; builds and tests were not run as instructed.
  • Findings: none

kixelated and others added 3 commits November 10, 2026 01:28
js/net now reads and writes lite-07-wip's route layer as Rust does:

- The codec carries ROUTE_START/UPDATE/HOLD/END, ANNOUNCE_FLOOR, and the
  subscriber's ROUTE_REQUEST and ROUTE_END echo. Claims name a Route ID in
  place of a hop chain, and every TRACK, TRACKS, SUBSCRIBE, FETCH, and
  DATAGRAMS request names an origin Node ID (0 lets the publisher choose).
- A publisher answers from its origin's own route: one Node ID per origin,
  claims in batches closed by a floor, ROUTE_END after the last withdrawal,
  and a restart only after the echo. A browser forwards nothing.
- A subscriber applies each route's batch at its floor, reports the best
  claim per path with the route's metric, availability, and node, echoes
  every ROUTE_END, and pins each consume's requests to the node it selected.
- A detached request follows its instance's cheapest route, as Rust does.
- Rust and JS share route-message cases in fixtures/lite.json, the
  announce goldens match lite::compress, and the JS lite-07 interop lanes,
  datagram tracks, and nightly drain run again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A lite-07 relay forwards a peer's claims to its other peers only as a
proxy, as max_age's relay already does, so the restored JS datagram-tracks
leg through the relay found nothing to subscribe to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated kixelated changed the title js/net route layer feat(net)!: js/net speaks lite-07's route layer Nov 10, 2026
kixelated and others added 2 commits November 10, 2026 05:44
… inline

- The publisher applies each announce reply as it decodes it rather than
  queueing it, so a peer cannot grow a queue while our writes stall.
- The subscriber files every route's claim in the consume cache under its
  own owner, so a consume ranks each node's claim for its own path, epochs
  included, and spreads a prefix's children by rendezvous hash.
- A consume names the node now serving its instance, following failover.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ws its own routes

- BroadcastCache ranks the newest stream first on a tie at its own prefix,
  as refresh(), the origin, and Rust do, so a restart's consumers open the
  instance it reported.
- A consume names the node of its own instance's best route even once
  another instance wins the path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated

Copy link
Copy Markdown
Collaborator Author

Design change: the maintainer picked B (rethink) at the round-3 checkpoint. The announcement and the consume cache no longer rank separately. bestRoute in js/net/src/internal.ts is the one ranking (highest epoch per instance, then compareRouteCandidates, newest wins a tie) over a list ordered oldest first. BroadcastCache keeps that one ordered list per prefix, and instance(), node(), and the new claim(path, owners) all rank it. The subscriber's refresh() calls claim() with its stream's route owners, so byPath and its separate reorder rule are gone. The next review starts a new session with quest review --new.

(Written by Claude Opus 5.5 with medium effort)

kixelated and others added 2 commits November 10, 2026 09:34
- bestCandidate ports best_route's pick: epochs, route_order (the node,
  instance, and chain hashes), then the newest claim. The announcements,
  BroadcastCache.instance and BroadcastCache.node all rank through it.
- A claim is the newest when it starts or restarts, and keeps its place
  through a re-price, as a Rust entry keeps its id. The announce stream
  no longer reorders on every floor, so it and the consumes agree.
- lite07_ties_match_js pins the node hash and both tie rules in Rust,
  and the JS tests assert the same picks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ts origin

- No route starts until every ended one is echoed, so a peer that keeps
  rotating our identity (a ROUTE_REQUEST for the last seqno) and withholds
  its echoes holds one ended route, not one per rotation.
- A closed publishing origin ends its claims and route, then the answer
  finishes, as the pre-lite-07 path does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated

Copy link
Copy Markdown
Collaborator Author

Superseding the B note above: the maintainer then chose to break ties exactly as rs/moq-net does. bestCandidate ports best_route, and the announcements and consume cache both rank through it. It is pushed as 5339f99961. eb45e04578 fixes the two publisher findings from the review of 0b69afdc53: ended routes are bounded across identity rotations, and the answer finishes when the origin closes.

0b69afdc53 was never pushed and now diverges from this branch. Please do not force-push it. Rebuild on eb45e04578 if anything from it is still wanted.

(Written by Claude Opus 5.5 with high effort)

@kixelated

Copy link
Copy Markdown
Collaborator Author

Design change: the maintainer replaced the B pick with one rule. js/net breaks route ties exactly as rs/moq-net does, in every selector. bestCandidate in js/net/src/internal.ts ports best_route: the highest epoch per instance, then route_order through compareRouteCandidates (the node, instance, and chain hashes), and the newest claim on a full tie. A claim counts as new when it starts or restarts, and keeps its place through a re-price, as a Rust entry keeps its id. The announcements and BroadcastCache both rank through it. lite07_ties_match_js pins the tie in Rust, and the JS tests assert the same picks. The next review starts a new session with quest review --new.

(Written by Claude Opus 5.5 with high effort)

…d routes as Rust

- A request through a route the origin received consumes the session
  pinned to that route's instance (Established.consume takes an optional
  instance), so another instance's claim at the prefix, ranked apart, is
  never served under this one's identity.
- compareRouteCandidates keys each candidate on its own, a lite-07 claim
  by its node and any other by its instance and chain, as route_order does,
  instead of keying a lite-06 route at zero beside a lite-07 one.
- The origin's announcement order counts a lite-07 claim as one hop, as
  requests already did (routeHops).
- lite07_ties_match_js pins the mixed pick in Rust.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kixelated and others added 2 commits November 10, 2026 10:31
…re-pins

- routes() takes an internal claims option: a lite-07 stream then reports
  each origin node's claim at a path as its own route rather than only the
  best one, as Rust's routed.rs forwards each claim. The bridge asks for it
  and keys its routes by path and node, so the origin ranks every candidate,
  spreads a prefix's children by node, and resolves a request pinned to a
  claim that does not win. announced() keeps one route per path.
- A route handed to a report of another instance restarts, and drive reads
  the handle's instance per request, so a handover never serves one
  instance under another's identity.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ing/routes/js

# Conflicts:
#	js/net/src/origin.ts
@kixelated
kixelated marked this pull request as ready for review November 10, 2026 19:06

@kixelated kixelated left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Automated review by review (OpenAI)

Reviewed 5c8f391. No actionable defects found in the reviewed route-layer implementation and integration changes.

Direction: the wire codecs align with the Rust route messages, while publisher end/echo handling bounds retained routes. Subscriber batches commit at floors, the consume cache retains every node's claim, and the origin bridge now pins requests to the received instance. Shared ranking and the added tie/reprice, fallback, and pinned-instance regressions address consistency between announcement and consumption. Restored interop lanes exercise the newly supported lite-07 paths.

Scope caveat: cross-stream certifying-floor reconciliation remains explicitly unimplemented; this review does not establish multi-upstream consistency.

Verification limits: GitHub-only static review of the changed source, fixtures, tests, and workflow wiring, with Rust codec comparison. No builds, tests, browser/media runs, or performance measurements executed. Author-reported checks were not independently reproduced; the head's Check workflow was still in progress when inspected.

@coderabbitai

coderabbitai Bot commented Nov 10, 2026

Copy link
Copy Markdown
Contributor

Explain Change →

No actionable comments were generated in the recent review.

Walkthrough

The JavaScript network stack now supports Lite 07 routed announcements, including route starts, updates, holds, ends, floors, and replies. Requests carry origin-node information, and route selection supports node-aware ranking and instance pins. Publisher and subscriber flows handle claims, route changes, and failover. The changes also update Rust fixtures and tests, enable Lite 07 interop cases, and revise related workflow and quest documentation.

Merge Risk: Minimal · up to 5c8f3

This change adds lite-07 route-layer support to js/net and enables the matching interop lanes. No concrete merge-blocking defect was identified in the supplied evidence. Some interop clients were not run by the author, so ordinary CI results are worth confirming before merge.

Pre-merge checks: Passed 4 · Inconclusive 1

Failed Failed checks: 1 inconclusive

Check name Status Explanation Resolution
Docstring Coverage Inconclusive Docstring coverage is 69.77% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documenta… Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 4 passed

Check name Status Explanation
Title check Passed The title clearly identifies the main change: adding lite-07 route-layer support to js/net.
Description check Passed The description explains the route-layer changes, implementation details, interop work, and known follow-ups. It is directly related to the changeset.
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Full details: Docstring Coverage

Explanation

Docstring coverage is 69.77% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documentation-only edits and deletions within surviving functions are included; unchanged diff context and fully deleted functions are excluded. Analyzed 43 functions across 40 files. (6 skipped: 5 unsupported, 1 too large.)

Resolution

Write docstrings for the functions missing them to satisfy the coverage threshold.

Finishing Touches

Simplify code

  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Warning

Some tools did not complete. Review the errors below.

Biome (2.5.15)

js/net/src/announced.ts

Biome could not lint this file: configuration resulted in errors. Check the repository's Biome configuration and plugins.

js/net/src/connection/close.test.ts

Biome could not lint this file: configuration resulted in errors. Check the repository's Biome configuration and plugins.

js/net/src/connection/forward.test.ts

Biome could not lint this file: configuration resulted in errors. Check the repository's Biome configuration and plugins.

  • 31 others
Clippy (1.99.1)

Clippy execution timed out

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

Recent review info

Run configuration

  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 5c9d6477-2044-4a76-8963-b6e7e47ba553
Commits

Reviewing files that changed from the base of the PR and between 27fb3eb and 5c8f391.

Files selected for processing: 52

  • .github/workflows/interop.yml
  • .github/workflows/nightly.yml
  • js/net/src/announced.ts
  • js/net/src/connection/close.test.ts
  • js/net/src/connection/forward.test.ts
  • js/net/src/connection/forward.ts
  • js/net/src/connection/session.ts
  • js/net/src/consume.test.ts
  • js/net/src/consume.ts
  • js/net/src/hop.ts
  • js/net/src/integers.test.ts
  • js/net/src/internal.ts
  • js/net/src/lite/announce.test.ts
  • js/net/src/lite/announce.ts
  • js/net/src/lite/connection.ts
  • js/net/src/lite/corpus.test.ts
  • js/net/src/lite/datagram.test.ts
  • js/net/src/lite/datagram.ts
  • js/net/src/lite/fetch.test.ts
  • js/net/src/lite/fetch.ts
  • js/net/src/lite/instance.ts
  • js/net/src/lite/publisher.test.ts
  • js/net/src/lite/publisher.ts
  • js/net/src/lite/setup.ts
  • js/net/src/lite/subscribe.ts
  • js/net/src/lite/subscriber.test.ts
  • js/net/src/lite/subscriber.ts
  • js/net/src/lite/track.ts
  • js/net/src/lite/tracks.ts
  • js/net/src/lite/version.ts
  • js/net/src/origin-request.test.ts
  • js/net/src/origin.test.ts
  • js/net/src/origin.ts
  • js/net/src/route.ts
  • js/net/src/wire.ts
  • quest/m2/cluster-routing/routes/README.md
  • quest/m2/cluster-routing/routes/acceptance.md
  • quest/m2/cluster-routing/routes/js.md
  • rs/moq-net/fixtures/lite.json
  • rs/moq-net/src/lite/compress.rs
  • rs/moq-net/src/lite/corpus.rs
  • rs/moq-net/src/model/origin.rs
  • rs/moq-net/src/test_interop.rs
  • rs/moq-tokio/tests/integration/datagram_tracks.rs
  • rs/moq-tokio/tests/integration/max_age.rs
  • rs/moq-tokio/tests/integration/relay_fill.rs
  • sh/gh/test-inventory.allow
  • test/interop/README.md
  • test/interop/clients/js/media.ts
  • test/interop/interop.sh
  • test/interop/socket.sh
  • test/justfile
Files with no reviewable changes: 6

  • quest/m2/cluster-routing/routes/acceptance.md
  • quest/m2/cluster-routing/routes/README.md
  • sh/gh/test-inventory.allow
  • quest/m2/cluster-routing/routes/js.md
  • test/interop/socket.sh
  • test/interop/clients/js/media.ts

Included review availability: This review used your included allowance. Your plan provides up to 4 included reviews per hour; 1 remain after this review.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Important

The new lite-07 publisher only re-floors on a claim change, so a grant change that flips whether the prefix may be certified never reaches the peer. Rust re-floors every route on every permit change, and a Rust peer acts on the difference (it drops claims no floor covers when it adopts a view).

Reviewed changes

  • Route codec — ROUTE_START/UPDATE/HOLD/END, ANNOUNCE_FLOOR, and the subscriber's ROUTE_REQUEST/ROUTE_END echo, with lite-07 claims naming a Route ID in place of a hop chain and every request naming a Node ID.
  • Routed publisher — #runRouted advertises the origin's claims under one route, closes each batch with a floor, restarts only after every echo, and refuses requests naming another node.
  • Routed subscriber — #receiveRouted applies each route's claims whole at its floor, reports the best claim per path (or per node for forwarding), takes a ROUTE_END's claims offline, and echoes it.
  • Selection parity — bestCandidate, rendezvousHash, routeKey, and routeHops port Rust's best_route/route_order, including the newest claim winning a full tie while a re-price keeps its place.
  • Consume and forwarding — the cache selects and names the node of an instance's best route; forwardAnnounced keys routes by prefix plus node and pins forwarded requests to the route's instance.
  • Origin — pick and failover merge into follow, so a detached or pinned request follows its instance's cheapest route rather than staying on the first it resolved.
  • Interop and tests — 13 lite-07 route fixture cases replayed by both sides, Rust's lite07_ties_match_js and the shared goldens, and every JS lite-07 lane (interop, sockets, max-age, relay-fill, datagram-tracks, nightly drain, media replacement) restored.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using deepseek-v4.1-flash-1017 (free via Pullfrog for OSS) | 𝕏

await write({ status: "update", id: prev.id, cost });
}
}
if (!route || !changed) return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

A grant change only reaches the peer through a claim change: sync() returns here before the floor whenever latest is unchanged, even when certifies flipped at line 874. A token offered (auth_session.ts offer) or withdrawn (withdraw) on the live session therefore leaves the route's claims uncertified, or leaves an old certification standing, for the rest of the stream, and a Rust peer acts on exactly that difference.

Technical details
# A floor is only written when a claim message moved

## Affected sites
- `js/net/src/lite/publisher.ts:874` — `certifies` is read from `certified()`, which depends on the live grant (`#grant.peek()`); the grant is subscribed at `publisher.ts:964-966`, so a change does run `sync()`.
- `js/net/src/lite/publisher.ts:918` — `if (!route || !changed) return;` skips the floor whenever `visible()` produced the same claim set, which is exactly what a grant change that keeps every currently shown claim does.
- `js/net/src/lite/publisher.ts:919` — the floor's revision is the only thing that tells the peer the prefix is certified.

## Scenario
1. A lite-07 announce stream is open with `prefix = a`, the grant covers only `a/x/**`, and the origin's only claim is `a/x`. `sync()` sends `active a/x` and `floor(id, revision: undefined)`, so the peer holds the claim uncertified.
2. A token covering `a/**` is offered on the session (or, symmetrically, the token covering `a/**` is withdrawn from a grant that already showed the same claims). `#grant` pokes, `sync()` runs with an identical `latest` but `certifies` flipped.
3. `changed` stays `false`, so no floor is written. The claims stay uncertified for the life of the stream, and a Rust peer's `View::restrict`/`restrict_all` keeps dropping them (`rs/moq-net/src/route/mod.rs:1285` retains only claims a kept floor covers), so the browser's broadcasts are never adopted into that node's route table.

Rust does not have this gap: a permit change calls `refilter`, which re-floors every route unconditionally, and `floor()` re-derives `certified` on each call and sends `None` when it no longer holds (`rs/moq-net/src/lite/publisher/routed.rs:258-264`, `531-548`, `640-651`).

## Required outcome
- A floor is written whenever the route's certification changes, even when no `active`/`update`/`restart`/`endedId` was written in that pass.
- A certifying revision is only ever the latest floor's value while `certified()` still holds for the prefix.

## Suggested approach
- Track the certification of the last floor written for the current route (reset it when a route starts), and change the condition at line 918 to write a floor when `changed` or that value differs from `certifies`.
- A test in `js/net/src/lite/publisher.test.ts` on the `routedAnswer` harness: open the stream under a grant that covers one claim but not the prefix's subtree, then set a grant that covers the subtree and expect `floor(id, revision)` without any claim message before it; the reverse shrink expects `floor(id)` with no revision.

@kixelated

Copy link
Copy Markdown
Collaborator Author

Heads-up from moq-dev/moq#8458, which follows the maintainer's 2026-11-10 decision: lite-07 ROUTE_START and ROUTE_UPDATE replace Down Only (8) with Flags (8). Bit 0x1 is down-only, bit 0x2 means a Min RTT (i) in milliseconds follows, and any other bit is a PROTOCOL_VIOLATION. Bytes 0 and 1 decode as before, but a Rust relay on #8458 sets 0x2 on every route it measures. This codec's 0/1 check would refuse those routes, so it needs to accept the flag and skip or read the field before both land. The read side (RouteInfo MinRtt) is tracked in quest/m2/route-latency.md.

(Written by Claude Opus 5.5 with high)

This branch has not been deployed

No deployments
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