Repository navigation
feat(moq-net,moq-tokio,js/net)!: a WebSocket fallback session returns to QUIC once UDP works - #8330
Conversation
Quest-Claim: 24e5fd79490258eb
… moves back Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ves back Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Maintainer decision (via /quest-complete):
(Written by Claude Opus 5.5 with high effort) |
…it gets through Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… session phase Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Warning Review limit reachedYou've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Next included review available in 20 minutes. View limit details
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. |
kixelated
left a comment
There was a problem hiding this comment.
Automated review by review (OpenAI)
Reviewed head f378a39.
P2 — Close a successful probe that loses the session race (js/net/src/connection/reload.ts:621–624). A probe can pass its final abort check and return Reached just as the fallback closes, sends GOAWAY, or the effect is cancelled. If that competing event wins effect.race, the fulfilled probe value is discarded. The finally block's probing.abort() is too late to dispose it: reachable has removed its abort listener and #probe has already returned. This leaves an open WebTransport session neither reused nor closed. Retain the probe promise and close any successful transport whose result was not selected, analogous to connect.ts's loser cleanup. Add a test that resolves the probe concurrently with fallback close/GOAWAY or effect cancellation and checks the losing transport closes.
Otherwise checked the Rust probe policy/backoff and timeout, token preview/commit separation, auth handover, QUIC transport reuse, fallback race-memory cleanup, and JS leaving phases. No additional actionable findings.
Verification limits: GitHub-only static source/diff review; no tests, network fault simulation, browser runs, or builds executed. The current PR reports merge conflicts, so any eventual conflict resolution remains unreviewed.
There was a problem hiding this comment.
ℹ️ No critical issues — three narrow race/parity findings inline.
Reviewed changes
- moq-net probe policy —
Action::Probe,Reconnect::fallback/probed, and theProbe/Probingstate machine that paces a preferred-transport probe on a jittered 10 s → 300 s backoff and turns a successful one into the dial in flight through the existing GOAWAY handover. - Token preview/commit —
Tokens::dialsplit intopreview/commit, so a failed probe leaves the serving session's in-band grants untouched and the credential is only committed once the probe gets through. - moq-tokio driver —
Client::on_fallback/Client::probe, the driver's probe/fallbackbookkeeping, and clearing the provisionalWEBSOCKET_WONwin onMigrating/Offline/Done;Action::Abandonnow also clears a dial in non-QUIC builds and tells a probe abandon from a failed dial. - js/net probe path — internal
Reached/reachable/establish,Reload.#proberaced against close/drain,websocketWonclearing on a probe win and on session close, and the new internal wireleave()(lite sends a client GOAWAY on lite-04+, ietf drains locally) with aleavingphase that frees the shared auth handle. - Docs and quest — the transport concept doc, the
moq-tokio::websocketmodule docs, and the completedquest/m1/websocket-quic-upgrade.mddeletion with its milestone README line (no stale references remain).
The design reuses the existing handover rather than adding a second migration path, and the mock-clock tests in both languages cover the move, the backoff bound, a hung probe, the once case, and the provisional race win. The three findings below are all narrow (same-wake races and a parity gap); none blocks the normal path.
deepseek-v4.1-flash-1017 (free via Pullfrog for OSS) | 𝕏
…ic-upgrade # Conflicts: # js/net/api/index.api.md # js/net/src/connection/connect.ts # js/net/src/connection/reload.ts # js/net/src/lite/connection.ts
There was a problem hiding this comment.
ℹ️ No new issues in this increment — the only new commit merges
origin/main, and its conflict resolutions preserve every behavior already under review.
Reviewed changes
- Upstream renames applied everywhere —
@moq/qmux's class is imported asQmux, sojs/net's connection interface is nowSession(wasEstablished) acrossconnect.ts,reload.ts, the lite/ietf connections,wire.ts, the benchmark, and the tests, with no staleestablished.tsimport left behind. main's status derivation adopted in the reconnect loop —reload.ts's direct#dialing/establishedwrites became#serve/#setDialing/#report, and the probe handover still reportsconnectedwhile the draining predecessor serves, so moving off the fallback introduces nodisconnectedflicker.- Merge fallout in
moq-tokio—websocket.rsimportstokio_tungstenitedirectly (a declared, feature-gated dependency) andconnection.rsdrops the now-redundantmoq_net::kioimport, following upstream's dependency move.
Verified locally: bunx tsc --noEmit -p net/tsconfig.json is clean, bun test net/src/connection/reload.test.ts passes 46 including both fallback-return cases, and cargo check -p moq-tokio --features websocket succeeds.
deepseek-v4.1-flash-1017 (free via Pullfrog for OSS) | 𝕏
…e closes, and leaving the fallback earns the healthy reset Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Important
The three findings from review #5822095073 were resolved as "Fixed in 215b32a30a532bc09c87d9a7d058a6aa909a96e1", but that commit is not on quest/m1/websocket-quic-upgrade: the PR head is still 9f27a8935 (a merge of origin/main whose first parent is f378a3999), the PR's commit list does not contain it, and the GitHub commits API returns No commit found for SHA: 215b32a.... The head tree is identical to the version that review covered, so all three findings still hold and are re-anchored below rather than reopened as new.
Reviewed changes
- No commits landed — the head is unchanged since the prior pullfrog review (
9f27a89); only the three thread resolutions moved, and they cite a commit that is not on the branch. The code paths they describe as fixed are still the versions the prior review flagged.
deepseek-v4.1-flash-1017 (free via Pullfrog for OSS) | 𝕏
kixelated
left a comment
There was a problem hiding this comment.
Automated review by review (OpenAI)
Reviewed head 215b32a incrementally, accounting for the intervening main merge. The prior P2 successful-probe leak is fixed: Reload retains the probe promise and closes every fulfilled transport whose result was not selected, including effect cancellation and peer-GOAWAY precedence.
No new actionable findings. Both policies now give an already-arrived peer GOAWAY precedence over a successful probe, and JS resets retry pacing after a healthy fallback session as Rust already does. The new Rust regression covers simultaneous peer GOAWAY/probe completion. A deterministic JS regression for a successful probe losing to close/GOAWAY/cancellation would help preserve the cleanup guarantee.
Verification limits: GitHub-only static source/diff review. No tests, builds, browser runs or network-fault simulations executed.
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
215b32a3lands the three fixes — the probe path now gives an already-arrived peer GOAWAY precedence over a successful probe in both languages, and JS resets the retry window when a healthy fallback is left.- JS probe ownership —
Reloadretains the probe promise and closes everyReachedtheeffect.racedid not select, instead of inferring ownership fromsignal.aborted, so a losing or cancelled probe can no longer leak a WebTransport session. - JS healthy reset — moving off the fallback resets
#delay/#deadlinewhen the fallback served past#pacing().healthy, matching Rust'sretry.settle, while still skipping the redirect-loop and restart bookkeeping. - Rust peer precedence —
Reconnect::probed(Ok)now pollslive.goawaywith a no-op waiter before replacing the phase; a ready GOAWAY drops the probe onto its backoff and the nextpollfollows the redirect. The non-consuming poll leaves the message for thatpoll. - Regression coverage —
a_peer_goaway_wins_over_a_probesends a GOAWAY with a successful probe, assertsprobedreturns false, and asserts the next action is not a probe, so a lost GOAWAY fails the test.
The three open findings from the prior review are addressed and their threads are resolved. Residual, not a defect: a peer GOAWAY that only becomes readable after the probe result was committed as the dial is not re-consulted (same in both languages), but the fallback is already draining at that point.
Verified locally: bun test js/net/src/connection/reload.test.ts (46 pass) and cargo test -p moq-net --lib --features async reconnect::tests (28 pass).
deepseek-v4.1-flash-1017 (free via Pullfrog for OSS) | 𝕏
Resolve against #8266's token plumbing: Tokens::preview/commit keep the probe's read-only preview on main's credential shape (no URL or versions), and Reconnect builds main's Attempt (token + auth handle) for both dials and probes, with moq-tokio's attempt helper using Attempt::client. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
- No new commits — the head is unchanged at
215b32a3since the prior Pullfrog approval, so this synchronize event carries no delta; the run re-verified the resolved findings at head rather than re-reviewing new code. - Peer GOAWAY precedence —
Reconnect::probed(Ok)still yields to a readylive.goawaybefore replacing the phase,a_peer_goaway_wins_over_a_probestill covers it inreconnect/tests.rs, andReloadstill prefers an already-settleddrainingover aReached. - Probe ownership and healthy reset —
Reloadstill closes everyReachedthe race did not select and resets#delay/#deadlineafter a fallback session served past the healthy bar, matching Rust'sretry.settle.
deepseek-v4.1-flash-1017 (free via Pullfrog for OSS) | 𝕏
…line #8266 renamed the reader's output from compress=<bool> to compression=<none|deflate> but left the harness matching the old line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
quest land watch: the merge queue removed this pull request at 2a2f7e5 because it conflicts with The land fixer works on it in the background, and the watcher re-enqueues it at the back of the queue once it is fixed or judged flaky, or hands it back here with what it needs. (written by quest land watch) |
|
quest land watch: armed auto-merge on this pull request at 465c215, so it joins the back of the merge queue once its required checks pass, with the land fixer's fix ( (written by quest land watch) |
kixelated
left a comment
There was a problem hiding this comment.
Automated review by review (OpenAI)
Reviewed 465c215 incrementally from 215b32a, accounting for main merges. No new actionable findings. The prior P2 successful-probe leak remains fixed; the JS loser-cleanup changes are preserved.
Checked the adaptation to main's credential API: preview remains read-only, commit stays after fallback draining, and both normal dials and transport probes use Attempt::client for the URL, token and auth handle. Probe and replacement-handshake deadlines now honor the optional timeout, while the merged retry-window checks and shared jitter helper are retained. The hung-probe regression uses an explicit timeout.
Verification limits: GitHub-only static source/diff review; no builds, tests, browser runs or network-fault simulations performed.
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
- Merged
origin/maincredential API —Tokens::preview/commitnow carry main'sDial { token: Option<String>, connection }, andReconnect::attemptbuilds main'sAttempt { target, token, auth }somoq-tokio's dial and probe paths useAttempt::clientfor the URL, SETUP credential, and auth handle. The preview stays read-only and is committed only after the fallback starts draining. - Optional connect timeout — the probe and probe-handover deadlines use
Config::timeout: Option<Duration>directly, dropping the zero-means-none filter and leaving a probe unbounded when no timeout is configured, matching the ordinary dial path. - Retry backoff merge —
Retry::wait()keeps main's window-less signature via the PR's sharedjitterhelper, withback_offapplying the retry window exactly asorigin/maindoes. - Probe test configuration — the hung-probe and probe-stops-with-fallback tests now pass
Some(..)timeouts. - Interop expectation — the catalog fanout harness now matches the native JS reader's renamed
compression=none|deflateline.
The delta is a mechanical adaptation to base with no behavior change, and its files are byte-identical to the squash commit 2311ef4cc now on main. All three prior Pullfrog threads remain resolved and their fixes are intact.
deepseek-v4.1-flash-1017 (free via Pullfrog for OSS) | 𝕏

Finishes quest/m1/websocket-quic-upgrade.md and deletes it, with its link.
Problem
A short UDP outage makes a reconnecting link redial. WebSocket wins that race, and the link then stays on WebSocket for good: nothing re-probes QUIC while a session runs, and the race memory (
WEBSOCKET_WONin moq-tokio,websocketWonin@moq/net) drops QUIC's head start on every later redial until QUIC wins once. One blip leaves a link on the degraded transport indefinitely.Changes
Reconnectprobes the preferred transport. A probe previews its tokens (Tokens::preview) and commits them (Tokens::commit, replacingTokens::dial) only once it gets through, so a failing probe never disturbs the fallback session's grants. A driver whose session landed on a fallback callsReconnect::fallback(now). The policy then emitsAction::Probe(attempt)5 to 10 s later, then on a doubling, jittered backoff capped at one every 5 minutes, each bounded by the connect timeout (Action::Abandonon expiry). The driver reports withReconnect::probed(now, Result<(), E>).Action::Migrating). The probe then becomes the dial in flight: the driver finishes the MoQ handshake over the transport it reached and reportsconnected/failedas usual. The fallback drains first because an auth handle backs one active session at a time. It keeps serving until the replacement does, and closes once idle or at the handover cap. A failed handshake falls into the usual migration retry while the predecessor serves.http(s)://URL with the QUIC backend compiled. Its probe is a QUIC-only dial (Client::probe), and the probed connection carries the replacement session, so a WebSocket win in a second race is impossible.WEBSOCKET_WONwhen the WebSocket session that set it ends: on close, GOAWAY, or a probe moving off it. A post-outage redial therefore gives QUIC its head start again. This needs no clock.@moq/netmirrors it.websocketWonis cleared when the winning WebSocket session closes. While the reconnectingConnection(Reload) runs on WebSocket, it probes with a bare WebTransport session (reachableinconnect.ts, internal) on the same backoff. Once one opens, the session sends its own GOAWAY through a new internal wire hook,leave()(aleavingphase on the lite and IETF connections), drains as the predecessor, and the replacement finishes its MoQ handshake over the probed WebTransport session (internalestablish) with no second race, as in moq-tokio.leave()and sends no client GOAWAY. Lite sends one on lite-04+.moq-tokio::websocketmodule docs anddoc/concept/transport.mdno longer promise that a WebSocket session stays put.Tests
@moq/net(manual clock, in-memoryWebSocketStreamand WebTransport): the fallback moves to WebTransport after WebTransport becomes reachable over the probe's own session (one WebTransport dial), the old peer receives the GOAWAY, and the status never reportsdisconnected.auth::tokens: previewing a probe's credential leaves the live session's in-band grants alone until the dial is committed.Choices made without asking
[connect.websocket]probe setting and noMoqWebSocketConfigfield, since no consumer needs to tune it yet.Error::Duplicate).with_reconnect(false)) never probes: moving would be a second session.Decisions
Action::Probeis a new variant on the exhaustivemoq_net::reconnect::Action, a semver break for any out-of-tree driver ofReconnect(the quest's plan had called the milestone additive): ✅ accept the break and mark the title!(rejected: a separateReconnect::poll_probethat drivers could skip, a second poll loop every driver must remember to run). moq-tokio is the only in-tree driver,Actionis exhaustive so drivers handle new actions, andmainallows breaking changes. Recorded here.Public API / wire impact
Action::Probe,Reconnect::fallback, andReconnect::probed.Action::Abandonnow also covers a probe.Impact
Migration for an out-of-tree driver of
moq_net::reconnect::Reconnect: thematchonActiongains anAction::Probe(attempt)arm. A driver that never callsReconnect::fallbacknever receives it and can matchAction::Probe(_) => unreachable!(). A driver that does dials the preferred transport only, then reports withReconnect::probed(now, result).@moq/net: no public API change (reachable,Reached,establish, and the wire view'sleaveare internal). Same behavior change.Caused by
No single PR: a WebSocket session has stayed put since the fallback was added.
Follow-ups
Merge with main
origin/mainafter moq-dev/moq#8266 reshaped the dial credential.Tokens::preview/commitkeep the probe's read-only preview on main's shape (Dial { token: Option<String>, .. }, no URL or versions),Reconnectbuilds main'sAttempt { target, token, auth }for dials and probes alike, and moq-tokio'sClient::attemptusesAttempt::client.test/interop/interop.shnow expects the native JS catalog reader'scatalog compression=none|deflateline. moq-dev/moq#8266 renamed that output but left the harness matchingcompress=, which failsjust test interop --allon main too; no open PR fixed it.just checkandjust test interop --allpass on the merged head.Models
Size: M
Implementer: Claude Code, Opus 5.5, medium effort
Implementer: Claude Code, claude-opus-5-5, medium effort (iterate, M)
Implementer: Claude Code, claude-opus-5-5, medium effort (iterate, S)
(Written by Opus 5.5 with medium effort)
Review
mechanical fix by codex, gpt-6.1-sol, medium effort:
Merged base, preserving its optional timeout handling and the PR shared jitter helper; adapted probe timeout uses and tests; ran just ci base, just test interop --all, and git diff --check base.(head 465c215)Rewritten by
quest reviewafter each round.01a1be6f-8eaa-7560-be09-b0b0df4632bb, round 5The incremental changes correctly adapt probe credential handling to the upstream API and align the interop expectation with the reader’s output, with no new findings.