Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion js/net/src/connection/accept.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,17 @@ async function acceptNegotiated(transport: WebTransport, url: URL, props?: Accep
await server.encode(stream.writer, setupVersion);

if (Object.values(Lite.Version).includes(selectedVersion as Lite.Version)) {
return new Lite.Connection(url, transport, selectedVersion as Lite.Version, stream);
const version = selectedVersion as Lite.Version;
// Lite03+ has no SessionInfo protocol on the control stream. When it's
// negotiated via this Draft14 SETUP fallback (e.g. Firefox WebTransport,
// which can't select an ALPN), close the bootstrap stream after the
// exchange and run the session as if it had been ALPN-negotiated directly.
const isLegacy = version === Lite.Version.DRAFT_01 || version === Lite.Version.DRAFT_02;
if (isLegacy) {
return new Lite.Connection(url, transport, version, stream);
}
stream.writer.close();
return new Lite.Connection(url, transport, version, undefined);
} else if (Object.values(Ietf.Version).includes(selectedVersion as Ietf.Version)) {
const maxRequestId = client.parameters.getVarint(Ietf.SetupOption.MaxRequestId) ?? 0n;
return new Ietf.Connection({
Expand Down
44 changes: 40 additions & 4 deletions js/net/src/connection/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,40 @@ import { exchangeSetup } from "./handshake.ts";
// Default head start for WebTransport before attempting the WebSocket fallback.
const DEFAULT_WEBSOCKET_DELAY_MS = 500;

// Versions advertised in the Draft14 SETUP fallback (bare `moql` ALPN or no ALPN
// at all, e.g. Firefox WebTransport, which can't select an ALPN). Listing every
// shipped moq-lite version lets the server pick Lite03+ even without ALPN
// selection. Lite05Wip is intentionally omitted: it's work-in-progress and not
// advertised by default.
const LITE_FALLBACK_VERSIONS = [
Lite.Version.DRAFT_04,
Lite.Version.DRAFT_03,
Lite.Version.DRAFT_02,
Lite.Version.DRAFT_01,
Ietf.Version.DRAFT_14,
];

// Lite01/Lite02 still run the SessionInfo protocol on the SETUP control stream.
function isLiteLegacy(v: Lite.Version): boolean {
return v === Lite.Version.DRAFT_01 || v === Lite.Version.DRAFT_02;
}

// Build a Lite connection negotiated via the Draft14 SETUP fallback. Lite03+ has
// no SessionInfo protocol, so close the bootstrap stream we borrowed for the
// exchange and run the session as if it had been ALPN-negotiated directly.
function liteFallbackConnection(
url: URL,
session: WebTransport,
version: Lite.Version,
stream: Stream,
): Lite.Connection {
if (isLiteLegacy(version)) {
return new Lite.Connection(url, session, version, stream);
}
stream.writer.close();
return new Lite.Connection(url, session, version, undefined);
}

/** Tuning for the WebSocket fallback used when WebTransport is unavailable or loses the connect race. */
export interface WebSocketOptions {
// If true (default), enable the WebSocket fallback.
Expand Down Expand Up @@ -162,12 +196,14 @@ export async function connect(url: URL, props?: ConnectProps): Promise<Establish
const client = new Ietf.ClientSetup({
// NOTE: draft 15 onwards does not use CLIENT_SETUP to negotiate the version.
// We still echo it just to make sure we're not accidentally trying to negotiate the version.
// For the Draft14 fallback (ALPN_LITE or no ALPN, e.g. Firefox WebTransport),
// advertise every shipped moq-lite version so we can negotiate Lite03+ without ALPN.
versions:
setupVersion === Ietf.Version.DRAFT_16
? [Ietf.Version.DRAFT_16]
: setupVersion === Ietf.Version.DRAFT_15
? [Ietf.Version.DRAFT_15]
: [Lite.Version.DRAFT_02, Lite.Version.DRAFT_01, Ietf.Version.DRAFT_14],
: LITE_FALLBACK_VERSIONS,
parameters: params,
});
console.debug(url.toString(), "sending client setup", client);
Expand All @@ -184,7 +220,7 @@ export async function connect(url: URL, props?: ConnectProps): Promise<Establish
console.debug(url.toString(), "received server setup", server);

if (Object.values(Lite.Version).includes(server.version as Lite.Version)) {
return new Lite.Connection(url, session, server.version as Lite.Version, stream);
return liteFallbackConnection(url, session, server.version as Lite.Version, stream);
} else if (Object.values(Ietf.Version).includes(server.version as Ietf.Version)) {
const maxRequestId = server.parameters.getVarint(Ietf.SetupOption.MaxRequestId) ?? 0n;
return new Ietf.Connection({
Expand Down Expand Up @@ -242,7 +278,7 @@ async function connectTransport(url: URL, session: WebTransport): Promise<Establ
? [Ietf.Version.DRAFT_16]
: setupVersion === Ietf.Version.DRAFT_15
? [Ietf.Version.DRAFT_15]
: [Lite.Version.DRAFT_02, Lite.Version.DRAFT_01, Ietf.Version.DRAFT_14],
: LITE_FALLBACK_VERSIONS,
parameters: params,
});
await client.encode(stream.writer, setupVersion);
Expand All @@ -255,7 +291,7 @@ async function connectTransport(url: URL, session: WebTransport): Promise<Establ
const server = await Ietf.ServerSetup.decode(stream.reader, setupVersion);

if (Object.values(Lite.Version).includes(server.version as Lite.Version)) {
return new Lite.Connection(url, session, server.version as Lite.Version, stream);
return liteFallbackConnection(url, session, server.version as Lite.Version, stream);
} else if (Object.values(Ietf.Version).includes(server.version as Ietf.Version)) {
const maxRequestId = server.parameters.getVarint(Ietf.SetupOption.MaxRequestId) ?? 0n;
return new Ietf.Connection({
Expand Down
15 changes: 15 additions & 0 deletions js/net/src/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,21 @@ test("integration: lite draft-03", async () => {
await runPublishSubscribeFlow(Lite.ALPN_03);
});

test("integration: lite draft-04", async () => {
await runPublishSubscribeFlow(Lite.ALPN_04);
});

// No-ALPN fallback (e.g. Firefox WebTransport, which can't select an ALPN).
// The SETUP exchange advertises every shipped moq-lite version, so the server
// can pick Lite03+ even without ALPN selection.
test("integration: lite draft-03 via SETUP fallback (no ALPN)", async () => {
await runPublishSubscribeFlow("", Lite.Version.DRAFT_03);
});

test("integration: lite draft-04 via SETUP fallback (no ALPN)", async () => {
await runPublishSubscribeFlow("", Lite.Version.DRAFT_04);
});
Comment on lines +71 to +80

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.

⚠️ Potential issue | 🟡 Minor

Add an explicit moql fallback case here too.

These tests only exercise the empty-protocol fallback. The same negotiation path is also entered when protocol === Lite.ALPN, and that is part of the behavior this PR changes, so a regression there would still go unnoticed.

🧪 Suggested coverage
 test("integration: lite draft-03 via SETUP fallback (no ALPN)", async () => {
 	await runPublishSubscribeFlow("", Lite.Version.DRAFT_03);
 });
 
 test("integration: lite draft-04 via SETUP fallback (no ALPN)", async () => {
 	await runPublishSubscribeFlow("", Lite.Version.DRAFT_04);
 });
+
+test("integration: lite draft-03 via SETUP fallback (`moql` ALPN)", async () => {
+	await runPublishSubscribeFlow(Lite.ALPN, Lite.Version.DRAFT_03);
+});
+
+test("integration: lite draft-04 via SETUP fallback (`moql` ALPN)", async () => {
+	await runPublishSubscribeFlow(Lite.ALPN, Lite.Version.DRAFT_04);
+});
As per coding guidelines "Write unit tests for critical functionality".
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// No-ALPN fallback (e.g. Firefox WebTransport, which can't select an ALPN).
// The SETUP exchange advertises every moq-lite version, so the server can
// pick the preferred version — including Lite03+ — even without ALPN selection.
test("integration: lite draft-03 via SETUP fallback (no ALPN)", async () => {
await runPublishSubscribeFlow("", Lite.Version.DRAFT_03);
});
test("integration: lite draft-04 via SETUP fallback (no ALPN)", async () => {
await runPublishSubscribeFlow("", Lite.Version.DRAFT_04);
});
// No-ALPN fallback (e.g. Firefox WebTransport, which can't select an ALPN).
// The SETUP exchange advertises every moq-lite version, so the server can
// pick the preferred version — including Lite03+ — even without ALPN selection.
test("integration: lite draft-03 via SETUP fallback (no ALPN)", async () => {
await runPublishSubscribeFlow("", Lite.Version.DRAFT_03);
});
test("integration: lite draft-04 via SETUP fallback (no ALPN)", async () => {
await runPublishSubscribeFlow("", Lite.Version.DRAFT_04);
});
test("integration: lite draft-03 via SETUP fallback (`moql` ALPN)", async () => {
await runPublishSubscribeFlow(Lite.ALPN, Lite.Version.DRAFT_03);
});
test("integration: lite draft-04 via SETUP fallback (`moql` ALPN)", async () => {
await runPublishSubscribeFlow(Lite.ALPN, Lite.Version.DRAFT_04);
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@js/lite/src/integration.test.ts` around lines 67 - 76, Add test cases that
mirror the existing "no ALPN" tests but set the protocol argument to Lite.ALPN
so the SETUP-based negotiation path that falls back to "moql" is exercised;
specifically, add tests calling runPublishSubscribeFlow(Lite.ALPN,
Lite.Version.DRAFT_03) and runPublishSubscribeFlow(Lite.ALPN,
Lite.Version.DRAFT_04) (with test names analogous to the existing no-ALPN
titles) so the behavior when protocol === Lite.ALPN is covered.


test("integration: ietf draft-14", async () => {
await runPublishSubscribeFlow("", Ietf.Version.DRAFT_14);
});
Expand Down
5 changes: 4 additions & 1 deletion js/net/src/lite/version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ export const Version = {
export type Version = (typeof Version)[keyof typeof Version];

/// The WebTransport subprotocol identifier for moq-lite.
/// Version negotiation still happens via SETUP when this is used.
/// Version negotiation still happens via SETUP when this is used, or when no
/// ALPN selection is available at all (e.g. Firefox WebTransport). In both
/// cases we advertise every shipped moq-lite version in the legacy SETUP
/// versions list, so Lite03+ can still be negotiated without a dedicated ALPN.
export const ALPN = "moql";

/// The ALPN string for Draft03, which uses ALPN-based version negotiation.
Expand Down
69 changes: 67 additions & 2 deletions rs/moq-net/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -205,10 +205,21 @@ impl Client {

let recv_bw = match version {
Version::Lite(v) => {
let stream = stream.with_version(v);
// Lite03+ has no SessionInfo protocol on the control stream. When it's
// negotiated via this legacy SETUP fallback (because ALPN selection
// wasn't available, e.g. Firefox WebTransport), close the bootstrap
// stream after the exchange and run the session as if it had been
// ALPN-negotiated directly.
let setup_stream = match v {
lite::Version::Lite01 | lite::Version::Lite02 => Some(stream.with_version(v)),
_ => {
let _ = stream.writer.finish();
None
}
};
lite::start(
session.clone(),
Some(stream),
setup_stream,
self.publish.clone(),
self.consume.clone(),
self.stats.clone(),
Expand Down Expand Up @@ -454,6 +465,7 @@ mod tests {
assert_eq!(
advertised,
vec![
Version::Lite(lite::Version::Lite03),
Version::Lite(lite::Version::Lite02),
Version::Lite(lite::Version::Lite01),
Version::Ietf(ietf::Version::Draft14),
Expand All @@ -466,6 +478,49 @@ mod tests {
assert_eq!(code, Error::Cancel.to_code());
}

async fn run_alpn_lite_fallback_lite03_case(protocol: Option<&'static str>) {
// Server negotiates Lite03 via the legacy SETUP versions list.
// No SessionInfo frame is appended: Lite03+ has no SETUP stream protocol.
let mut server_bytes = Vec::new();
let server = setup::Server {
version: Version::Lite(lite::Version::Lite03).into(),
parameters: Bytes::new(),
};
server
.encode(&mut server_bytes, Version::Ietf(ietf::Version::Draft14))
.unwrap();

let fake = FakeSession::new(protocol, server_bytes);
let client = Client::new().with_versions(
[
Version::Lite(lite::Version::Lite04),
Version::Lite(lite::Version::Lite03),
Version::Lite(lite::Version::Lite02),
Version::Lite(lite::Version::Lite01),
Version::Ietf(ietf::Version::Draft14),
]
.into(),
);

let session = client.connect(fake.clone()).await.unwrap();
assert_eq!(session.version(), Version::Lite(lite::Version::Lite03));

// The client advertises every shipped moq-lite version in the SETUP list.
let mut setup_bytes = Bytes::from(fake.control_writes());
let setup = setup::Client::decode(&mut setup_bytes, Version::Ietf(ietf::Version::Draft14)).unwrap();
let advertised: Vec<Version> = setup.versions.iter().map(|v| Version::try_from(*v).unwrap()).collect();
assert_eq!(
advertised,
vec![
Version::Lite(lite::Version::Lite04),
Version::Lite(lite::Version::Lite03),
Version::Lite(lite::Version::Lite02),
Version::Lite(lite::Version::Lite01),
Version::Ietf(ietf::Version::Draft14),
]
);
}

#[tokio::test(start_paused = true)]
async fn alpn_lite_falls_back_to_draft14_and_switches_version_post_setup() {
run_alpn_lite_fallback_case(Some(ALPN_LITE)).await;
Expand All @@ -475,4 +530,14 @@ mod tests {
async fn no_alpn_falls_back_to_draft14_and_switches_version_post_setup() {
run_alpn_lite_fallback_case(None).await;
}

#[tokio::test(start_paused = true)]
async fn alpn_lite_fallback_negotiates_lite03() {
run_alpn_lite_fallback_lite03_case(Some(ALPN_LITE)).await;
}

#[tokio::test(start_paused = true)]
async fn no_alpn_fallback_negotiates_lite03() {
run_alpn_lite_fallback_lite03_case(None).await;
}
Comment on lines +481 to +542

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.

⚠️ Potential issue | 🟡 Minor

Cover the Lite04 fallback selection as well.

NEGOTIATED now prefers Lite04 first, but these new fallback tests only assert the Lite03 path. A regression in selecting or bootstrapping Lite04 via ALPN_LITE or no ALPN would still pass here.

🧪 Suggested extension
-	async fn run_alpn_lite_fallback_lite03_case(protocol: Option<&'static str>) {
+	async fn run_alpn_lite_fallback_case(protocol: Option<&'static str>, negotiated: lite::Version) {
 		// Server negotiates Lite03 via the legacy SETUP versions list.
 		// No SessionInfo frame is appended — Lite03+ has no session stream protocol.
 		let mut server_bytes = Vec::new();
 		let server = setup::Server {
-			version: Version::Lite(lite::Version::Lite03).into(),
+			version: Version::Lite(negotiated).into(),
 			parameters: Bytes::new(),
 		};
@@
-		assert_eq!(session.version(), Version::Lite(lite::Version::Lite03));
+		assert_eq!(session.version(), Version::Lite(negotiated));
@@
 	#[tokio::test(start_paused = true)]
 	async fn alpn_lite_fallback_negotiates_lite03() {
-		run_alpn_lite_fallback_lite03_case(Some(ALPN_LITE)).await;
+		run_alpn_lite_fallback_case(Some(ALPN_LITE), lite::Version::Lite03).await;
 	}
 
 	#[tokio::test(start_paused = true)]
 	async fn no_alpn_fallback_negotiates_lite03() {
-		run_alpn_lite_fallback_lite03_case(None).await;
+		run_alpn_lite_fallback_case(None, lite::Version::Lite03).await;
+	}
+
+	#[tokio::test(start_paused = true)]
+	async fn alpn_lite_fallback_negotiates_lite04() {
+		run_alpn_lite_fallback_case(Some(ALPN_LITE), lite::Version::Lite04).await;
+	}
+
+	#[tokio::test(start_paused = true)]
+	async fn no_alpn_fallback_negotiates_lite04() {
+		run_alpn_lite_fallback_case(None, lite::Version::Lite04).await;
 	}
As per coding guidelines "Write unit tests for critical functionality".
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@rs/moq-lite/src/client.rs` around lines 430 - 486, Add a parallel test that
covers the Lite04 fallback path: create a new async helper (e.g.,
run_alpn_lite_fallback_lite04_case) modeled on
run_alpn_lite_fallback_lite03_case but set server.version to
Version::Lite(lite::Version::Lite04), assert the resulting session.version() is
Lite04, and assert the client setup advertisement still lists the NEGOTIATED set
starting with Lite04; then add two tokio::test entries that call this helper
with Some(ALPN_LITE) and None to cover both ALPN and no-ALPN code paths so
Lite04 selection/bootstrapping is exercised.

}
15 changes: 13 additions & 2 deletions rs/moq-net/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -207,10 +207,21 @@ impl Server {

let recv_bw = match version {
Version::Lite(v) => {
let stream = stream.with_version(v);
// Lite03+ has no SessionInfo protocol on the control stream. When it's
// negotiated via this legacy SETUP fallback (because ALPN selection
// wasn't available, e.g. Firefox WebTransport), close the bootstrap
// stream after the exchange and run the session as if it had been
// ALPN-negotiated directly.
let setup_stream = match v {
lite::Version::Lite01 | lite::Version::Lite02 => Some(stream.with_version(v)),
_ => {
let _ = stream.writer.finish();
None
}
};
lite::start(
session.clone(),
Some(stream),
setup_stream,
self.publish.clone(),
self.consume.clone(),
self.stats.clone(),
Expand Down
19 changes: 16 additions & 3 deletions rs/moq-net/src/version.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,22 @@ use crate::{coding, ietf, lite};
/// The versions of MoQ that are negotiated via SETUP.
///
/// Ordered by preference, with the client's preference taking priority.
/// This intentionally includes only SETUP-negotiated versions (Lite02, Lite01, Draft14);
/// Lite03 and newer IETF drafts negotiate via dedicated ALPNs instead.
pub(crate) const NEGOTIATED: [Version; 3] = [
///
/// This path is used when ALPN-based selection is unavailable: a bare `"moql"`
/// ALPN, or no ALPN at all (e.g. Firefox's WebTransport, which doesn't expose
/// an ALPN selection API). To avoid stranding such peers on the oldest drafts,
/// we advertise every shipped moq-lite version in the legacy SETUP versions
/// list so Lite03+ can still be negotiated without a dedicated ALPN.
///
/// Lite03+ borrows the draft-14 SETUP framing *only* for this bootstrap
/// exchange. Once negotiated, the SETUP stream is closed and the rest of the
/// session follows the dedicated-ALPN semantics (no SessionInfo messages).
///
/// Lite05Wip is intentionally excluded: it is work-in-progress and must not be
/// advertised by default, matching [`ALPNS`].
pub(crate) const NEGOTIATED: [Version; 5] = [
Version::Lite(lite::Version::Lite04),
Version::Lite(lite::Version::Lite03),
Version::Lite(lite::Version::Lite02),
Version::Lite(lite::Version::Lite01),
Version::Ietf(ietf::Version::Draft14),
Expand Down