Skip to content

moq-relay: stop downgrading WebSocket clients to moq-lite-02#1523

Merged
kixelated merged 5 commits into
mainfrom
claude/confident-spence-edec63
May 28, 2026
Merged

moq-relay: stop downgrading WebSocket clients to moq-lite-02#1523
kixelated merged 5 commits into
mainfrom
claude/confident-spence-edec63

Conversation

@kixelated
Copy link
Copy Markdown
Collaborator

@kixelated kixelated commented May 28, 2026

Summary

Fixes the Android-side report where WebSocket clients were silently negotiated down to moq-lite-02. Two compounding bugs in rs/moq-relay/src/websocket.rs::serve_ws:

  1. ws.protocols(["webtransport"]) advertised only the bare webtransport subprotocol to axum. qmux clients offer the full matrix (qmux-00.moq-lite-04, qmux-00.moq-lite-03, qmux-00.moql, ..., webtransport, webtransport.moq-lite-04, ...). axum exact-matches and picks the bare webtransport, which carries no version.
  2. qmux::ws::Bare::new(socket).accept() never forwarded the negotiated header to qmux, so even if axum had picked a versioned subprotocol, qmux::Session::protocol() would still come back None.

Both gaps feed into rs/moq-net/src/server.rs's Some(ALPN_LITE) | None arm, which falls back to SETUP-based negotiation with [Lite02, Lite01, Draft14] and picks Lite02.

Changes

  • New supported_subprotocols() builds the cross product of qmux::PREFIXES × moq_net::ALPNS, with bare qmux fallbacks appended last so versioned subprotocols always win.
  • The upgrade callback captures socket.protocol() before the Stream/Sink adapters erase the WebSocket type, then threads it into qmux::ws::Bare::with_alpn(...).
  • Regression tests in rs/moq-relay/src/websocket.rs:
    • supported_subprotocols_lists_full_matrix pins the list shape.
    • axum_ws_negotiates_newest_moq_alpn runs a minimal axum router that mirrors serve_ws's subprotocol wiring, connects with a qmux client offering moq_net::ALPNS, and asserts both sides see moq-lite-04.
  • New rs/moq-relay/tests/smoke.rs (modelled on rs/moq-native/tests/broadcast.rs): stands up the relay's full axum + Auth + Cluster stack on a free port with public auth, connects a publisher and subscriber via ws://, pushes a frame end-to-end, and asserts both sides see moq-lite-04. Covers the full request path (auth verify → cluster scoping → subprotocol negotiation → broadcast announce → frame delivery).
  • Regression tests in rs/moq-native/tests/broadcast.rs (audit work that confirmed the lower layers were already correct):
    • broadcast_websocket_uses_newest_version — direct ws:// connect must negotiate moq-lite-04.
    • broadcast_race_quic_wins — with QUIC and WebSocket on the same port and zero head start, QUIC must still win.

Test plan

  • cargo test -p moq-relay
  • cargo test -p moq-relay --test smoke
  • cargo test -p moq-native --test broadcast
  • cargo clippy -p moq-relay --all-targets -- -D warnings
  • cargo clippy --tests -p moq-native -- -D warnings
  • cargo fmt --check

(Written by Claude)

Two integration tests guard the ALPN/version path we just audited after
a report of Android clients negotiating moq-lite-02 over the WebSocket
fallback:

- broadcast_websocket_uses_newest_version asserts a direct ws:// connect
  ends up on moq-lite-04 (the newest both sides advertise by default).
  A Lite02 outcome here would mean qmux's subprotocol negotiation lost
  the modern per-version ALPNs and only matched bare `moql`, falling
  through to the legacy SETUP path.
- broadcast_race_quic_wins binds the WebSocket TCP listener and the
  QUIC UDP listener on the same port, disables the WebSocket head start,
  and asserts the server saw `transport() == "quic"` with the newest
  version. Catches accidental WebSocket wins when QUIC is reachable.

Both assertions run on client and server. A shared NEWEST_LITE constant
makes the bump obvious when Versions::all() advances.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 28, 2026

Actionable comments posted: 0

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 28, 2026

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d675f4a2-305b-42a2-8621-275d221c320c

📥 Commits

Reviewing files that changed from the base of the PR and between fad7813 and 1b0dec5.

📒 Files selected for processing (1)
  • rs/moq-relay/tests/smoke.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • rs/moq-relay/tests/smoke.rs

Walkthrough

This PR updates WebSocket handling to advertise a full qmux × moq-net subprotocol matrix, captures the negotiated ALPN during upgrade and passes it into qmux::ws::Bare via with_alpn, adds the workspace dependency web-transport-trait, and adds end-to-end tests: native broadcast tests asserting NEWEST_LITE negotiation (including a QUIC-vs-WebSocket race) and a relay smoke test that round-trips a frame while verifying moq-lite negotiation.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main fix: addressing the WebSocket client downgrade to moq-lite-02 in the relay, which aligns with the substantial changes to websocket.rs subprotocol negotiation.
Description check ✅ Passed The description thoroughly explains both bugs, the fix approach, changes made, and test coverage—all directly related to the changeset across websocket.rs, tests, and configuration files.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch claude/confident-spence-edec63

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

Comment @coderabbitai help to get the list of available commands and usage tips.

`serve_ws` told axum that the only supported subprotocol was
`["webtransport"]`. qmux clients offer a richer list
(`qmux-00.moq-lite-04`, `qmux-00.moq-lite-03`, `qmux-00.moql`, ...,
`webtransport`, `webtransport.moq-lite-04`, ...). axum performs an
exact-string match and selected the bare `webtransport`, which carries
no version. The `Bare::new(socket).accept()` call below then ignored
the negotiated header anyway. Both gaps fed into
`moq-net/src/server.rs`'s `Some(ALPN_LITE) | None` arm, which falls
back to SETUP-based negotiation with `[Lite02, Lite01, Draft14]` and
picks Lite02. That's what the Android reporter was seeing.

Fix both halves:

- New `supported_subprotocols()` builds the cross product of
  `qmux::PREFIXES` × `moq_net::ALPNS`, with the bare qmux fallbacks
  appended last so versioned subprotocols always win.
- The upgrade callback captures `socket.protocol()` before the
  Stream/Sink adapters erase the WebSocket type, then threads it into
  `qmux::ws::Bare::with_alpn(...)` so qmux can resolve the moq version
  from the header axum negotiated.

Two regression tests guard against this coming back:

- `supported_subprotocols_lists_full_matrix` pins the list shape:
  newest moq ALPN first, every ALPN under every prefix, bare qmux
  fallbacks last.
- `axum_ws_negotiates_newest_moq_alpn` spins up a minimal axum router
  that mirrors `serve_ws`'s subprotocol wiring, connects with a qmux
  client offering `moq_net::ALPNS`, and asserts both the client side
  and the server side observe `moq-lite-04` on the resulting qmux
  session.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@kixelated kixelated changed the title moq-native: regression-test WebSocket version and QUIC vs WS race moq-relay: stop downgrading WebSocket clients to moq-lite-02 May 28, 2026
Adds rs/moq-relay/tests/smoke.rs, modelled on
rs/moq-native/tests/broadcast.rs. It stands up the relay's actual
axum + Auth + Cluster stack on a free 127.0.0.1 port with fully
public auth, connects a publisher and a subscriber via `ws://`,
pushes one frame end-to-end, and asserts both sides observe
`moq-lite-04` on the resulting session.

This complements the in-`websocket.rs` unit and integration tests:
the unit tests cover the protocol matrix and the qmux `Bare` wiring
in isolation, while this smoke test exercises the full request path
(auth verify → cluster scoping → subprotocol negotiation → qmux
SETUP → broadcast announce → group/frame delivery). A regression
that downgraded modern clients to Lite02, or broke the relay's
publish/subscribe pipeline, would fail this test immediately.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

🧹 Nitpick comments (1)
rs/moq-relay/src/websocket.rs (1)

194-205: ⚡ Quick win

Derive the expected ALPN/prefixes from the source-of-truth constants.

These assertions duplicate the current "moq-lite-04", "qmux-00", and "webtransport" literals, so the next ALPN/prefix bump will break the regression test independently of the production logic. Iterating qmux::PREFIXES and moq_net::ALPNS here keeps the test aligned with supported_subprotocols().

♻️ Suggested cleanup
 	#[cfg(test)]
 	mod tests {
 		use super::*;
 		use axum::{Router, extract::WebSocketUpgrade, routing::any};
 		use std::sync::Mutex;
 		use tokio::sync::oneshot;
 		// Brings `qmux::Session::protocol` and `::closed` into scope.
 		use web_transport_trait::Session as _;
+
+		fn newest_moq_alpn() -> &'static str {
+			*moq_net::ALPNS.first().expect("missing moq ALPNs")
+		}

 		#[test]
 		fn supported_subprotocols_lists_full_matrix() {
 			let list = supported_subprotocols();

 			// Newest moq ALPN under the preferred prefix must come first so axum
 			// picks it whenever the client offers it.
-			assert_eq!(list.first().map(String::as_str), Some("qmux-00.moq-lite-04"));
+			let expected_first = format!("{}{}", qmux::PREFIXES[0], newest_moq_alpn());
+			assert_eq!(list.first().map(String::as_str), Some(expected_first.as_str()));

 			// Every moq ALPN must appear under every qmux prefix.
-			for &alpn in moq_net::ALPNS {
-				assert!(list.contains(&format!("qmux-00.{alpn}")), "missing qmux-00.{alpn}");
-				assert!(
-					list.contains(&format!("webtransport.{alpn}")),
-					"missing webtransport.{alpn}"
-				);
+			for &prefix in qmux::PREFIXES {
+				for &alpn in moq_net::ALPNS {
+					assert!(list.contains(&format!("{prefix}{alpn}")), "missing {prefix}{alpn}");
+				}
 			}
 		}
 ...
 		assert_eq!(
 			session.protocol(),
-			Some("moq-lite-04"),
+			Some(newest_moq_alpn()),
 			"client side should see the newest moq ALPN, got {:?}",
 			session.protocol(),
 		);
 ...
 		assert_eq!(
 			server_alpn.as_deref(),
-			Some("moq-lite-04"),
+			Some(newest_moq_alpn()),
 			"server side should see the newest moq ALPN after Bare::with_alpn",
 		);

Also applies to: 282-297

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rs/moq-relay/src/websocket.rs` around lines 194 - 205, Replace hard-coded
ALPN/prefix literals with values derived from the source-of-truth constants:
build the expected first ALPN string using qmux::PREFIXES.first() combined with
the newest moq ALPN from moq_net::ALPNS (so the assertion comparing list.first()
uses format!("{}.{}", qmux::PREFIXES.first().unwrap(), moq_net::ALPNS.<use
newest index>)), and replace the manual "qmux-00" / "webtransport" checks by
iterating qmux::PREFIXES and moq_net::ALPNS to assert
list.contains(&format!("{}.{alpn}")) for each prefix and alpn (apply the same
change for the second occurrence around lines 282–297); use the qmux::PREFIXES
and moq_net::ALPNS symbols so the test follows supported_subprotocols()
automatically.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@rs/moq-relay/src/websocket.rs`:
- Around line 194-205: Replace hard-coded ALPN/prefix literals with values
derived from the source-of-truth constants: build the expected first ALPN string
using qmux::PREFIXES.first() combined with the newest moq ALPN from
moq_net::ALPNS (so the assertion comparing list.first() uses format!("{}.{}",
qmux::PREFIXES.first().unwrap(), moq_net::ALPNS.<use newest index>)), and
replace the manual "qmux-00" / "webtransport" checks by iterating qmux::PREFIXES
and moq_net::ALPNS to assert list.contains(&format!("{}.{alpn}")) for each
prefix and alpn (apply the same change for the second occurrence around lines
282–297); use the qmux::PREFIXES and moq_net::ALPNS symbols so the test follows
supported_subprotocols() automatically.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 26f4123b-5b5d-413a-92ba-5633e74454b9

📥 Commits

Reviewing files that changed from the base of the PR and between 6a395df and 0bca14c.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (2)
  • rs/moq-relay/Cargo.toml
  • rs/moq-relay/src/websocket.rs

Replace hard-coded "moq-lite-04", "qmux-00", "webtransport" literals
in the websocket regression tests with iteration over qmux::PREFIXES,
qmux::ALPNS, and moq_net::ALPNS. Adding a newer ALPN or renaming a
prefix no longer breaks these tests independently of the production
code; they keep tracking whatever supported_subprotocols() advertises.

Per CodeRabbit nitpick on #1523.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@kixelated
Copy link
Copy Markdown
Collaborator Author

Addressed CodeRabbit's nitpick in fad7813: the websocket tests now derive expected ALPNs by iterating qmux::PREFIXES, qmux::ALPNS, and moq_net::ALPNS instead of hard-coding "moq-lite-04" / "qmux-00" / "webtransport". Adding a newer ALPN no longer requires touching the tests.

(Written by Claude)

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

🧹 Nitpick comments (1)
rs/moq-relay/tests/smoke.rs (1)

15-17: ⚡ Quick win

Derive the expected lite version from moq_net::ALPNS instead of pinning moq-lite-04.

This is now the one place in the new coverage that will go stale on the next lite-version bump, even if negotiation is still correct.

♻️ Proposed change
-const NEWEST_LITE: &str = "moq-lite-04";
+fn newest_lite_version() -> moq_net::Version {
+	moq_net::ALPNS
+		.iter()
+		.copied()
+		.find(|alpn| alpn.starts_with("moq-lite"))
+		.expect("missing moq-lite ALPN")
+		.parse()
+		.expect("parse newest lite version")
+}
@@
-	let expected_version: moq_net::Version = NEWEST_LITE.parse().expect("parse version");
+	let expected_version = newest_lite_version();

Also applies to: 103-103

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rs/moq-relay/tests/smoke.rs` around lines 15 - 17, Replace the hardcoded
NEWEST_LITE ("moq-lite-04") with a value derived from moq_net::ALPNS so the test
picks up the current lite version automatically; update the constant/usage of
NEWEST_LITE in tests/smoke.rs to compute the expected lite ALPN from
moq_net::ALPNS (e.g., select the appropriate element such as the last/ newest
entry) and keep TIMEOUT as-is, ensuring all assertions that referenced
NEWEST_LITE now use the derived value.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@rs/moq-relay/tests/smoke.rs`:
- Around line 15-17: Replace the hardcoded NEWEST_LITE ("moq-lite-04") with a
value derived from moq_net::ALPNS so the test picks up the current lite version
automatically; update the constant/usage of NEWEST_LITE in tests/smoke.rs to
compute the expected lite ALPN from moq_net::ALPNS (e.g., select the appropriate
element such as the last/ newest entry) and keep TIMEOUT as-is, ensuring all
assertions that referenced NEWEST_LITE now use the derived value.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d57113c8-50cd-4f0c-8d73-de3bc094c68b

📥 Commits

Reviewing files that changed from the base of the PR and between 0bca14c and fad7813.

📒 Files selected for processing (2)
  • rs/moq-relay/src/websocket.rs
  • rs/moq-relay/tests/smoke.rs

Replaces the hard-coded NEWEST_LITE = "moq-lite-04" with a helper
that picks the first `moq-lite-*` entry out of moq_net::ALPNS. A
future lite bump (e.g. lite-05 leaving WIP) won't break this test
independently of the production negotiation.

Per CodeRabbit nitpick on #1523.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@kixelated
Copy link
Copy Markdown
Collaborator Author

Addressed CodeRabbit's smoke-test nitpick in 1b0dec5: NEWEST_LITE = "moq-lite-04" is now derived from the first moq-lite-* entry in moq_net::ALPNS via a small helper. Same pattern as fad7813 applied to the relay smoke test.

(Written by Claude)

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 28, 2026

Actionable comments posted: 0

@kixelated kixelated merged commit 012695a into main May 28, 2026
1 check passed
@kixelated kixelated deleted the claude/confident-spence-edec63 branch May 28, 2026 17:49
@moq-bot moq-bot Bot mentioned this pull request May 28, 2026
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