From 3f73bc1c274e16e36083102c260a1eb55814ff0c Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 10 Oct 2026 14:16:37 -0700 Subject: [PATCH 1/8] chore(quest): claim Lite auth unsupported carrier Co-Authored-By: GPT-6 From c7ca79dadb74b6a20835f828b9af52647e5ccd4d Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 10 Oct 2026 14:38:01 -0700 Subject: [PATCH 2/8] fix(net): send explicit AUTH NOT_SUPPORTED on Lite Co-Authored-By: GPT-6 --- drafts/draft-lcurley-moq-lite.md | 7 +++++- js/net/src/auth.test.ts | 29 +++++++++++++++++++++++ js/net/src/auth.ts | 4 +++- js/net/src/error.ts | 2 ++ js/net/src/ietf/auth.ts | 2 +- js/net/src/lite/auth.test.ts | 27 +++++++++++++++++++-- js/net/src/lite/auth.ts | 14 +++++++---- rs/moq-net/src/error.rs | 14 +++++++++++ rs/moq-net/src/ietf/error.rs | 24 +++++++++++++++++-- rs/moq-net/src/ietf/session.rs | 6 ++--- rs/moq-net/src/lite/publisher.rs | 40 +++++++++++++++++++++++--------- rs/moq-net/src/lite/session.rs | 9 +++++-- rs/moq-net/src/test_interop.rs | 32 +++++++++++++++++++++++++ rs/moq-net/tests/auth.rs | 36 ++++++++++++++++++++++++++++ test/interop/README.md | 4 ++++ test/interop/auth-unsupported.ts | 32 +++++++++++++++++++++++++ test/justfile | 5 ++-- 17 files changed, 258 insertions(+), 29 deletions(-) create mode 100644 test/interop/auth-unsupported.ts diff --git a/drafts/draft-lcurley-moq-lite.md b/drafts/draft-lcurley-moq-lite.md index cd636a8f27..84a2124c7f 100644 --- a/drafts/draft-lcurley-moq-lite.md +++ b/drafts/draft-lcurley-moq-lite.md @@ -294,6 +294,8 @@ Sent when terminating the session, via the transport's session close. | ------- | ------------- | ----------- | | 0x15 | VERSION_NEGOTIATION_FAILED | No version could be negotiated. | | ------- | ------------- | ----------- | +| 0x30 | NOT_SUPPORTED | The requested feature, token, or grant is unsupported. | +| ------- | ------------- | ----------- | ### Stream Error Codes Sent when resetting a stream (RESET_STREAM), or when refusing to receive one (STOP_SENDING). @@ -544,7 +546,9 @@ When the union shrinks, an endpoint SHOULD withdraw its announcements and cancel An endpoint that cancels a subscription, or refuses or stops serving a request, because the union does not cover it resets the stream with UNAUTHORIZED. An endpoint that announces a broadcast outside the union once its own setup tokens are answered SHOULD close the session with UNAUTHORIZED, rather than wait for a subscription that will never come. -An acceptor that does not verify a token in band, or cannot express its grant in AUTH_OK, resets the stream, the same as a peer that does not support the Auth Stream (see [STREAM_TYPE](#stream_type)). +An acceptor that does not verify a token in band, or cannot express its grant in AUTH_OK, MUST send AUTH_ERROR with NOT_SUPPORTED and close its side of the stream. +This applies before or after an AUTH_OK, including when a grant exceeds the message size limit. +A presenter receiving a stream reset before any reply treats it as unsupported, for compatibility with peers that do not support the Auth Stream (see [STREAM_TYPE](#stream_type)). An opener that sees the stream reset before any reply treats the token as unsupported rather than refused. ### Goaway @@ -1549,6 +1553,7 @@ The `Message Length` describes the payload size on the wire. - Added announce compression: ANNOUNCE_START gains `Path Base` and `Path Keep` to copy the head of a live advertisement's suffix, and ANNOUNCE_START and ANNOUNCE_UPDATE gain `Hop Base` and `Hop Keep` to copy the tail of a live advertisement's Hop ID list. - Capped the Message Length of every message except FRAME at 65,535 bytes. - Added the TOO_MANY_REQUESTS (0x7) session code, closing a session whose peer goes past the endpoint's bound on subscriptions or announcements. +- Added NOT_SUPPORTED (0x30) to session errors; unsupported Auth Stream tokens and grants use AUTH_ERROR before or after a grant. - Added the Auth Stream (0x7) with AUTH, AUTH_OK, and AUTH_ERROR: either endpoint presents a token on its own stream and learns the [path patterns](#path-pattern) it may publish and subscribe to. The union of a session's open grants is its scope. A shrink withdraws what it no longer covers, and an announcement outside the scope closes the session with UNAUTHORIZED. A peer without the stream resets it. ## moq-lite-06 diff --git a/js/net/src/auth.test.ts b/js/net/src/auth.test.ts index 44dec229d1..fc7dcdc633 100644 --- a/js/net/src/auth.test.ts +++ b/js/net/src/auth.test.ts @@ -81,6 +81,35 @@ describe.each([Lite.ALPN_07_WIP, Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_22])("%s", server.abort(); }); + test("an oversized grant update reports Unsupported and revokes only its token", async () => { + const { client, server } = await connect({ publish: new OriginProducer(), protocol }); + const requests = server.auth.requests(); + const issued: Issued[] = []; + const answering = (async () => { + for (;;) { + const request = await requests.next(); + if (!request) break; + issued.push(request.accept(grant([request.token.byteLength === 0 ? "a" : "b"], []))); + } + })(); + try { + await waitFor(client.auth.grant, (g) => g?.publish.equals(patterns("a")) === true); + const token = await client.auth.add("update"); + await waitFor(client.auth.grant, (g) => g?.publish.equals(patterns("a", "b")) === true); + const publish = new Path.Patterns( + Array.from({ length: 20 }, (_, i) => Path.Pattern.subtree(`${i}${"x".repeat(4000)}`)), + ); + issued[issued.length - 1].update({ publish, subscribe: patterns() }); + expect(await token.closed).toBeInstanceOf(Unsupported); + expect(token.grant.peek()).toBeUndefined(); + await waitFor(client.auth.grant, (g) => g?.publish.equals(patterns("a")) === true); + } finally { + client.abort(); + server.abort(); + await answering; + } + }); + test("an out-of-scope broadcast closes the session and names the path", async () => { const origin = new OriginProducer(); const { client, server, transport } = await connect({ publish: origin, protocol }); diff --git a/js/net/src/auth.ts b/js/net/src/auth.ts index a3f371426c..7bce301da9 100644 --- a/js/net/src/auth.ts +++ b/js/net/src/auth.ts @@ -13,7 +13,7 @@ */ import { type Getter, Signal } from "@moq/signals"; -import type { SessionCode } from "./error.ts"; +import { SessionCode } from "./error.ts"; import type * as Path from "./path.ts"; /** @@ -38,6 +38,8 @@ export function grantsEqual(a: Grant | undefined, b: Grant | undefined): boolean /** The session or peer carries no AUTH exchange, or takes no tokens in band. */ export class Unsupported extends Error { + /** The moq-lite session code for this condition. */ + readonly code = SessionCode.Unsupported; constructor(message = "auth unsupported") { super(message); this.name = "Unsupported"; diff --git a/js/net/src/error.ts b/js/net/src/error.ts index c72b5236df..9860642c96 100644 --- a/js/net/src/error.ts +++ b/js/net/src/error.ts @@ -46,6 +46,8 @@ export const SessionCode = Object.freeze( Timeout: 0x11 as SessionCode, /** No version could be negotiated. */ Version: 0x15 as SessionCode, + /** The peer cannot support the requested feature; moq-lite only. */ + Unsupported: 0x30 as SessionCode, } as const), ); diff --git a/js/net/src/ietf/auth.ts b/js/net/src/ietf/auth.ts index 672b19db5e..6f611ffba9 100644 --- a/js/net/src/ietf/auth.ts +++ b/js/net/src/ietf/auth.ts @@ -263,7 +263,7 @@ export class IetfAuthWire implements AuthWire { async refuse(stream: Stream, code: SessionCode, reason: string): Promise { // The public API speaks session codes; this registry distinguishes only a version mismatch. - const wire = code === SessionCode.Version ? NOT_SUPPORTED : UNAUTHORIZED; + const wire = code === SessionCode.Version || code === SessionCode.Unsupported ? NOT_SUPPORTED : UNAUTHORIZED; await new AuthError(wire, reason).encode(stream.writer, this.#version); } diff --git a/js/net/src/lite/auth.test.ts b/js/net/src/lite/auth.test.ts index 2f7dd3181d..f6e24c0ae0 100644 --- a/js/net/src/lite/auth.test.ts +++ b/js/net/src/lite/auth.test.ts @@ -1,8 +1,8 @@ import { expect, test } from "bun:test"; import { SessionCode } from "../error.ts"; import * as Path from "../path.ts"; -import { Reader, Writer } from "../stream.ts"; -import { AuthError, AuthMessage, AuthOk, decodeAuthReplyMaybe } from "./auth.ts"; +import { Reader, Stream, Writer } from "../stream.ts"; +import { AuthError, AuthMessage, AuthOk, decodeAuthReplyMaybe, LiteAuthWire } from "./auth.ts"; import * as Lite from "./index.ts"; import * as Message from "./message.ts"; @@ -118,3 +118,26 @@ test.each([ ])("%s carries no AUTH", async (_, version) => { await expect(roundTrip((w) => new AuthMessage(new Uint8Array()).encode(w, version))).rejects.toThrow(); }); + +test("unsupported after AUTH_OK is an AUTH_ERROR on the wire", async () => { + const chunks: Uint8Array[] = []; + const stream = new Stream({ + readable: new ReadableStream(), + writable: new WritableStream({ + write: (chunk) => { + chunks.push(new Uint8Array(chunk)); + }, + }), + version: Lite.Version.DRAFT_07, + }); + const wire = new LiteAuthWire({} as WebTransport, Lite.Version.DRAFT_07); + await wire.grant(stream, { publish: patterns("room/**"), subscribe: patterns() }); + await wire.unsupported(stream, "grant cannot be encoded"); + stream.close(); + await stream.writer.closed; + const reader = new Reader(undefined, new Uint8Array(Buffer.concat(chunks)), Lite.Version.DRAFT_07); + expect(await decodeAuthReplyMaybe(reader, Lite.Version.DRAFT_07)).toBeInstanceOf(AuthOk); + const reply = await decodeAuthReplyMaybe(reader, Lite.Version.DRAFT_07); + expect(reply).toBeInstanceOf(AuthError); + expect((reply as AuthError).code).toBe(0x30); +}); diff --git a/js/net/src/lite/auth.ts b/js/net/src/lite/auth.ts index 47b7d99cf6..115dacad26 100644 --- a/js/net/src/lite/auth.ts +++ b/js/net/src/lite/auth.ts @@ -1,6 +1,6 @@ import { Unsupported } from "../auth.ts"; import type { AuthWire, WireGrant, WireReply } from "../auth_session.ts"; -import { type SessionCode, SessionError } from "../error.ts"; +import { SessionCode, SessionError } from "../error.ts"; import * as Path from "../path.ts"; import { type Reader, Stream, type Writer } from "../stream.ts"; import * as Message from "./message.ts"; @@ -186,7 +186,12 @@ export class LiteAuthWire implements AuthWire { if (reply instanceof AuthOk) { return { grant: { publish: reply.publish, subscribe: reply.subscribe, expires: reply.expires } }; } - return { refused: new SessionError(reply.code as SessionCode, { reason: reply.reason }) }; + return { + refused: + reply.code === SessionCode.Unsupported + ? new Unsupported(reply.reason) + : new SessionError(reply.code as SessionCode, { reason: reply.reason }), + }; } /** The stream type is already consumed by the dispatcher. */ @@ -203,8 +208,9 @@ export class LiteAuthWire implements AuthWire { await new AuthError(code, reason).encode(stream.writer, this.#version); } - /** Resetting reads as unsupported to the presenter, the same as a peer that predates AUTH. */ + /** Tell the presenter explicitly, including after an earlier grant. */ async unsupported(stream: Stream, reason: string): Promise { - stream.writer.reset(new Unsupported(reason)); + await new AuthError(SessionCode.Unsupported, reason).encode(stream.writer, this.#version); + await stream.writer.close(); } } diff --git a/rs/moq-net/src/error.rs b/rs/moq-net/src/error.rs index edc758b9ef..5d6a676977 100644 --- a/rs/moq-net/src/error.rs +++ b/rs/moq-net/src/error.rs @@ -57,6 +57,10 @@ pub enum SessionError { /// A code this version does not recognize, kept verbatim. #[error("unknown code={0}")] Unknown(u32), + + /// The peer cannot support the requested feature; moq-lite only. + #[error("not supported")] + Unsupported, } impl SessionError { @@ -72,6 +76,7 @@ impl SessionError { Self::GoawayTimeout => 0x10, Self::Timeout => 0x11, Self::Version => 0x15, + Self::Unsupported => 0x30, Self::App(app) => *app as u32 + 64, Self::Unknown(code) => *code, } @@ -93,6 +98,7 @@ impl SessionError { 0x10 => Self::GoawayTimeout, 0x11 => Self::Timeout, 0x15 => Self::Version, + 0x30 => Self::Unsupported, code @ 64.. => match u16::try_from(code - 64) { Ok(app) => Self::App(app), Err(_) => Self::Unknown(code), @@ -556,6 +562,7 @@ impl From<&Error> for SessionError { Error::Stream(_) => Self::Internal, Error::Cancel | Error::Closed | Error::GoingAway | Error::SessionClosed => Self::Cancel, Error::Unauthorized => Self::Unauthorized, + Error::Unsupported => Self::Unsupported, Error::Version | Error::UnknownAlpn(_) => Self::Version, Error::TooManyParameters => Self::KeyValueFormatting, Error::TooManyRequests => Self::TooManyRequests, @@ -647,6 +654,12 @@ mod tests { // Both registries are wire contracts now (draft-lcurley-moq-lite, Error Codes), so // every code we send must decode back to what we meant. + #[test] + fn unsupported_has_a_lite_session_code() { + assert_eq!(SessionError::from(&Error::Unsupported).to_code(), 0x30); + assert_eq!(SessionError::from_code(0x30), SessionError::Unsupported); + } + #[test] fn session_codes_round_trip() { // Only the registered codes are a wire contract, so only they round trip. @@ -660,6 +673,7 @@ mod tests { SessionError::GoawayTimeout, SessionError::Timeout, SessionError::Version, + SessionError::Unsupported, SessionError::App(0), SessionError::App(404), ]; diff --git a/rs/moq-net/src/ietf/error.rs b/rs/moq-net/src/ietf/error.rs index 9b9b4b77d2..4417e3a93d 100644 --- a/rs/moq-net/src/ietf/error.rs +++ b/rs/moq-net/src/ietf/error.rs @@ -290,7 +290,9 @@ pub(crate) mod request { Error::Timeout | Error::Stream(StreamError::DeliveryTimeout) | Error::Session(SessionError::Timeout) => { return TIMEOUT; } - Error::Unsupported | Error::Version | Error::Session(SessionError::Version) => return NOT_SUPPORTED, + Error::Unsupported | Error::Version | Error::Session(SessionError::Version | SessionError::Unsupported) => { + return NOT_SUPPORTED; + } // moq-transport has no code for a FETCH that reached a datagram, which is never // cached, so it is a group that is not there. NOT_SUPPORTED would claim the peer // does not implement FETCH at all. @@ -368,11 +370,12 @@ pub(crate) mod request { /// Every error a rejection distinguishes, plus one it does not, so the checks below cover /// the whole registry rather than the variants someone remembered. A new arm in /// [`to_code`] belongs here. - const EVERY_ERROR: [Error; 12] = [ + const EVERY_ERROR: [Error; 13] = [ Error::Duplicate, Error::Unauthorized, Error::Timeout, Error::Unsupported, + Error::Session(SessionError::Unsupported), Error::NotFound, Error::NotFetchable, Error::InvalidRange, @@ -581,6 +584,14 @@ pub(crate) mod request { } } +/// Map a session failure to the moq-transport registry, which has no NOT_SUPPORTED. +pub(crate) fn to_session_code(err: &crate::Error) -> u32 { + match SessionError::from(err) { + SessionError::Unsupported => SessionError::Internal.to_code(), + code => code.to_code(), + } +} + #[cfg(test)] mod tests { use super::*; @@ -598,6 +609,15 @@ mod tests { Version::Draft22, ]; + #[test] + fn lite_unsupported_is_not_an_ietf_session_code() { + assert_eq!(to_session_code(&Error::Unsupported), SessionError::Internal.to_code()); + assert_eq!( + to_session_code(&Error::Session(SessionError::Unsupported)), + SessionError::Internal.to_code() + ); + } + /// A routine unsubscribe must not read as a fault on our side. moq-lite's own error /// enum encodes a cancellation as 0, which is this wire's INTERNAL_ERROR, so the codes /// come from here instead. diff --git a/rs/moq-net/src/ietf/session.rs b/rs/moq-net/src/ietf/session.rs index e5f9dfb6a9..142b0bd1ad 100644 --- a/rs/moq-net/src/ietf/session.rs +++ b/rs/moq-net/src/ietf/session.rs @@ -204,7 +204,7 @@ where Version::Draft14 | Version::Draft15 | Version::Draft16 => { let Some(setup) = setup else { let err = Error::ProtocolViolation; - session.close(SessionError::from(&err).to_code(), "setup stream required"); + session.close(super::error::to_session_code(&err), "setup stream required"); return Err(err); }; let control = Control::new(request_id_max, client).with_window(limits.requests(), client); @@ -580,7 +580,7 @@ where } Err(err) => { tracing::warn!(%err, "session error"); - session.close(SessionError::from(err).to_code(), err.to_string().as_ref()); + session.close(super::error::to_session_code(err), err.to_string().as_ref()); } _ => { tracing::info!("session closed"); @@ -1313,7 +1313,7 @@ async fn enforce_grant( tracing::error!(broadcast = %origin.absolute(&path), "publishing outside our grant; closing the session"); let err = Error::Unauthorized; session.close( - SessionError::from(&err).to_code(), + super::error::to_session_code(&err), &crate::auth::unauthorized_reason(&path), ); Err(err) diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index 0bc24118f7..3ba05ff76a 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -399,6 +399,7 @@ struct AuthServe { default: Option, /// Our side is finished: FIN sent, waiting for the acknowledgement. finished: bool, + unsupported: bool, } impl AuthServe { @@ -413,12 +414,14 @@ impl AuthServe { serving: None, default: None, finished: false, + unsupported: false, }) } fn poll(&mut self, waiter: &kio::Waiter) -> Poll> { let res = ready!(self.poll_serve(waiter)); let err = match res { + Ok(()) if self.unsupported => Error::Unsupported, Ok(()) => Error::Cancel, Err(err) => { match &err { @@ -456,9 +459,16 @@ impl AuthServe { let _ = requests.try_push(crate::auth::Request::new(msg.token, issue.clone())); } // Only the connection's own credential has a default answer; a token - // needs someone to verify it. Resetting reads as "unsupported" to - // the presenter, the same as a peer that predates AUTH. - None if !msg.token.is_empty() => return Poll::Ready(Err(Error::Unsupported)), + // needs someone to verify it. Tell the presenter explicitly. + None if !msg.token.is_empty() => { + let mut state = issue.lock(); + state.outbox.push_back(crate::auth::Reply::Refuse { + code: crate::SessionError::Unsupported, + reason: Error::Unsupported.to_string(), + }); + state.done = true; + self.unsupported = true; + } None => { self.default = Some(crate::auth::DefaultGrant::new( self.shared.auth.clone(), @@ -508,7 +518,12 @@ impl AuthServe { match state.outbox.pop_front() { Some(reply) => { drop(state); - Self::write(&self.shared, stream, reply)?; + if !Self::write(&self.shared, stream, reply)? { + self.unsupported = true; + let mut state = issue.lock(); + state.outbox.clear(); + state.done = true; + } } // The app is done with the grant, or refused the token: close our side. None => { @@ -520,7 +535,7 @@ impl AuthServe { } } - fn write(shared: &Shared, stream: &mut Stream, reply: crate::auth::Reply) -> Result<(), Error> { + fn write(shared: &Shared, stream: &mut Stream, reply: crate::auth::Reply) -> Result { let msg = match reply { crate::auth::Reply::Grant(grant) => { let now = shared.runtime.now(); @@ -535,13 +550,16 @@ impl AuthServe { reason, }), }; - // A grant that cannot be encoded is withheld, never trimmed. Nothing is buffered, and - // the reset that follows ends the token: as the first reply it reads as "unsupported", - // and after an AUTH_OK it revokes the earlier grant. - stream.writer.buffer(&msg).map_err(|err| { + // Message encoding is transactional: a refused grant leaves no bytes behind. + if let Err(err) = stream.writer.buffer(&msg) { tracing::debug!(%err, "auth reply cannot be encoded; refusing the token"); - Error::Unsupported - }) + stream.writer.buffer(&lite::AuthReply::Error(lite::AuthError { + code: crate::SessionError::Unsupported.to_code().into(), + reason: Error::Unsupported.to_string(), + }))?; + return Ok(false); + } + Ok(true) } } diff --git a/rs/moq-net/src/lite/session.rs b/rs/moq-net/src/lite/session.rs index a40940f483..2017ad2c17 100644 --- a/rs/moq-net/src/lite/session.rs +++ b/rs/moq-net/src/lite/session.rs @@ -631,9 +631,14 @@ impl PresentToken { let Ok(code) = u32::try_from(refused.code) else { return Poll::Ready(Error::ProtocolViolation); }; - let err = Error::Session(crate::SessionError::from_code(code)); + let err = match crate::SessionError::from_code(code) { + crate::SessionError::Unsupported => Error::Unsupported, + code => Error::Session(code), + }; tracing::warn!(%err, reason = %refused.reason, "auth token refused"); - self.handle.refused(self.id); + if !matches!(err, Error::Unsupported) { + self.handle.refused(self.id); + } *answered = true; return Poll::Ready(err); } diff --git a/rs/moq-net/src/test_interop.rs b/rs/moq-net/src/test_interop.rs index 1b8cee78a0..81349ee283 100644 --- a/rs/moq-net/src/test_interop.rs +++ b/rs/moq-net/src/test_interop.rs @@ -280,3 +280,35 @@ fn ietf_datagram_interop() { assert_eq!(timestamp.map(|t| t.as_millis()), Some(1234), "{version}: Timestamp"); } } + +/// Unsupported after a grant keeps its meaning across the Rust/JS Lite binding boundary. +#[test] +#[ignore = "requires Bun; run by just test interop"] +fn lite_auth_unsupported_interop() { + let version = lite::Version::Lite07; + let grant = lite::AuthReply::Ok(lite::AuthOk { + publish: crate::Patterns::from(crate::Pattern::subtree("room").unwrap()), + subscribe: crate::Patterns::new(), + expires: None, + }); + let refused = lite::AuthReply::Error(lite::AuthError { + code: crate::SessionError::Unsupported.to_code().into(), + reason: "unsupported".into(), + }); + let mut bytes = Vec::new(); + let mut writer = Encoder::new(&mut bytes, version.into()); + grant.encode(&mut writer, version).unwrap(); + refused.encode(&mut writer, version).unwrap(); + let echo: Vec = serde_json::from_slice(&bun("auth-unsupported.ts", serde_json::json!(bytes))).unwrap(); + assert_eq!(echo, bytes); + let mut reader = Decoder::new(&echo, version.into()); + assert_eq!( + >::decode(&mut reader, version).unwrap(), + grant + ); + assert_eq!( + >::decode(&mut reader, version).unwrap(), + refused + ); + assert!(reader.is_empty()); +} diff --git a/rs/moq-net/tests/auth.rs b/rs/moq-net/tests/auth.rs index e402c0b7d5..d8efa4c691 100644 --- a/rs/moq-net/tests/auth.rs +++ b/rs/moq-net/tests/auth.rs @@ -59,6 +59,7 @@ cases!( an_issued_grant_ends_with_its_session, a_reset_auth_stream_reports_unsupported, a_revoked_grant_cancels_its_subscriptions, + a_grant_update_too_large_for_one_message_is_unsupported, nothing_outside_the_grant_reaches_the_peer, ); @@ -939,6 +940,41 @@ async fn a_grant_too_large_for_one_message_is_unsupported(version: &'static str) .expect("timed out"); } +async fn a_grant_update_too_large_for_one_message_is_unsupported(version: &'static str) { + within(async { + let mut pair = connect(Options { + version: Some(version), + client_publish: Some(produce_origin(2)), + server_subscribe: Some(produce_origin(1)), + server_requests: true, + ..Default::default() + }) + .await; + let mut issued = serve(pair.requests.take().unwrap(), |token| { + Some(grant(if token.is_empty() { &["a"] } else { &["b"] }, &[])) + }); + let (_, _setup) = issued.next().await.unwrap(); + let token = pair.client.auth().add("update").await.unwrap(); + let (_, updated) = issued.next().await.unwrap(); + assert_eq!(granted(&pair.client).await, grant(&["a", "b"], &[])); + let huge: Patterns = (0..20) + .map(|i| Pattern::subtree(&format!("{i}{}", "x".repeat(4000))).unwrap()) + .collect(); + updated.update(Grant { + publish: huge, + subscribe: Patterns::new(), + expires: None, + }); + let err = token.closed().await; + assert!(matches!(err, Error::Unsupported), "{err:?}"); + assert_eq!(token.grant().peek(), None); + wait_for(pair.client.auth().grant(), |g| g == &Some(grant(&["a"], &[]))).await; + assert_eq!(pair.client_transport.close_reason(), None); + }) + .await + .expect("timed out"); +} + fn pattern_set(texts: &[&str]) -> Patterns { texts.iter().map(|text| Pattern::try_from(*text).unwrap()).collect() } diff --git a/test/interop/README.md b/test/interop/README.md index cc276caad3..0fa69288f7 100644 --- a/test/interop/README.md +++ b/test/interop/README.md @@ -337,6 +337,10 @@ hands moq-net's QUIC and leading-ones encodings of each varint size boundary `number` conversion, and returns js/net's own encodings, which Rust requires to match byte for byte and decode back to the same value. +`lite_auth_unsupported_interop` sends an AUTH_OK followed by AUTH_ERROR +NOT_SUPPORTED across Rust and JS in both directions, checking that the later +refusal retains its meaning and both bindings emit the same bytes. + `lite_varint_interop` runs next to it and does the same through moq-lite's version dispatch: `lite-varint.ts` decodes Rust's lite-06 (QUIC) and lite-07 (leading-ones) varints, a SETUP carrying a 62-bit Hop ID, a datagram, and a diff --git a/test/interop/auth-unsupported.ts b/test/interop/auth-unsupported.ts new file mode 100644 index 0000000000..f1744bd13a --- /dev/null +++ b/test/interop/auth-unsupported.ts @@ -0,0 +1,32 @@ +// The Rust interop preflight sends a grant followed by NOT_SUPPORTED, then reads ours back. +import { Unsupported } from "../../js/net/src/auth.ts"; +import { LiteAuthWire } from "../../js/net/src/lite/auth.ts"; +import { Version } from "../../js/net/src/lite/version.ts"; +import { Stream } from "../../js/net/src/stream.ts"; + +const bytes = Uint8Array.from(JSON.parse(process.argv[2]) as number[]); +const encoded: Uint8Array[] = []; +const stream = new Stream({ + readable: new ReadableStream({ + start(controller) { + controller.enqueue(bytes); + controller.close(); + }, + }), + writable: new WritableStream({ + write(chunk) { + encoded.push(new Uint8Array(chunk)); + }, + }), + version: Version.DRAFT_07, +}); +const wire = new LiteAuthWire({} as WebTransport, Version.DRAFT_07); +const first = await wire.read(stream); +if (!first || !("grant" in first) || !first.grant.publish.matches("room/cam")) throw new Error("Rust grant missing"); +const second = await wire.read(stream); +if (!second || !("refused" in second) || !(second.refused instanceof Unsupported)) + throw new Error("Rust unsupported lost"); +await wire.grant(stream, first.grant); +await wire.unsupported(stream, "unsupported"); +await stream.writer.closed; +process.stdout.write(JSON.stringify([...Buffer.concat(encoded)])); diff --git a/test/justfile b/test/justfile index 30114d289d..c31e532a8e 100644 --- a/test/justfile +++ b/test/justfile @@ -36,10 +36,11 @@ harness: # Finite track tails run too; `--tail` selects just Rust/Node/Bun tail checks. # Both runs first check js/net's varints against moq-net's encoder at every size boundary, # and on lite-06/07 messages (the filter also matches `lite_varint_interop`), then -# moq-transport OBJECT_DATAGRAMs both ways on every draft. +# moq-transport OBJECT_DATAGRAMs both ways on every draft, and Lite AUTH_ERROR +# NOT_SUPPORTED after a grant in both directions. [positional-arguments] interop *args: - just rs test -p moq-net --lib varint_interop ietf_datagram_interop --run-ignored all + just rs test -p moq-net --lib varint_interop ietf_datagram_interop auth_unsupported_interop --run-ignored all ./interop/interop.sh "$@" # Nightly main vs newest installable registry releases: tokens, moq-lite session From efb9586b42cb8bfcd687cb7ebb300ccad11cb1bf Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 10 Oct 2026 14:42:38 -0700 Subject: [PATCH 3/8] quest: retire Lite AUTH NOT_SUPPORTED Co-Authored-By: GPT-6 --- quest/m1/auth/README.md | 3 +- quest/m1/auth/expired-error.md | 2 +- quest/m1/auth/not-supported.md | 52 ---------------------------------- 3 files changed, 3 insertions(+), 54 deletions(-) delete mode 100644 quest/m1/auth/not-supported.md diff --git a/quest/m1/auth/README.md b/quest/m1/auth/README.md index c867be64a1..26e7a63e42 100644 --- a/quest/m1/auth/README.md +++ b/quest/m1/auth/README.md @@ -99,7 +99,6 @@ because `main` assigned 0x3A to NOT_FETCHABLE first. ## Required - [WebSocket refusal](/quest/m1/auth/ws-unauthorized.md) - the relay serves WebSocket through moq-tokio, so a refused token closes the session as Unauthorized, as QUIC does -- [Lite NOT_SUPPORTED](/quest/m1/auth/not-supported.md) - a lite acceptor answers AUTH_ERROR NOT_SUPPORTED after a grant too, with a lite session code for `Error::Unsupported` - [AUTH violations](/quest/m1/auth/violations.md) - every AUTH protocol violation closes the session in Rust and JS, lite and IETF - [Malformed grant](/quest/m1/auth/malformed-grant.md) - a malformed or non-canonical grant pattern, or an out-of-range `Expires`, closes the @@ -133,3 +132,5 @@ because `main` assigned 0x3A to NOT_FETCHABLE first. - [Peer grants](/quest/m3/p2p/peer-grant.md) - P2P's node-bound credential, built on this line's relay tokens + +- [#5269](https://github.com/moq-dev/moq/pull/5269) - explicit Lite AUTH NOT_SUPPORTED, including grant updates diff --git a/quest/m1/auth/expired-error.md b/quest/m1/auth/expired-error.md index e2b322f743..ae4ecae1c3 100644 --- a/quest/m1/auth/expired-error.md +++ b/quest/m1/auth/expired-error.md @@ -13,7 +13,7 @@ refusal as final. on `main`. Map it from lite's `AUTH_ERROR { Expired }` and moq-transport's `EXPIRED_AUTH_TOKEN`, and back again when refusing. - Lite session code 0x30 is NOT_SUPPORTED - ([Lite NOT_SUPPORTED](/quest/m1/auth/not-supported.md)). `Expired` takes a + ([Lite NOT_SUPPORTED](https://github.com/moq-dev/moq/pull/5269)). `Expired` takes a shared moq-transport session value if one means the same thing, otherwise 0x31. - Carry it through moq-ffi's error mapping and each wrapper. diff --git a/quest/m1/auth/not-supported.md b/quest/m1/auth/not-supported.md deleted file mode 100644 index 21d565c768..0000000000 --- a/quest/m1/auth/not-supported.md +++ /dev/null @@ -1,52 +0,0 @@ -# [M] Lite NOT_SUPPORTED - -## Goal - -A lite presenter learns that its token is unsupported whenever that happens, -not only on the first reply. Today, if a grant update is too large for AUTH_OK -after a grant was issued, the acceptor resets the stream with INTERNAL_ERROR. -The grant is revoked, but the presenter sees a generic stream error, while the -moq-transport binding answers `AUTH_ERROR { NOT_SUPPORTED }`. After this -change, lite has a NOT_SUPPORTED session code, so `Error::Unsupported` -round-trips on both wires. - -## Plan - -Decided while planning the follow-ups of -[#4446](https://github.com/moq-dev/moq/pull/4446): - -- **The carrier is AUTH_ERROR, not a stream reset code.** AUTH_ERROR after - AUTH_OK already revokes a grant, so this mirrors the IETF binding exactly. - A lite acceptor answers `AUTH_ERROR { NOT_SUPPORTED }` for every - unsupported case: no in-band verification, a grant that cannot be - expressed, or a grant too large for AUTH_OK, whether as the first reply or - after one. A presenter still reads a reset before any reply as unsupported, - because older acceptors and peers without the Auth Stream reset. -- **The code is lite session code 0x30 NOT_SUPPORTED**, the first of - moq-lite's own range (48 to 63). moq-transport's session registry has no - NOT_SUPPORTED. A bridge maps it to REQUEST_ERROR NOT_SUPPORTED (0x3), which - `Error::Unsupported` already does on IETF. - [Expired token error](/quest/m1/auth/expired-error.md) allocates after it. -- **`Error::Unsupported` maps to 0x30 everywhere on lite**, not just in - AUTH_ERROR. Rust `SessionError` and JS `SessionCode` round-trip it, so a - session closed for an unsupported feature stops reading as INTERNAL_ERROR. -- **The message ceiling is not this quest's** (decided in the 2026-10-06 - audit): request caps (#4820) caps lite messages at 65,535 bytes, moq-transport's 16-bit control Message Length, so - a grant too large for AUTH_OK is too large on both wires. This quest only - answers that case with NOT_SUPPORTED. -- **Compatibility.** An old peer that receives 0x30 treats it as an - unspecified error, as the draft requires. That is no worse than today's - reset. -- Drafts: add the session code to `drafts/draft-lcurley-moq-lite.md`, and - replace the reset rule in the Auth Stream section. Update `drafts/draft-lcurley-moq-auth.md` only where it - refers to lite. `just drafts check`. -- Tests: a regression test per side (Rust and JS). An oversized update after - an AUTH_OK reports `Unsupported` and revokes the grant. Also add an interop - case, run with `just test interop --all`. - -Public API: none new (`Error::Unsupported` exists). Wire: a new lite session -code. - -## Related - -- [AUTH violations](/quest/m1/auth/violations.md) - AUTH protocol violations close the session on the same path (AUTH endings landed in #4550) From d45b9337b44c680a387bc95b2af4eaf024fb7f2d Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 10 Oct 2026 15:49:52 -0700 Subject: [PATCH 4/8] test(net): recognize the assigned Lite unsupported code Co-Authored-By: GPT-6 --- js/net/src/error.test.ts | 10 ++++++++-- test/interop/README.md | 4 ++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/js/net/src/error.test.ts b/js/net/src/error.test.ts index c6d5756c2a..8d94abba70 100644 --- a/js/net/src/error.test.ts +++ b/js/net/src/error.test.ts @@ -214,10 +214,16 @@ test("the code tables match the spec", () => { expect(Number(StreamCode.SessionClosed)).toBe(0x3); expect(Number(StreamCode.TooFarBehind)).toBe(0x5); - // The session table carries only what the draft assigns. + // Session codes retain moq-transport assignments below 32; Lite owns 48-63. for (const code of Object.values(SessionCode)) { - expect(code).toBeLessThan(32); + if (code === SessionCode.Unsupported) { + expect(code).toBeGreaterThanOrEqual(48); + expect(code).toBeLessThan(64); + } else { + expect(code).toBeLessThan(32); + } } + expect(Number(SessionCode.Unsupported)).toBe(0x30); // The stream table adds moq-lite's own 48-63 assignments. 32-47 is reserved and // carries nothing, so no code sits there. diff --git a/test/interop/README.md b/test/interop/README.md index 0fa69288f7..310357f7a1 100644 --- a/test/interop/README.md +++ b/test/interop/README.md @@ -337,8 +337,8 @@ hands moq-net's QUIC and leading-ones encodings of each varint size boundary `number` conversion, and returns js/net's own encodings, which Rust requires to match byte for byte and decode back to the same value. -`lite_auth_unsupported_interop` sends an AUTH_OK followed by AUTH_ERROR -NOT_SUPPORTED across Rust and JS in both directions, checking that the later +`lite_auth_unsupported_interop` sends an AUTH\_OK followed by AUTH\_ERROR +NOT\_SUPPORTED across Rust and JS in both directions, checking that the later refusal retains its meaning and both bindings emit the same bytes. `lite_varint_interop` runs next to it and does the same through moq-lite's From 8da84fd2f9efd54bf057f2442bbfd8864a4dfd6f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 11 Oct 2026 01:02:12 -0700 Subject: [PATCH 5/8] fix(net): scope session errors to the negotiated protocol Co-Authored-By: GPT-6 Astra --- drafts/draft-lcurley-moq-lite.md | 1 - js/net/src/auth.test.ts | 20 ++++++++++++++++ js/net/src/lite/auth.test.ts | 2 +- rs/moq-net/src/coding/codes.rs | 39 +++++++++++++++++++++++++++---- rs/moq-net/src/coding/stream.rs | 8 +++---- rs/moq-net/src/error.rs | 22 ++++++++--------- rs/moq-net/src/ietf/error.rs | 17 -------------- rs/moq-net/src/ietf/session.rs | 6 ++--- rs/moq-net/src/lite/error.rs | 24 +++++++++++++++++++ rs/moq-net/src/lite/mod.rs | 1 + rs/moq-net/src/lite/publisher.rs | 24 +++++++++---------- rs/moq-net/src/lite/session.rs | 12 ++++++---- rs/moq-net/src/lite/subscriber.rs | 10 ++++---- rs/moq-net/src/server.rs | 22 +++++++++-------- rs/moq-net/src/session.rs | 8 ++++--- rs/moq-net/src/test_interop.rs | 2 +- rs/moq-net/src/version.rs | 7 ++++++ rs/moq-net/tests/session_close.rs | 30 ++++++++++++++++++++++++ rs/moq-net/tests/session_setup.rs | 20 ++++++++++++++++ 19 files changed, 198 insertions(+), 77 deletions(-) create mode 100644 rs/moq-net/src/lite/error.rs diff --git a/drafts/draft-lcurley-moq-lite.md b/drafts/draft-lcurley-moq-lite.md index 84a2124c7f..46ab79eb84 100644 --- a/drafts/draft-lcurley-moq-lite.md +++ b/drafts/draft-lcurley-moq-lite.md @@ -549,7 +549,6 @@ An endpoint that announces a broadcast outside the union once its own setup toke An acceptor that does not verify a token in band, or cannot express its grant in AUTH_OK, MUST send AUTH_ERROR with NOT_SUPPORTED and close its side of the stream. This applies before or after an AUTH_OK, including when a grant exceeds the message size limit. A presenter receiving a stream reset before any reply treats it as unsupported, for compatibility with peers that do not support the Auth Stream (see [STREAM_TYPE](#stream_type)). -An opener that sees the stream reset before any reply treats the token as unsupported rather than refused. ### Goaway Either endpoint can open a Goaway Stream (0x5) to initiate a graceful session shutdown. diff --git a/js/net/src/auth.test.ts b/js/net/src/auth.test.ts index fc7dcdc633..97e2818bb5 100644 --- a/js/net/src/auth.test.ts +++ b/js/net/src/auth.test.ts @@ -179,6 +179,26 @@ describe.each([Lite.ALPN_07_WIP, Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_22])("%s", server.abort(); }); + test("an explicit unsupported token uses the negotiated AUTH registry", async () => { + const { client, server } = await connect({ publish: new OriginProducer(), protocol }); + const requests = server.auth.requests(); + const answering = (async () => { + for (;;) { + const request = await requests.next(); + if (!request) break; + if (request.token.byteLength === 0) request.accept(grant([], [])); + else request.reject(SessionCode.Unsupported, "credential kind unsupported"); + } + })(); + try { + await expect(client.auth.add("unsupported")).rejects.toBeInstanceOf(Unsupported); + } finally { + client.abort(); + server.abort(); + await answering; + } + }); + test("a refused token surfaces the acceptor's code and reason", async () => { const { client, server } = await connect({ publish: new OriginProducer(), protocol }); const requests = server.auth.requests(); diff --git a/js/net/src/lite/auth.test.ts b/js/net/src/lite/auth.test.ts index f6e24c0ae0..edf29a15ea 100644 --- a/js/net/src/lite/auth.test.ts +++ b/js/net/src/lite/auth.test.ts @@ -88,7 +88,7 @@ test("AUTH_OK matches the Rust encoding", async () => { }); // Mirrors `oversized_message_is_refused` in `rs/moq-net/src/lite/auth.rs`: the peer would -// refuse it, so the acceptor withholds it, type included, and resets the stream instead. +// refuse it, so the acceptor withholds it, type included, before sending AUTH_ERROR. test("a reply larger than the peer reads writes nothing", async () => { const body = new Uint8Array(Message.MAX_MESSAGE_SIZE + 1); const r = await roundTrip(async (w) => { diff --git a/rs/moq-net/src/coding/codes.rs b/rs/moq-net/src/coding/codes.rs index 409ae657bb..af5f828072 100644 --- a/rs/moq-net/src/coding/codes.rs +++ b/rs/moq-net/src/coding/codes.rs @@ -1,4 +1,4 @@ -//! Which stream reset registry a stream's codes are written and read with. +//! The negotiated registries for errors reported by a stream. use crate::{Error, StreamError, ietf, lite}; @@ -20,14 +20,18 @@ pub trait StreamCodes { /// Read a code the peer reset a stream (or sent STOP_SENDING) with. fn decode_stream_code(&self, code: u32) -> StreamError; + /// Read the session close reported while using this stream's protocol. + fn decode_session_code(&self, code: u32) -> crate::SessionError { + crate::SessionError::from_code(code) + } + /// Turn a transport failure into an [`Error`], reading a stream reset with this /// registry. /// - /// A session close is not stream-scoped, so it decodes through - /// [`SessionError`](crate::SessionError) exactly as [`Error::from_transport`] does. + /// Session closes use the same negotiated protocol as stream resets. fn transport_error(&self, err: E) -> Error { if let Some((code, _reason)) = err.session_error() { - return crate::SessionError::from_code(code).into(); + return self.decode_session_code(code).into(); } if let Some(code) = err.stream_error() { @@ -41,6 +45,10 @@ pub trait StreamCodes { /// moq-lite's registry, specified by draft-lcurley-moq-lite (Error Codes). A code a version /// lacks is sent there as the closest one it has, and not read back there. impl StreamCodes for lite::Version { + fn decode_session_code(&self, code: u32) -> crate::SessionError { + lite::error::from_session_code(code) + } + fn encode_stream_code(&self, err: &StreamError) -> u32 { match err { StreamError::NotFetchable if !self.has_not_fetchable() => StreamError::NotFound.to_code(), @@ -70,6 +78,13 @@ impl StreamCodes for ietf::Version { /// The negotiated version before it is narrowed to one protocol, e.g. the SETUP stream a /// pre-lite-05 session opens. impl StreamCodes for crate::Version { + fn decode_session_code(&self, code: u32) -> crate::SessionError { + match self { + Self::Lite(version) => version.decode_session_code(code), + Self::Ietf(version) => version.decode_session_code(code), + } + } + fn encode_stream_code(&self, err: &StreamError) -> u32 { match self { Self::Lite(version) => version.encode_stream_code(err), @@ -196,6 +211,22 @@ mod tests { Error::Stream(StreamError::Internal) )); + // A session-level failure still needs the stream's negotiated protocol. + assert!(matches!( + version.transport_error(Failed { + session: Some(0x30), + stream: None + }), + Error::Session(crate::SessionError::Unknown(0x30)) + )); + assert!(matches!( + lite::Version::Lite07.transport_error(Failed { + session: Some(0x30), + stream: None + }), + Error::Session(crate::SessionError::Unsupported) + )); + // Neither: the transport itself failed. assert!(matches!( version.transport_error(Failed { diff --git a/rs/moq-net/src/coding/stream.rs b/rs/moq-net/src/coding/stream.rs index 215e777010..1984620c73 100644 --- a/rs/moq-net/src/coding/stream.rs +++ b/rs/moq-net/src/coding/stream.rs @@ -31,7 +31,7 @@ impl Stream { where V: Clone, { - let (send, recv) = ready!(session.poll_open_bi(cx)).map_err(Error::from_transport)?; + let (send, recv) = ready!(session.poll_open_bi(cx)).map_err(|err| version.transport_error(err))?; Poll::Ready(Ok(Self::build(send, recv, version))) } @@ -40,7 +40,7 @@ impl Stream { where V: Clone, { - let (send, recv) = session.open_bi().await.map_err(Error::from_transport)?; + let (send, recv) = session.open_bi().await.map_err(|err| version.transport_error(err))?; Ok(Self::build(send, recv, version)) } @@ -49,7 +49,7 @@ impl Stream { where V: Clone, { - let (send, recv) = ready!(session.poll_accept_bi(cx)).map_err(Error::from_transport)?; + let (send, recv) = ready!(session.poll_accept_bi(cx)).map_err(|err| version.transport_error(err))?; Poll::Ready(Ok(Self::build(send, recv, version))) } @@ -58,7 +58,7 @@ impl Stream { where V: Clone, { - let (send, recv) = session.accept_bi().await.map_err(Error::from_transport)?; + let (send, recv) = session.accept_bi().await.map_err(|err| version.transport_error(err))?; Ok(Self::build(send, recv, version)) } diff --git a/rs/moq-net/src/error.rs b/rs/moq-net/src/error.rs index 5d6a676977..b9b2b770b1 100644 --- a/rs/moq-net/src/error.rs +++ b/rs/moq-net/src/error.rs @@ -6,8 +6,10 @@ use crate::coding; /// unchanged; 64+ are the application's. The stream registry is [`StreamError`] and the two /// are disjoint, so the same integer means different things in each. /// -/// Every variant is a registered code, so the registry round-trips: 32 through 47 is -/// reserved, nothing is sent there, and a received one stays [`Unknown`](Self::Unknown). +/// Shared codes round-trip through [`to_code`](Self::to_code) and +/// [`from_code`](Self::from_code). Protocol drivers translate Lite-only variants at +/// their wire boundary; the shared decoder keeps those numbers [`Unknown`](Self::Unknown). +/// Codes 32 through 47 are reserved and have no assigned meaning. #[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)] #[non_exhaustive] pub enum SessionError { @@ -64,7 +66,7 @@ pub enum SessionError { } impl SessionError { - /// The integer sent on the wire. + /// The shared session code; Lite-only conditions fall back to an internal error. pub fn to_code(&self) -> u32 { match self { Self::Cancel => 0x0, @@ -76,13 +78,13 @@ impl SessionError { Self::GoawayTimeout => 0x10, Self::Timeout => 0x11, Self::Version => 0x15, - Self::Unsupported => 0x30, + Self::Unsupported => 0x1, Self::App(app) => *app as u32 + 64, Self::Unknown(code) => *code, } } - /// Decode a code received off the wire. + /// Decode a shared session code, leaving protocol-specific codes unknown. /// /// Unlike a raw peer code, the registered ones are specified, so decoding them is a /// wire contract rather than an assumption. Anything unregistered stays @@ -98,7 +100,6 @@ impl SessionError { 0x10 => Self::GoawayTimeout, 0x11 => Self::Timeout, 0x15 => Self::Version, - 0x30 => Self::Unsupported, code @ 64.. => match u16::try_from(code - 64) { Ok(app) => Self::App(app), Err(_) => Self::Unknown(code), @@ -655,9 +656,9 @@ mod tests { // Both registries are wire contracts now (draft-lcurley-moq-lite, Error Codes), so // every code we send must decode back to what we meant. #[test] - fn unsupported_has_a_lite_session_code() { - assert_eq!(SessionError::from(&Error::Unsupported).to_code(), 0x30); - assert_eq!(SessionError::from_code(0x30), SessionError::Unsupported); + fn lite_unsupported_is_not_in_the_shared_session_registry() { + assert_eq!(SessionError::from(&Error::Unsupported).to_code(), 0x1); + assert_eq!(SessionError::from_code(0x30), SessionError::Unknown(0x30)); } #[test] @@ -673,7 +674,6 @@ mod tests { SessionError::GoawayTimeout, SessionError::Timeout, SessionError::Version, - SessionError::Unsupported, SessionError::App(0), SessionError::App(404), ]; @@ -693,7 +693,7 @@ mod tests { // The reserved 32-47 range, and anything else unregistered, keeps its value instead // of being given a meaning. A peer on the old placeholders (0x20-0x22) lands here. - for code in [0x1f, 0x20, 0x21, 0x22, 0x2f] { + for code in [0x1f, 0x20, 0x21, 0x22, 0x2f, 0x30] { assert_eq!(SessionError::from_code(code), SessionError::Unknown(code)); } diff --git a/rs/moq-net/src/ietf/error.rs b/rs/moq-net/src/ietf/error.rs index 4417e3a93d..a7d8dda8a1 100644 --- a/rs/moq-net/src/ietf/error.rs +++ b/rs/moq-net/src/ietf/error.rs @@ -584,14 +584,6 @@ pub(crate) mod request { } } -/// Map a session failure to the moq-transport registry, which has no NOT_SUPPORTED. -pub(crate) fn to_session_code(err: &crate::Error) -> u32 { - match SessionError::from(err) { - SessionError::Unsupported => SessionError::Internal.to_code(), - code => code.to_code(), - } -} - #[cfg(test)] mod tests { use super::*; @@ -609,15 +601,6 @@ mod tests { Version::Draft22, ]; - #[test] - fn lite_unsupported_is_not_an_ietf_session_code() { - assert_eq!(to_session_code(&Error::Unsupported), SessionError::Internal.to_code()); - assert_eq!( - to_session_code(&Error::Session(SessionError::Unsupported)), - SessionError::Internal.to_code() - ); - } - /// A routine unsubscribe must not read as a fault on our side. moq-lite's own error /// enum encodes a cancellation as 0, which is this wire's INTERNAL_ERROR, so the codes /// come from here instead. diff --git a/rs/moq-net/src/ietf/session.rs b/rs/moq-net/src/ietf/session.rs index 142b0bd1ad..e5f9dfb6a9 100644 --- a/rs/moq-net/src/ietf/session.rs +++ b/rs/moq-net/src/ietf/session.rs @@ -204,7 +204,7 @@ where Version::Draft14 | Version::Draft15 | Version::Draft16 => { let Some(setup) = setup else { let err = Error::ProtocolViolation; - session.close(super::error::to_session_code(&err), "setup stream required"); + session.close(SessionError::from(&err).to_code(), "setup stream required"); return Err(err); }; let control = Control::new(request_id_max, client).with_window(limits.requests(), client); @@ -580,7 +580,7 @@ where } Err(err) => { tracing::warn!(%err, "session error"); - session.close(super::error::to_session_code(err), err.to_string().as_ref()); + session.close(SessionError::from(err).to_code(), err.to_string().as_ref()); } _ => { tracing::info!("session closed"); @@ -1313,7 +1313,7 @@ async fn enforce_grant( tracing::error!(broadcast = %origin.absolute(&path), "publishing outside our grant; closing the session"); let err = Error::Unauthorized; session.close( - super::error::to_session_code(&err), + SessionError::from(&err).to_code(), &crate::auth::unauthorized_reason(&path), ); Err(err) diff --git a/rs/moq-net/src/lite/error.rs b/rs/moq-net/src/lite/error.rs new file mode 100644 index 0000000000..89a24e1e06 --- /dev/null +++ b/rs/moq-net/src/lite/error.rs @@ -0,0 +1,24 @@ +//! The Lite additions to the shared session error registry. + +use crate::{Error, SessionError}; + +pub(crate) fn to_session_code(error: &SessionError) -> u32 { + match error { + SessionError::Unsupported => 0x30, + error => error.to_code(), + } +} + +pub(crate) fn from_session_code(code: u32) -> SessionError { + match code { + 0x30 => SessionError::Unsupported, + code => SessionError::from_code(code), + } +} + +pub(crate) fn from_transport(error: impl crate::transport::Error) -> Error { + if let Some((code, _)) = error.session_error() { + return from_session_code(code).into(); + } + Error::from_transport(error) +} diff --git a/rs/moq-net/src/lite/mod.rs b/rs/moq-net/src/lite/mod.rs index aa9c3ec0b3..d577793743 100644 --- a/rs/moq-net/src/lite/mod.rs +++ b/rs/moq-net/src/lite/mod.rs @@ -9,6 +9,7 @@ mod auth; mod compress; mod datagram; mod epoch; +pub(crate) mod error; mod fetch; mod goaway; mod group; diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index d87b494cd5..b2407d87f4 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -371,10 +371,10 @@ impl Control { // logs per-stream errors, so close the session here rather than letting a // peer silently replace a redirect an observer may already be acting on. tracing::warn!(%uri, "duplicate GOAWAY received; closing session"); - self.shared - .session - .clone() - .close(SessionError::from(&err).to_code(), &err.to_string()); + self.shared.session.clone().close( + crate::lite::error::to_session_code(&SessionError::from(&err)), + &err.to_string(), + ); return Poll::Ready(Err(err)); } return Poll::Ready(Ok(())); @@ -546,7 +546,7 @@ impl AuthServe { }) } crate::auth::Reply::Refuse { code, reason } => lite::AuthReply::Error(lite::AuthError { - code: code.to_code().into(), + code: crate::lite::error::to_session_code(&code).into(), reason, }), }; @@ -554,7 +554,7 @@ impl AuthServe { if let Err(err) = stream.writer.buffer(&msg) { tracing::debug!(%err, "auth reply cannot be encoded; refusing the token"); stream.writer.buffer(&lite::AuthReply::Error(lite::AuthError { - code: crate::SessionError::Unsupported.to_code().into(), + code: crate::lite::error::to_session_code(&crate::SessionError::Unsupported).into(), reason: Error::Unsupported.to_string(), }))?; return Ok(false); @@ -594,7 +594,7 @@ impl Enforce { ); let err = Error::Unauthorized; shared.session.clone().close( - SessionError::from(&err).to_code(), + crate::lite::error::to_session_code(&SessionError::from(&err)), &crate::auth::unauthorized_reason(&path), ); Poll::Ready(Err(err)) @@ -1549,10 +1549,10 @@ impl> RequestServe { Ok(charged) => self._charged = Some(charged), // A peer past its limits loses the session, not just this request. Err(err) => { - self.shared - .session - .clone() - .close(crate::SessionError::from(&err).to_code(), "too many subscriptions"); + self.shared.session.clone().close( + crate::lite::error::to_session_code(&crate::SessionError::from(&err)), + "too many subscriptions", + ); return Poll::Ready(Err(err)); } } @@ -3651,7 +3651,7 @@ impl GroupServe { Err(err) => { self.settle_open(false); self.state = GroupState::Done; - return Poll::Ready(Err(Error::from_transport(err))); + return Poll::Ready(Err(crate::lite::error::from_transport(err))); } }; self.settle_open(true); diff --git a/rs/moq-net/src/lite/session.rs b/rs/moq-net/src/lite/session.rs index 2017ad2c17..b91277a9c3 100644 --- a/rs/moq-net/src/lite/session.rs +++ b/rs/moq-net/src/lite/session.rs @@ -69,7 +69,7 @@ impl SetupAccept { match stream { Ok(stream) => pending.push(Reader::new(stream, version)), Err(err) => { - accept_error = Some(Error::from_transport(err)); + accept_error = Some(crate::lite::error::from_transport(err)); break; } } @@ -405,8 +405,10 @@ where } Err(err) => { tracing::warn!(%err, "session error"); - self.session - .close(SessionError::from(err).to_code(), err.to_string().as_ref()); + self.session.close( + crate::lite::error::to_session_code(&SessionError::from(err)), + err.to_string().as_ref(), + ); } _ => { tracing::info!("session closed"); @@ -631,7 +633,7 @@ impl PresentToken { let Ok(code) = u32::try_from(refused.code) else { return Poll::Ready(Error::ProtocolViolation); }; - let err = match crate::SessionError::from_code(code) { + let err = match crate::lite::error::from_session_code(code) { crate::SessionError::Unsupported => Error::Unsupported, code => Error::Session(code), }; @@ -713,7 +715,7 @@ impl SendSetup { loop { match &mut self.state { SendSetupState::Open { session, setup } => { - let stream = ready!(session.poll_open_uni(cx)).map_err(Error::from_transport)?; + let stream = ready!(session.poll_open_uni(cx)).map_err(crate::lite::error::from_transport)?; let mut writer = Writer::new(stream, self.version); writer.buffer(&super::DataType::Setup)?; writer.buffer(&**setup)?; diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index 806b4bb6da..93607e791f 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -704,7 +704,7 @@ impl UniAccept { }, }); } - Poll::Ready(Err(err)) => return Poll::Ready(Err(Error::from_transport(err))), + Poll::Ready(Err(err)) => return Poll::Ready(Err(crate::lite::error::from_transport(err))), Poll::Pending => break, } } @@ -1125,7 +1125,7 @@ impl DatagramRecv { } let mut cx = waiter.context(); loop { - let payload = ready!(self.recv.poll_recv_datagram(&mut cx)).map_err(Error::from_transport)?; + let payload = ready!(self.recv.poll_recv_datagram(&mut cx)).map_err(crate::lite::error::from_transport)?; if let Err(err) = self.subscriber.route_datagram(payload) { tracing::debug!(%err, "dropping datagram"); } @@ -4501,7 +4501,7 @@ impl Establish { } EstablishState::WaitOk { stream } => { if let Poll::Ready(err) = self.closed.poll_closed(&mut cx) { - return Poll::Ready(Err(Error::from_transport(err))); + return Poll::Ready(Err(crate::lite::error::from_transport(err))); } let resp = ready!(stream.reader.poll_decode::(&mut cx))?; if !matches!(resp, lite::SubscribeResponse::Ok(_)) { @@ -4778,7 +4778,7 @@ impl TrackInfoFetch { } TrackInfoState::Read { stream } => { if let Poll::Ready(err) = self.closed.poll_closed(&mut cx) { - return Poll::Ready(Err(Error::from_transport(err))); + return Poll::Ready(Err(crate::lite::error::from_transport(err))); } let info = ready!(stream.reader.poll_decode::(&mut cx))?; let TrackInfoState::Read { stream } = std::mem::replace(&mut self.state, TrackInfoState::Open) @@ -5224,7 +5224,7 @@ impl ServeLoop { // (5) The session died: hand the track back for another route, with // the session's error in case none takes it. if let Poll::Ready(err) = self.closed.poll_closed(&mut cx) { - return Poll::Ready(ServeEnd::GiveBack(Error::from_transport(err))); + return Poll::Ready(ServeEnd::GiveBack(crate::lite::error::from_transport(err))); } return Poll::Pending; diff --git a/rs/moq-net/src/server.rs b/rs/moq-net/src/server.rs index 95d2651ab2..f4472e656b 100644 --- a/rs/moq-net/src/server.rs +++ b/rs/moq-net/src/server.rs @@ -173,7 +173,7 @@ impl Server { let mut refused = session.clone(); self.handshake_lite(now, session) .await - .inspect_err(|err| close(&mut refused, err)) + .inspect_err(|err| close(&mut refused, err, None)) } async fn handshake_lite(&self, now: Instant, mut session: S) -> Result, Error> @@ -296,7 +296,7 @@ impl Server { let mut refused = session.clone(); self.handshake(now, session) .await - .inspect_err(|err| close(&mut refused, err)) + .inspect_err(|err| close(&mut refused, err, None)) } async fn handshake(&self, now: Instant, mut session: S) -> Result, Error> @@ -594,7 +594,7 @@ where } fn close(mut self: Box, err: Error) { - close(&mut self.session, &err); + close(&mut self.session, &err, Some(self.version.into())); } } @@ -723,7 +723,7 @@ where } fn close(mut self: Box, err: Error) { - close(&mut self.session, &err); + close(&mut self.session, &err, Some(self.version)); } } @@ -856,18 +856,20 @@ where impl RequestInner { fn close(self, err: Error) { - let mut session = match self.handshake { - PausedHandshake::LiteBare { session, .. } => session, - PausedHandshake::LiteSetup { session, .. } => session, + let (mut session, version) = match self.handshake { + PausedHandshake::LiteBare { session, version } => (session, version), + PausedHandshake::LiteSetup { session, version, .. } => (session, version), PausedHandshake::Boxed(paused) => return paused.close(err), }; - close(&mut session, &err); + close(&mut session, &err, Some(version.into())); } } /// Close `session` with `err`'s wire code. -fn close(session: &mut S, err: &Error) { - session.close(SessionError::from(err).to_code(), &err.to_string()); +fn close(session: &mut S, err: &Error, version: Option) { + let error = SessionError::from(err); + let code = version.map_or_else(|| error.to_code(), |version| version.session_code(&error)); + session.close(code, &err.to_string()); } impl Drop for Handshake { diff --git a/rs/moq-net/src/session.rs b/rs/moq-net/src/session.rs index 4303a0baf5..c10968de5a 100644 --- a/rs/moq-net/src/session.rs +++ b/rs/moq-net/src/session.rs @@ -4,7 +4,7 @@ use std::{sync::Arc, task::Poll, time::Duration}; use crate::transport::Stats as _; -use crate::{Error, SessionError, Version, auth, bandwidth, goaway}; +use crate::{Error, SessionError, Version, auth, bandwidth, coding::StreamCodes, goaway}; /// How long [`Session::close`] waits for queued data before closing anyway. const CLOSE_TIMEOUT: Duration = Duration::from_secs(1); @@ -181,7 +181,7 @@ impl Session { && !matches!(*close, Some(Close::Abort { .. })) { *close = Some(Close::Abort { - code: SessionError::from(&err).to_code(), + code: self.version.session_code(&SessionError::from(&err)), reason: err.to_string(), }); } @@ -352,6 +352,7 @@ impl Session { }); let supervisor = Supervisor { + version, local_close: protocol.local_close(), runtime: runtime.clone(), closed_watch: session.clone(), @@ -395,6 +396,7 @@ impl Session { /// /// Finishes once the transport reports closed; everything else is moot then. pub(crate) struct Supervisor { + version: Version, local_close: Arc, runtime: crate::time::Clock, session: S, @@ -450,7 +452,7 @@ impl Supervisor { }; if let Ok(mut closed) = self.closed.write() { *closed = Some(Ended { - err: Error::from_transport(err), + err: self.version.transport_error(err), drain, }); } diff --git a/rs/moq-net/src/test_interop.rs b/rs/moq-net/src/test_interop.rs index 81349ee283..a3b9dbe47e 100644 --- a/rs/moq-net/src/test_interop.rs +++ b/rs/moq-net/src/test_interop.rs @@ -292,7 +292,7 @@ fn lite_auth_unsupported_interop() { expires: None, }); let refused = lite::AuthReply::Error(lite::AuthError { - code: crate::SessionError::Unsupported.to_code().into(), + code: crate::lite::error::to_session_code(&crate::SessionError::Unsupported).into(), reason: "unsupported".into(), }); let mut bytes = Vec::new(); diff --git a/rs/moq-net/src/version.rs b/rs/moq-net/src/version.rs index e1cccfb7b3..11dd849b26 100644 --- a/rs/moq-net/src/version.rs +++ b/rs/moq-net/src/version.rs @@ -84,6 +84,13 @@ pub enum Version { } impl Version { + pub(crate) fn session_code(self, error: &crate::SessionError) -> u32 { + match self { + Self::Lite(_) => crate::lite::error::to_session_code(error), + Self::Ietf(_) => error.to_code(), + } + } + /// Iterate the names accepted by [`FromStr`]. pub fn names() -> impl Iterator { ALL.iter().map(Self::as_str) diff --git a/rs/moq-net/tests/session_close.rs b/rs/moq-net/tests/session_close.rs index 389b8f1f40..0b3eb9ddc5 100644 --- a/rs/moq-net/tests/session_close.rs +++ b/rs/moq-net/tests/session_close.rs @@ -289,3 +289,33 @@ async fn older_lite_close_keeps_ack_drain() { pair.client.close().await.expect("released draft still drains on ACKs"); } } + +/// A locally unsupported feature uses only the negotiated session registry. +#[moq_net_sim::test] +async fn unsupported_abort_uses_the_negotiated_session_registry() { + for protocol in ["moq-lite-06", "moq-lite-07-wip", "moq-transport-17", "moq-transport-22"] { + let version: Version = protocol.parse().unwrap(); + let pair = connect_mock(MockConnectOptions::new(version)).await; + pair.client.abort(Error::Unsupported); + let _ = pair.server.closed().await; + let (code, _) = pair.server_transport.close_reason().expect("transport closed"); + assert_eq!(code, if version.is_lite() { 0x30 } else { 0x1 }, "{protocol}"); + } +} + +/// A Lite-only close code arriving on IETF stays unknown instead of gaining Lite semantics. +#[moq_net_sim::test] +async fn unsupported_close_is_decoded_by_the_negotiated_session_registry() { + for protocol in ["moq-lite-06", "moq-lite-07-wip", "moq-transport-17", "moq-transport-22"] { + let version: Version = protocol.parse().unwrap(); + let mut pair = connect_mock(MockConnectOptions::new(version)).await; + moq_net::transport::poll::Session::close(&mut pair.server_transport, 0x30, "unsupported"); + let error = pair.client.closed().await; + let expected = if version.is_lite() { + moq_net::SessionError::Unsupported + } else { + moq_net::SessionError::Unknown(0x30) + }; + assert!(matches!(error, Error::Session(code) if code == expected), "{protocol}"); + } +} diff --git a/rs/moq-net/tests/session_setup.rs b/rs/moq-net/tests/session_setup.rs index 3cfa2f863a..cae78297c3 100644 --- a/rs/moq-net/tests/session_setup.rs +++ b/rs/moq-net/tests/session_setup.rs @@ -173,3 +173,23 @@ fn every_version_is_classified() { sorted.sort_unstable(); assert_eq!(sorted, all); } + +/// Admission refusals use the same negotiated session registry as active sessions. +#[moq_net_sim::test] +async fn unsupported_admission_uses_the_negotiated_session_registry() { + for name in LATE { + let version = version(name); + let (session, request) = dial(version).await; + request.close(Error::Unsupported); + let err = setup(&session).await.expect("the refusal never arrived").unwrap_err(); + let expected = if version.is_lite() { + SessionError::Unsupported + } else { + SessionError::Internal + }; + assert!( + matches!(&err, Error::Session(code) if *code == expected), + "{name}: {err:?}" + ); + } +} From bb5f0dc01a67515cc9a5fdaf7c865092262eae6f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 12 Oct 2026 08:45:04 -0700 Subject: [PATCH 6/8] doc(concept): note that an unsupported AUTH token ends only that token Co-Authored-By: Claude Opus 5.5 --- doc/concept/moq-lite.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/doc/concept/moq-lite.md b/doc/concept/moq-lite.md index 930cc5cf54..5f11187bee 100644 --- a/doc/concept/moq-lite.md +++ b/doc/concept/moq-lite.md @@ -166,7 +166,9 @@ to a prefix. Right after setup both sides present the credential the connection already carried (the URL token, a client certificate, or nothing), so a publisher learns before anyone subscribes whether its broadcasts can reach the peer. More tokens can be added without reconnecting; the session's scope is the -union of every open token's grant. +union of every open token's grant. A token the peer has no verifier for, or +whose grant is too large to send, fails with `Unsupported` and ends only that +token; the session and its other grants stay up. A subscription or fetch that loses access resets with the `UNAUTHORIZED` stream code and the session stays up. A client that publishes outside its grant closes From 50a322ecbec5d740f451c4583920329b2cd9c3f6 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 12 Oct 2026 09:19:41 -0700 Subject: [PATCH 7/8] fix(net): an explicit Rust Unsupported refusal sends IETF NOT_SUPPORTED Co-Authored-By: Claude Opus 5.5 --- js/net/src/ietf/auth.ts | 2 +- rs/moq-net/src/ietf/auth.rs | 4 ++-- rs/moq-net/tests/auth.rs | 5 +++++ 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/js/net/src/ietf/auth.ts b/js/net/src/ietf/auth.ts index 6f611ffba9..cad3f9c85c 100644 --- a/js/net/src/ietf/auth.ts +++ b/js/net/src/ietf/auth.ts @@ -262,7 +262,7 @@ export class IetfAuthWire implements AuthWire { } async refuse(stream: Stream, code: SessionCode, reason: string): Promise { - // The public API speaks session codes; this registry distinguishes only a version mismatch. + // The public API speaks session codes; this registry distinguishes only unsupported from unauthorized. const wire = code === SessionCode.Version || code === SessionCode.Unsupported ? NOT_SUPPORTED : UNAUTHORIZED; await new AuthError(wire, reason).encode(stream.writer, this.#version); } diff --git a/rs/moq-net/src/ietf/auth.rs b/rs/moq-net/src/ietf/auth.rs index 8f5fc3dc17..c257962294 100644 --- a/rs/moq-net/src/ietf/auth.rs +++ b/rs/moq-net/src/ietf/auth.rs @@ -191,10 +191,10 @@ const UNAUTHORIZED: u64 = 0x1; const NOT_SUPPORTED: u64 = 0x3; /// The AUTH_ERROR code for a refusal. The public API speaks session codes, and the -/// only one this registry distinguishes is a version mismatch. +/// only distinction this registry carries is unsupported versus unauthorized. fn to_code(code: SessionError) -> u64 { match code { - SessionError::Version => NOT_SUPPORTED, + SessionError::Version | SessionError::Unsupported => NOT_SUPPORTED, _ => UNAUTHORIZED, } } diff --git a/rs/moq-net/tests/auth.rs b/rs/moq-net/tests/auth.rs index 6fa914bc99..5f5c87b27e 100644 --- a/rs/moq-net/tests/auth.rs +++ b/rs/moq-net/tests/auth.rs @@ -568,6 +568,9 @@ async fn a_refused_token_reports_the_code(version: &'static str) { true => { std::mem::forget(request.accept(Grant::default())); } + false if request.token() == b"opaque".as_slice() => { + request.reject(SessionError::Unsupported, "unknown token format") + } false => request.reject(SessionError::Unauthorized, "bad signature"), } } @@ -575,6 +578,8 @@ async fn a_refused_token_reports_the_code(version: &'static str) { let err = pair.client.auth().add("forged").await.err().expect("refused"); assert!(matches!(err, Error::Session(SessionError::Unauthorized)), "{err:?}"); + let err = pair.client.auth().add("opaque").await.err().expect("refused"); + assert!(matches!(err, Error::Unsupported), "{err:?}"); }) .await .expect("timed out"); From c749d0b426f0b519039e5eafc0717909ee6150d2 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 12 Oct 2026 09:53:16 -0700 Subject: [PATCH 8/8] fix(net): an unknown code never gains a Lite-only session meaning Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/lite/error.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/rs/moq-net/src/lite/error.rs b/rs/moq-net/src/lite/error.rs index 89a24e1e06..216f9ac92e 100644 --- a/rs/moq-net/src/lite/error.rs +++ b/rs/moq-net/src/lite/error.rs @@ -5,6 +5,9 @@ use crate::{Error, SessionError}; pub(crate) fn to_session_code(error: &SessionError) -> u32 { match error { SessionError::Unsupported => 0x30, + // moq-lite's own range is assigned only by its tables; a code from elsewhere + // must not pick up a meaning there. + SessionError::Unknown(0x30..=0x3f) => SessionError::Internal.to_code(), error => error.to_code(), } } @@ -22,3 +25,19 @@ pub(crate) fn from_transport(error: impl crate::transport::Error) -> Error { } Error::from_transport(error) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_unknown_code_does_not_gain_a_lite_meaning() { + assert_eq!(to_session_code(&SessionError::Unsupported), 0x30); + assert_eq!(from_session_code(0x30), SessionError::Unsupported); + for code in 0x30..=0x3f { + assert_eq!(to_session_code(&SessionError::Unknown(code)), 0x1); + } + assert_eq!(to_session_code(&SessionError::Unknown(0x20)), 0x20); + assert_eq!(to_session_code(&SessionError::App(7)), 64 + 7); + } +}