From 9c28c39fa3872cd90e1afbd264f2db7955ef560a Mon Sep 17 00:00:00 2001 From: Luke Curley <432854+kixelated@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:27:04 -0700 Subject: [PATCH 01/31] quest: claim m1/auth/issuer From 793b60e7b0ff1fb04bfc65f01a91ead9283e05a3 Mon Sep 17 00:00:00 2001 From: Luke Curley <432854+kixelated@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:45:51 -0700 Subject: [PATCH 02/31] quest: fold the one-active-session decision for m1/auth/issuer --- quest/m1/auth/issuer.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/quest/m1/auth/issuer.md b/quest/m1/auth/issuer.md index 1f4a1af0e06..98b69e5bcb7 100644 --- a/quest/m1/auth/issuer.md +++ b/quest/m1/auth/issuer.md @@ -28,6 +28,11 @@ connection makes, with no re-add or re-announce from the application. refusal and a final stop on `Unauthorized`). With the handle in moq-net, `Connection::auth()` returns that `moq_net::auth::Handle`, and `connection::auth::{Handle, Auth}` go away. +- Decided 2026-10-20 (maintainer): a handle backs one active session at a + time, plus the predecessors it is draining, matching the reconnect code's + single active generation. Attaching a handle to a second concurrent session + fails loudly; independent concurrent sessions use separate handles, so an + `Auth` never has to pick between two live grants. - An `Auth`'s `grant()` and `closed()` follow the session that is live now: the grant clears while disconnected and returns once the new session's peer answers, and `closed()` settles only on a refusal or when the handle ends. From d0cf12c26fce3f1c83e00d73db376fefcd09f49e Mon Sep 17 00:00:00 2001 From: Luke Curley <432854+kixelated@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:27:29 -0700 Subject: [PATCH 03/31] feat(moq-net): an auth handle that outlives its sessions Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/auth.rs | 1401 ++++++++++++++++++-------- rs/moq-net/src/client.rs | 31 +- rs/moq-net/src/goaway.rs | 5 + rs/moq-net/src/ietf/auth.rs | 33 +- rs/moq-net/src/ietf/publisher.rs | 134 +-- rs/moq-net/src/ietf/session.rs | 60 +- rs/moq-net/src/ietf/subscriber.rs | 50 +- rs/moq-net/src/lite/publisher.rs | 40 +- rs/moq-net/src/lite/session.rs | 43 +- rs/moq-net/src/lite/subscriber.rs | 72 +- rs/moq-net/src/model/origin.rs | 10 +- rs/moq-net/src/server.rs | 22 +- rs/moq-net/src/session.rs | 22 +- rs/moq-net/tests/integration/auth.rs | 203 +++- 14 files changed, 1482 insertions(+), 644 deletions(-) diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index 0bdeccb2320..0448cbba544 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -9,6 +9,11 @@ //! A [`Route`](crate::origin::Route) can name it, so the request advertising the route //! carries it too, and swapping it renews that request in place. //! +//! A handle passed to `Client::with_auth` outlives its sessions: each session built on it +//! presents every token it holds, so a route naming one of its [`Auth`]s keeps it across a +//! reconnect. Only the peer's verdict ends such a token; a session ending leaves it for the +//! next. It backs one active session at a time, plus the ones draining after a GOAWAY. +//! //! The other direction, answering the peer's tokens, is automatic: the session //! grants what its own origin handles allow. An application that verifies tokens //! itself takes [`requests`](Handle::requests) before running the session's @@ -33,6 +38,7 @@ use std::{ collections::{BTreeMap, VecDeque}, task::Poll, + time::Duration, }; use bytes::Bytes; @@ -122,46 +128,85 @@ fn earliest(a: Option, b: Option) -> Option { } } -/// The shared state behind every auth handle of one session. +/// How long a token refused for a reason that may pass waits before its session presents it +/// again, doubling on each refusal up to [`RETRY_MAX`]. +const RETRY_INITIAL: Duration = Duration::from_secs(1); +const RETRY_MAX: Duration = Duration::from_secs(30); + +/// Whether a refusal is the acceptor failing to decide rather than its verdict on the +/// token, such as an unreachable auth server or a full verification queue. +fn transient(err: &Error) -> bool { + matches!( + err, + Error::Session(SessionError::Internal | SessionError::TooManyRequests | SessionError::Timeout) + ) +} + +/// The shared state behind one [`Handle`]: the tokens it presents, and every session +/// it backs. #[derive(Default)] pub(crate) struct State { + /// Created by a session for itself, so it ends with that session. + bound: bool, + /// Set once the handle ends; every waiter resolves with it. + closed: Option, + #[cfg_attr(not(feature = "async"), allow(dead_code))] + next_id: u64, + /// Every token the application added that it has not withdrawn. + tokens: BTreeMap, + next_link: u64, + /// Every session built on this handle that has not dropped, oldest first. + links: BTreeMap, + /// What the peer's connection credential lets it do, in the peer's terms: `None` (only + /// the origin handles bound it) until [`Handle::authorize`] sets it; the grant it was + /// last called with. + limit: Option, + #[cfg_attr(not(feature = "async"), allow(dead_code))] + next_issue: u64, + acceptor: Acceptor, +} + +/// A token the application added, presented on every session of its handle. +struct Token { + value: Bytes, + /// The first verdict any session's peer gave: `Ok` for a grant, or why it ended. + answered: Option>, + /// Why the token ended for good: a peer's refusal or revocation, or the handle's end. + ended: Option, +} + +/// One session's side of a handle. +struct Attached { /// Whether the negotiated version carries AUTH at all. supported: bool, /// Whether a request can carry a token itself (moq-transport), so an auth needs no /// AUTH stream to be named. requests: bool, - /// Set once the session ends; every waiter resolves with it. + /// Set once the session ends. closed: Option, - #[cfg_attr(not(feature = "async"), allow(dead_code))] - next_id: u64, - /// Every token this side presented that has not ended. - tokens: BTreeMap, + /// The session's GOAWAY: once either side sends one, the session is draining. + goaway: Option>, + /// Every token this session presented that has not been withdrawn. + slots: BTreeMap, /// Tokens waiting for the driver to open their stream. opening: VecDeque, /// The union of every open token's grant, `None` until the peer first replies. union: Option, /// The peer replied to some token, so the union is known even when empty. replied: bool, - /// What the peer's connection credential lets it do, in the peer's terms: `None` (only - /// the origin handles bound it) until [`Handle::authorize`] sets it; the grant it was - /// last called with. - limit: Option, /// The grants the application issued to the peer's AUTH tokens, by stream, in the /// peer's terms. A token on a single request never joins them. issued: BTreeMap, - #[cfg_attr(not(feature = "async"), allow(dead_code))] - next_issue: u64, /// The session's scope for the peer: the limit and every issued grant, `None` while the /// limit is. scope: Option, /// Bumped whenever the union or the scope changes, so the session's per-stream /// gates can skip re-matching paths on every wakeup. epoch: u64, - acceptor: Acceptor, } -/// One presented token. -pub(crate) struct Slot { +/// One token as one session presents it. +struct Slot { #[cfg_attr(not(feature = "async"), allow(dead_code))] token: Bytes, /// Presented by the session itself at setup, so enforcement waits on it. @@ -171,16 +216,40 @@ pub(crate) struct Slot { grant: Option, /// Handed to the driver to open its AUTH stream, which then owns ending it. streamed: bool, - /// The first reply: `Ok` for an AUTH_OK, the refusal otherwise. + /// The latest reply: `Ok` for an AUTH_OK, the refusal otherwise. #[cfg_attr(not(feature = "async"), allow(dead_code))] answered: Option>, - /// Why the token ended, once it has. + /// The peer refused the current attempt with an AUTH_ERROR. + refused: bool, + /// The delay before the last retry, which the next one doubles. + retry: Option, + /// Why the token ended on this session, once it has. ended: Option, /// The last [`Auth`] clone dropped: the driver closes the stream. withdrawn: bool, } -/// Who answers the peer's tokens. Decided once, when the driver first runs. +impl Slot { + fn new(token: Bytes, setup: bool, answered: Option>) -> Self { + let ended = match &answered { + Some(Err(err)) => Some(err.clone()), + _ => None, + }; + Self { + token, + setup, + grant: None, + streamed: false, + answered, + refused: false, + retry: None, + ended, + withdrawn: false, + } + } +} + +/// Who answers the peer's tokens. Decided once, when a driver first runs. #[derive(Default)] enum Acceptor { /// Nobody asked yet; the app may still take [`Handle::requests`]. @@ -194,9 +263,80 @@ enum Acceptor { Default, } -impl State { +impl Attached { + fn new(supported: bool, requests: bool, limit: Option<&Grant>) -> Self { + let mut attached = Self { + supported, + requests, + closed: None, + goaway: None, + slots: BTreeMap::new(), + opening: VecDeque::new(), + union: None, + replied: false, + issued: BTreeMap::new(), + scope: None, + epoch: 0, + }; + attached.rescope(limit); + attached + } + + /// Whether the session still takes new work: open, with no GOAWAY sent or received. + fn active(&self) -> bool { + self.closed.is_none() && !self.goaway.as_ref().is_some_and(|goaway| goaway.draining()) + } + + /// Present the handle's token `id` on this session, returning the answer when the + /// session settles it at once: `Ok` when only a request can carry it, so nothing will + /// answer, and [`Error::Unsupported`] when nothing can. + fn offer(&mut self, id: u64, token: &Bytes) -> Option> { + if self.closed.is_some() { + return None; + } + let answered = match (self.supported, self.requests) { + (true, _) => None, + (false, true) => Some(Ok(())), + (false, false) => Some(Err(Error::Unsupported)), + }; + self.slots.insert(id, Slot::new(token.clone(), false, answered.clone())); + if self.supported { + self.opening.push_back(id); + } + answered + } + + /// End token `id` on this session, if it is still live here. + fn end(&mut self, id: u64, err: &Error) { + let Some(slot) = self.slots.get_mut(&id) else { + return; + }; + if slot.ended.is_some() { + return; + } + slot.grant = None; + slot.answered.get_or_insert(Err(err.clone())); + slot.ended = Some(err.clone()); + self.epoch += 1; + self.recompute(); + } + + /// The last [`Auth`] clone for token `id` dropped. + fn withdraw(&mut self, id: u64) { + let Some(slot) = self.slots.get_mut(&id) else { + return; + }; + slot.withdrawn = true; + // Already over, or no stream whose close would end it: nothing left to close. + if slot.ended.is_some() || !slot.streamed { + self.opening.retain(|opening| *opening != id); + self.slots.remove(&id); + self.recompute(); + } + } + fn recompute(&mut self) { - let mut granted = self.tokens.values().filter_map(|slot| slot.grant.as_ref()).peekable(); + let mut granted = self.slots.values().filter_map(|slot| slot.grant.as_ref()).peekable(); if granted.peek().is_none() && !self.replied { return; } @@ -210,9 +350,9 @@ impl State { } } - /// Re-derive the scope from the limit and the issued grants. - fn rescope(&mut self) { - let scope = self.limit.as_ref().map(|limit| { + /// Re-derive the scope from the handle's limit and the issued grants. + fn rescope(&mut self, limit: Option<&Grant>) { + let scope = limit.map(|limit| { let mut scope = limit.clone(); for grant in self.issued.values() { scope.union(grant); @@ -225,11 +365,6 @@ impl State { } } - /// The error handed to anything waiting on a session that has ended. - fn closed(&self) -> Option { - self.closed.clone() - } - /// The union's and the scope's patterns for `direction`, each `None` while /// unrestricted. #[cfg_attr(not(feature = "async"), allow(dead_code))] @@ -266,6 +401,58 @@ impl State { } } +impl State { + /// The session the handle backs now: the newest one still open. + fn live(&self) -> Option<&Attached> { + self.links.values().rev().find(|link| link.closed.is_none()) + } + + /// Record a session's answer to token `id`: a grant settles the application's `add`, and + /// a refusal is the verdict that ends the token on every session. + fn answer(&mut self, id: u64, answer: Result<()>) { + match answer { + Ok(()) => { + if let Some(token) = self.tokens.get_mut(&id) { + token.answered.get_or_insert(Ok(())); + } + } + Err(err) => self.end_token(id, err), + } + } + + /// End token `id` for good, closing its stream on every session. + fn end_token(&mut self, id: u64, err: Error) { + let Some(token) = self.tokens.get_mut(&id) else { + return; + }; + if token.ended.is_some() { + return; + } + token.answered.get_or_insert(Err(err.clone())); + token.ended = Some(err.clone()); + for link in self.links.values_mut() { + link.end(id, &err); + } + } + + /// End the handle, returning the application's queue for the caller to close once the + /// lock is released, since refusing its tokens takes the lock again. + fn abort(&mut self, err: Error) -> Option { + if self.closed.is_some() { + return None; + } + self.closed = Some(err.clone()); + let ids: Vec = self.tokens.keys().copied().collect(); + for id in ids { + self.end_token(id, err.clone()); + } + match &self.acceptor { + Acceptor::App(queue) => Some(queue.clone()), + _ => None, + } + } +} + /// What one direction of a session allows: the grant the peer gave this side (the /// union of its tokens) and the limit this side set on the peer, each `None` /// while unrestricted. @@ -308,130 +495,94 @@ impl Permit { } } -/// The session's auth handle, returned by `Session::auth`. +/// The tokens this side presents and the grant they earn, across every session built on it. /// -/// Cheap to clone; every clone shares the session's tokens. -#[derive(Clone)] +/// A session made without one creates its own, returned by `Session::auth`, which ends +/// with that session. Pass one to `Client::with_auth` instead and it outlives its +/// sessions: each presents every token the handle holds, so an [`Auth`] it issued keeps +/// riding a route's announcements across a reconnect. It backs one active session at a +/// time, plus the ones draining after a GOAWAY. +/// +/// Cheap to clone; every clone shares the same tokens. +#[derive(Clone, Default)] pub struct Handle { state: kio::Shared, } impl Handle { - /// A handle for a session that speaks AUTH (`supported`) or never will. - #[cfg_attr(not(feature = "async"), allow(dead_code))] - pub(crate) fn new(supported: bool) -> Self { - Self { - state: kio::Shared::new(State { - supported, - ..Default::default() - }), - } + /// A handle backing no session yet, for `Client::with_auth`. + pub fn new() -> Self { + Self::default() } - /// A moq-transport handle: like [`new`](Self::new), but a request can carry a token - /// itself, so [`add`](Self::add) succeeds without AUTH. - #[cfg_attr(not(feature = "async"), allow(dead_code))] - pub(crate) fn ietf(supported: bool) -> Self { - Self { - state: kio::Shared::new(State { - supported, - requests: true, - ..Default::default() - }), - } - } - - /// Whether this side speaks AUTH: false on a version without it, on a side that does - /// not offer it, and once the peer turns out not to. - #[cfg_attr(not(feature = "async"), allow(dead_code))] - pub(crate) fn supported(&self) -> bool { - self.state.lock().supported - } - - /// The union of every grant this side holds: `None` until the peer first - /// answers a token (with a grant or a refusal), and forever on a version - /// without AUTH. + /// The union of every grant this side holds on the live session: `None` until its peer + /// first answers a token (with a grant or a refusal), while no session is live, and + /// forever on a version without AUTH. pub fn grant(&self) -> Watch { Watch::new(self.state.clone(), Selector::Union) } - /// Present another token, resolving once the peer answers it. + /// Present another token on every session of this handle, resolving once a peer answers it. /// - /// The grant joins the union while any clone of the returned [`Auth`] lives. On - /// moq-transport without AUTH there is no answer to wait for: it resolves at once with - /// a grant that stays unknown, and only a request that names it carries the token. + /// The grant joins the union while any clone of the returned [`Auth`] lives. With no + /// session live it waits for the next one. A refusal that may pass, such as an + /// unreachable auth server, is presented again after a backoff. On moq-transport + /// without AUTH there is no answer to wait for: it resolves at once with a grant that + /// stays unknown, and only a request that names it carries the token. /// /// # Errors /// /// [`Error::Unsupported`] when neither the session nor its requests can carry a token /// (moq-lite without AUTH, or a peer that takes no tokens in band), the peer's refusal - /// as [`Error::Session`], or the session's close. + /// as [`Error::Session`], or the handle's end. #[cfg(feature = "async")] pub async fn add(&self, token: impl Into) -> Result { - let auth = self.present(token.into(), false)?; + let auth = self.present(token.into())?; kio::wait(|waiter| auth.poll_answered(waiter)).await?; Ok(auth) } - /// Queue a token for the driver to present. + /// Register a token and queue it on every open session. #[cfg_attr(not(feature = "async"), allow(dead_code))] - pub(crate) fn present(&self, token: Bytes, setup: bool) -> Result { - let mut state = self.state.lock(); - if !state.supported && !state.requests { - return Err(Error::Unsupported); - } - if let Some(err) = state.closed() { - return Err(err); + pub(crate) fn present(&self, token: Bytes) -> Result { + let mut guard = self.state.lock(); + let state = &mut *guard; + if let Some(err) = &state.closed { + return Err(err.clone()); } let id = state.next_id; state.next_id += 1; - // Without AUTH only a request presents it, so nothing will answer. - let answered = (!state.supported).then_some(Ok(())); state.tokens.insert( id, - Slot { - token, - setup, - grant: None, - streamed: false, - answered, + Token { + value: token.clone(), + answered: None, ended: None, - withdrawn: false, }, ); - if state.supported { - state.opening.push_back(id); + let answers: Vec<_> = state + .links + .values_mut() + .filter_map(|link| link.offer(id, &token)) + .collect(); + for answer in answers { + state.answer(id, answer); } Ok(Auth { presented: std::sync::Arc::new(Presented { state: self.state.clone(), id, + link: None, }), }) } - /// The token a request carries for `auth`, or `None` when the request goes out unnamed - /// under the session's scope: `auth` came from another handle, it ended, or requests - /// carry no token on this version. - #[cfg_attr(not(feature = "async"), allow(dead_code))] - pub(crate) fn token(&self, auth: &Auth) -> Option { - if !auth.presented.state.same_channel(&self.state) { - return None; - } - let state = self.state.read(); - if !state.requests { - return None; - } - let slot = state.tokens.get(&auth.presented.id)?; - slot.ended.is_none().then(|| slot.token.clone()) - } - /// Answer every token the peer presents, instead of the default acceptor: on its own /// AUTH stream, or riding one of its moq-transport SUBSCRIBE or PUBLISH_NAMESPACE /// requests as an `AUTHORIZATION TOKEN`. /// - /// Call it before running the session's driver: whoever answers is decided - /// once, on the driver's first poll, so a later call cannot take over tokens + /// Call it before running the first session's driver: whoever answers is decided + /// once, on that driver's first poll, so a later call cannot take over tokens /// already in flight. Without it the session grants the peer what its own /// origin handles allow for the connection's credential, and refuses any other /// token as unsupported, along with the request that carried it. @@ -442,7 +593,7 @@ impl Handle { /// /// # Errors /// - /// [`Error::Duplicate`] if the requests were already taken, or the driver + /// [`Error::Duplicate`] if the requests were already taken, or a driver /// already started answering them itself. pub fn requests(&self) -> Result { let mut state = self.state.lock(); @@ -457,6 +608,51 @@ impl Handle { Ok(Requests { queue }) } + /// Authorize the peer's connection credential for `grant` on every session of this + /// handle, in the session's own paths. Calling it again re-authorizes, narrower or + /// wider, replacing the last grant. The session's origin handles still bound it, so it + /// never grants more than they allow. + /// + /// The session's scope for the peer is this grant and every grant issued to one of its + /// AUTH tokens through [`requests`](Self::requests); a session never authorized is bound + /// only by its origin handles. A request that carries its own token is decided by that + /// token instead, inside the scope or not. + /// + /// A narrower scope ends what falls outside at once, with [`Error::Unauthorized`] + /// where it has a reader: announcements to the peer retract, its new requests are + /// refused, its subscriptions reset, and the broadcasts it published abort. A wider + /// one brings back what the old scope held back: announcements to the peer are made + /// again, and so are the peer's announcements it withheld from the origin, and new + /// requests are accepted. The rest of the session carries on. It works on every + /// version, since the session enforces it without the peer's help. + /// + /// When the session answers the peer's connection credential itself, the peer is + /// told the grant, within what the origin handles allow, `expires` included. An + /// application answering tokens through [`requests`](Self::requests) updates its own + /// [`Issued`] grants. + pub fn authorize(&self, grant: &Grant) { + let mut guard = self.state.lock(); + let state = &mut *guard; + if state.limit.as_ref() == Some(grant) { + return; + } + state.limit = Some(grant.clone()); + for link in state.links.values_mut() { + link.epoch += 1; + link.rescope(state.limit.as_ref()); + } + } + + /// End the handle with `err`: every token ends, its sessions stop presenting them, and + /// the peer's queued tokens are refused. A handle a session created for itself ends + /// with that session. + pub fn abort(&self, err: Error) { + let queue = self.state.lock().abort(err); + if let Some(queue) = queue { + queue.close(); + } + } + /// Ask the acceptor to verify a token that rode on one of the peer's requests. /// /// The token reaches it through [`requests`](Self::requests) like any other. The grant it @@ -487,7 +683,7 @@ impl Handle { /// Decide who answers the peer's tokens: the app if it took the requests, /// otherwise the session itself (`None`). Idempotent. #[cfg_attr(not(feature = "async"), allow(dead_code))] - pub(crate) fn acceptor(&self) -> Option { + fn acceptor(&self) -> Option { let mut state = self.state.lock(); match &state.acceptor { Acceptor::App(queue) => Some(queue.clone()), @@ -498,40 +694,249 @@ impl Handle { } } } +} + +/// One session's side of a [`Handle`]: the tokens its driver presents, the peer's tokens +/// it answers, and the grants it enforces. +/// +/// Cheap to clone; the session leaves its handle when the last clone drops. +#[derive(Clone)] +pub(crate) struct Link { + handle: Handle, + attached: std::sync::Arc, +} + +/// Removes a session from its handle once nothing holds its [`Link`]. +struct Detach { + state: kio::Shared, + id: u64, +} + +impl Drop for Detach { + fn drop(&mut self) { + let queue = { + let mut state = self.state.lock(); + state.links.remove(&self.id); + // Dropped without closing, such as a driver dropped mid-session. + match state.bound { + true => state.abort(Error::Cancel), + false => None, + } + }; + if let Some(queue) = queue { + queue.close(); + } + } +} + +impl Link { + /// A session that speaks AUTH (`supported`) or never will, on a handle of its own. + #[cfg_attr(not(feature = "async"), allow(dead_code))] + pub(crate) fn new(supported: bool) -> Self { + Self::bound(supported, false) + } + + /// A moq-transport session: like [`new`](Self::new), but a request can carry a token + /// itself, so a token needs no AUTH to be presented. + #[cfg_attr(not(feature = "async"), allow(dead_code))] + pub(crate) fn ietf(supported: bool) -> Self { + Self::bound(supported, true) + } + + fn bound(supported: bool, requests: bool) -> Self { + let handle = Handle { + state: kio::Shared::new(State { + bound: true, + ..Default::default() + }), + }; + Self::attach(&handle, supported, requests).expect("a fresh handle backs no session") + } + + /// Build a session on `handle`, the moq-transport flavor when `requests`. + /// + /// # Errors + /// + /// [`Error::Duplicate`] while the handle backs another active session, or the + /// handle's end. + pub(crate) fn attach(handle: &Handle, supported: bool, requests: bool) -> Result { + let mut guard = handle.state.lock(); + let state = &mut *guard; + if let Some(err) = &state.closed { + return Err(err.clone()); + } + if state.links.values().any(Attached::active) { + return Err(Error::Duplicate); + } + let id = state.next_link; + state.next_link += 1; + let mut link = Attached::new(supported, requests, state.limit.as_ref()); + let answers: Vec<_> = state + .tokens + .iter() + .filter(|(_, token)| token.ended.is_none()) + .filter_map(|(token_id, token)| Some((*token_id, link.offer(*token_id, &token.value)?))) + .collect(); + state.links.insert(id, link); + for (token_id, answer) in answers { + state.answer(token_id, answer); + } + Ok(Self { + handle: handle.clone(), + attached: std::sync::Arc::new(Detach { + state: handle.state.clone(), + id, + }), + }) + } + + /// The handle this session is built on. + pub(crate) fn handle(&self) -> &Handle { + &self.handle + } + + fn state(&self) -> &kio::Shared { + &self.handle.state + } + + fn id(&self) -> u64 { + self.attached.id + } + + /// Run `f` on this session's side of the handle. + fn with(&self, f: impl FnOnce(&mut Attached) -> R) -> R { + self.with_state(|state, id| f(attached(&mut state.links, id))) + } + + /// Run `f` on the handle's state, with this session's id. + fn with_state(&self, f: impl FnOnce(&mut State, u64) -> R) -> R { + f(&mut self.state().lock(), self.id()) + } + + /// Read this session's side of the handle, and the handle's state. + fn read(&self, f: impl FnOnce(&Attached, &State) -> R) -> R { + let state = self.state().read(); + let link = state.links.get(&self.id()).expect("a link outlives its session's state"); + f(link, &state) + } + + /// Retire the session to draining once `goaway` is sent or received, freeing the + /// handle for a replacement. + pub(crate) fn drain_with(&self, goaway: std::sync::Arc) { + self.with(|link| link.goaway = Some(goaway)); + } + + /// Whether this side speaks AUTH: false on a version without it, on a side that does + /// not offer it, and once the peer turns out not to. + #[cfg_attr(not(feature = "async"), allow(dead_code))] + pub(crate) fn supported(&self) -> bool { + self.read(|link, _| link.supported) + } + + /// Present a token: the session's own credential at setup, presented on this session + /// alone, or else one of the handle's, presented on every session. + #[cfg_attr(not(feature = "async"), allow(dead_code))] + pub(crate) fn present(&self, token: Bytes, setup: bool) -> Result { + if !setup { + return self.handle.present(token); + } + let id = self.with_state(|state, link| { + let id = state.next_id; + let link = attached(&mut state.links, link); + if !link.supported && !link.requests { + return Err(Error::Unsupported); + } + if let Some(err) = &link.closed { + return Err(err.clone()); + } + // Without AUTH only a request presents it, so nothing will answer. + let answered = (!link.supported).then_some(Ok(())); + link.slots.insert(id, Slot::new(token, true, answered)); + if link.supported { + link.opening.push_back(id); + } + state.next_id += 1; + Ok(id) + })?; + Ok(Auth { + presented: std::sync::Arc::new(Presented { + state: self.state().clone(), + id, + link: Some(self.id()), + }), + }) + } + + /// The token a request carries for `auth`, or `None` when the request goes out unnamed + /// under the session's scope: `auth` came from another handle, it ended here, or + /// requests carry no token on this version. + #[cfg_attr(not(feature = "async"), allow(dead_code))] + pub(crate) fn token(&self, auth: &Auth) -> Option { + if !auth.presented.state.same_channel(self.state()) { + return None; + } + self.read(|link, _| { + if !link.requests { + return None; + } + let slot = link.slots.get(&auth.presented.id)?; + slot.ended.is_none().then(|| slot.token.clone()) + }) + } + + /// Ask the acceptor to verify a token that rode on one of the peer's requests; see + /// [`Handle::verify_request`]. + #[cfg_attr(not(feature = "async"), allow(dead_code))] + pub(crate) fn verify_request(&self, token: crate::setup::Token, hold: Option) -> RequestVerdict { + self.handle.verify_request(token, hold) + } + + /// Decide who answers the peer's tokens: the app if it took the requests, + /// otherwise the session itself (`None`). Idempotent. + #[cfg_attr(not(feature = "async"), allow(dead_code))] + pub(crate) fn acceptor(&self) -> Option { + self.handle.acceptor() + } /// The next token to open a stream for, or `Ready(None)` once the session ends. #[cfg_attr(not(feature = "async"), allow(dead_code))] pub(crate) fn poll_opening(&self, waiter: &kio::Waiter) -> Poll> { - let mut state = ready_or!(self.state.poll(waiter, |state| { - match state.opening.is_empty() && state.closed.is_none() { - true => Poll::Pending, - false => Poll::Ready(()), + let id = self.id(); + loop { + let mut guard = ready_or!(self.state().poll(waiter, |state| { + match state.links.get(&id) { + Some(link) if link.opening.is_empty() && link.closed.is_none() => Poll::Pending, + _ => Poll::Ready(()), + } + })); + let Some(link) = guard.links.get_mut(&id) else { + return Poll::Ready(None); + }; + if link.closed.is_some() { + return Poll::Ready(None); } - })); - if state.closed.is_some() { - return Poll::Ready(None); - } - let id = state.opening.pop_front().expect("checked non-empty"); - let token = state.tokens.get_mut(&id).map(|slot| { - slot.streamed = true; - slot.token.clone() - }); - match token { - Some(token) => Poll::Ready(Some((id, token))), - // Withdrawn before its stream opened: nothing to present. - None => { - drop(state); - self.poll_opening(waiter) + let token = link.opening.pop_front().expect("checked non-empty"); + // Withdrawn or ended before its stream opened: nothing to present. + if let Some(slot) = link.slots.get_mut(&token) + && slot.ended.is_none() + { + slot.streamed = true; + slot.refused = false; + return Poll::Ready(Some((token, slot.token.clone()))); } } } - /// Whether the token's handle dropped, so its stream should close. + /// Whether the token's stream should close: its handle dropped, or it ended, here or + /// for good on another session. #[cfg_attr(not(feature = "async"), allow(dead_code))] pub(crate) fn poll_withdrawn(&self, id: u64, waiter: &kio::Waiter) -> Poll<()> { - let _ = ready_or!(self.state.poll(waiter, |state| match state.tokens.get(&id) { - Some(slot) if !slot.withdrawn => Poll::Pending, - _ => Poll::Ready(()), + let link = self.id(); + let _ = ready_or!(self.state().poll(waiter, |state| { + match state.links.get(&link).and_then(|link| link.slots.get(&id)) { + Some(slot) if !slot.withdrawn && slot.ended.is_none() => Poll::Pending, + _ => Poll::Ready(()), + } })); Poll::Ready(()) } @@ -547,53 +952,125 @@ impl Handle { subscribe = %list(&grant.subscribe), "auth granted" ); - let mut state = self.state.lock(); - let Some(slot) = state.tokens.get_mut(&id) else { - return; - }; - slot.grant = Some(grant); - slot.answered.get_or_insert(Ok(())); - state.replied = true; - state.recompute(); + self.with_state(|state, link| { + let link = attached(&mut state.links, link); + let Some(slot) = link.slots.get_mut(&id) else { + return; + }; + if slot.ended.is_some() { + return; + } + slot.grant = Some(grant); + slot.answered = Some(Ok(())); + slot.retry = None; + link.replied = true; + link.recompute(); + state.answer(id, Ok(())); + }); } /// Record an AUTH_ERROR for the token: a reply that grants nothing, so a refused /// setup token leaves an empty union rather than an unknown (unrestricted) one. - /// The driver still ends the token with [`ended`](Self::ended). + /// The driver then calls [`retry`](Self::retry), and [`ended`](Self::ended) unless it + /// presents the token again. #[cfg_attr(not(feature = "async"), allow(dead_code))] pub(crate) fn refused(&self, id: u64) { - let mut state = self.state.lock(); - if state.tokens.contains_key(&id) { - state.replied = true; - } + self.with(|link| { + if let Some(slot) = link.slots.get_mut(&id) { + slot.refused = true; + link.replied = true; + } + }); + } + + /// The delay before presenting a refused token again, when the refusal `err` may pass + /// and the token is still wanted here; `None` when the driver should end it instead. + #[cfg_attr(not(feature = "async"), allow(dead_code))] + pub(crate) fn retry(&self, id: u64, err: &Error) -> Option { + self.with(|link| { + if link.closed.is_some() || !transient(err) { + return None; + } + let slot = link.slots.get_mut(&id)?; + if !slot.refused || slot.withdrawn || slot.ended.is_some() { + return None; + } + slot.refused = false; + slot.streamed = false; + slot.grant = None; + // Answered, so nothing waits on the token through the backoff. + slot.answered = Some(Err(err.clone())); + let delay = slot.retry.map_or(RETRY_INITIAL, |delay| (delay * 2).min(RETRY_MAX)); + slot.retry = Some(delay); + link.epoch += 1; + link.recompute(); + Some(delay) + }) + } + + /// The token to present again once a [`retry`](Self::retry) delay passes, or `None` + /// when it is no longer wanted here. + #[cfg_attr(not(feature = "async"), allow(dead_code))] + pub(crate) fn reopen(&self, id: u64) -> Option { + self.with(|link| { + if link.closed.is_some() { + return None; + } + let slot = link.slots.get_mut(&id)?; + if slot.withdrawn || slot.ended.is_some() { + return None; + } + slot.streamed = true; + Some(slot.token.clone()) + }) } - /// End the token: refused or revoked by the peer, withdrawn by us, or its - /// stream lost. A token that was never answered records `err` as its answer. + /// End the token on this session: refused or revoked by the peer, withdrawn by us, or + /// its stream lost. A token that was never answered records `err` as its answer. The + /// peer's verdict ends one of the handle's tokens on every session; anything else + /// leaves it for the next session to present. #[cfg_attr(not(feature = "async"), allow(dead_code))] pub(crate) fn ended(&self, id: u64, err: Error) { - let mut state = self.state.lock(); - let Some(slot) = state.tokens.get_mut(&id) else { - return; - }; - slot.grant = None; - slot.answered.get_or_insert(Err(err.clone())); - slot.ended = Some(err); - // The handle is gone and the token is over: nothing will read the slot again. - if slot.withdrawn { - state.tokens.remove(&id); - } - // Even with the union unchanged, a request carrying this token must drop it. - state.epoch += 1; - state.recompute(); + self.with_state(|state, link| { + let link = attached(&mut state.links, link); + let Some(slot) = link.slots.get_mut(&id) else { + return; + }; + let verdict = !slot.setup + && link.closed.is_none() + && !slot.withdrawn + && slot.ended.is_none() + && (slot.refused + || matches!(err, Error::Unsupported) + // The peer ended the grant without revoking it. + || (matches!(err, Error::Cancel) && slot.grant.is_some())); + slot.grant = None; + slot.answered.get_or_insert(Err(err.clone())); + slot.ended.get_or_insert(err.clone()); + // The handle is gone and the token is over: nothing will read the slot again. + if slot.withdrawn { + link.slots.remove(&id); + } + // Even with the union unchanged, a request carrying this token must drop it. + link.epoch += 1; + link.recompute(); + if verdict { + state.end_token(id, err); + } + }); } /// Resolve once every token the session presented at setup has its first /// reply, so enforcement never waits on a token the app added later. #[cfg_attr(not(feature = "async"), allow(dead_code))] pub(crate) fn poll_setup_answered(&self, waiter: &kio::Waiter) -> Poll<()> { - let _ = ready_or!(self.state.poll(waiter, |state| { - match state.tokens.values().any(|slot| slot.setup && slot.answered.is_none()) { + let id = self.id(); + let _ = ready_or!(self.state().poll(waiter, |state| { + let pending = state + .links + .get(&id) + .is_some_and(|link| link.slots.values().any(|slot| slot.setup && slot.answered.is_none())); + match pending { true => Poll::Pending, false => Poll::Ready(()), } @@ -604,11 +1081,15 @@ impl Handle { /// Wait only when a token already presented might widen the grant for this path. #[cfg_attr(not(feature = "async"), allow(dead_code))] fn poll_presented(&self, direction: Direction, path: &str, waiter: &kio::Waiter) -> Poll<()> { - let _ = ready_or!(self.state.poll(waiter, |state| { - let (granted, _) = state.parts(direction); + let id = self.id(); + let _ = ready_or!(self.state().poll(waiter, |state| { + let Some(link) = state.links.get(&id) else { + return Poll::Ready(()); + }; + let (granted, _) = link.parts(direction); if granted.is_some_and(|grant| grant.matches(path)) - || !state - .tokens + || !link + .slots .values() .any(|slot| !slot.token.is_empty() && slot.answered.is_none() && !slot.withdrawn) { @@ -620,46 +1101,17 @@ impl Handle { Poll::Ready(()) } - /// Authorize the peer's connection credential for `grant` on this session, in the - /// session's own paths. Calling it again re-authorizes, narrower or wider, replacing the - /// last grant. The session's origin handles still bound it, so it never grants more than - /// they allow. - /// - /// The session's scope for the peer is this grant and every grant issued to one of its - /// AUTH tokens through [`requests`](Self::requests); a session never authorized is bound - /// only by its origin handles. A request that carries its own token is decided by that - /// token instead, inside the scope or not. - /// - /// A narrower scope ends what falls outside at once, with [`Error::Unauthorized`] - /// where it has a reader: announcements to the peer retract, its new requests are - /// refused, its subscriptions reset, and the broadcasts it published abort. A wider - /// one brings back what the old scope held back: announcements to the peer are made - /// again, and so are the peer's announcements it withheld from the origin, and new - /// requests are accepted. The rest of the session carries on. It works on every - /// version, since the session enforces it without the peer's help. - /// - /// When the session answers the peer's connection credential itself, the peer is - /// told the grant, within what the origin handles allow, `expires` included. An - /// application answering tokens through [`requests`](Self::requests) updates its own - /// [`Issued`] grants. - pub fn authorize(&self, grant: &Grant) { - let mut state = self.state.lock(); - if state.limit.as_ref() != Some(grant) { - state.limit = Some(grant.clone()); - state.epoch += 1; - state.rescope(); - } - } - /// Record the grant an application issued to the peer's AUTH token `id`, or forget it with /// `None`, resizing the scope. fn issue(&self, id: u64, grant: Option) { - let mut state = self.state.lock(); - match grant { - Some(grant) => state.issued.insert(id, grant), - None => state.issued.remove(&id), - }; - state.rescope(); + self.with_state(|state, link| { + let link = attached(&mut state.links, link); + match grant { + Some(grant) => link.issued.insert(id, grant), + None => link.issued.remove(&id), + }; + link.rescope(state.limit.as_ref()); + }); } /// The union once it changed since `epoch` last saw it, advancing `epoch`. It @@ -667,8 +1119,9 @@ impl Handle { #[cfg_attr(not(feature = "async"), allow(dead_code))] pub(crate) fn poll_union(&self, epoch: &mut u64, waiter: &kio::Waiter) -> Poll> { let state = ready_or!(self.poll_epoch(*epoch, waiter)); - *epoch = state.epoch; - Poll::Ready(state.union.clone()) + let link = &state.links[&self.id()]; + *epoch = link.epoch; + Poll::Ready(link.union.clone()) } /// What `direction` allows once it may have changed since `epoch` last saw it, @@ -676,21 +1129,23 @@ impl Handle { #[cfg_attr(not(feature = "async"), allow(dead_code))] pub(crate) fn poll_permit(&self, direction: Direction, epoch: &mut u64, waiter: &kio::Waiter) -> Poll { let state = ready_or!(self.poll_epoch(*epoch, waiter)); - *epoch = state.epoch; - Poll::Ready(state.permit(direction)) + let link = &state.links[&self.id()]; + *epoch = link.epoch; + Poll::Ready(link.permit(direction)) } /// The limit once it may have changed since `epoch` last saw it, advancing `epoch`. #[cfg_attr(not(feature = "async"), allow(dead_code))] pub(crate) fn poll_limit(&self, epoch: &mut u64, waiter: &kio::Waiter) -> Poll> { let state = ready_or!(self.poll_epoch(*epoch, waiter)); - *epoch = state.epoch; + *epoch = state.links[&self.id()].epoch; Poll::Ready(state.limit.clone()) } #[cfg_attr(not(feature = "async"), allow(dead_code))] fn poll_epoch(&self, seen: u64, waiter: &kio::Waiter) -> Poll> { - self.state.poll(waiter, |state| match state.epoch != seen { + let id = self.id(); + self.state().poll(waiter, |state| match state.links[&id].epoch != seen { true => Poll::Ready(()), false => Poll::Pending, }) @@ -701,7 +1156,7 @@ impl Handle { /// limit both. #[cfg_attr(not(feature = "async"), allow(dead_code))] pub(crate) fn allows(&self, direction: Direction, path: &str) -> bool { - self.state.read().allows(direction, path) + self.read(|link, _| link.allows(direction, path)) } /// Whether a request token decides the request carrying it, rather than the session's @@ -712,85 +1167,115 @@ impl Handle { /// admits as it would without it. #[cfg_attr(not(feature = "async"), allow(dead_code))] pub(crate) fn token_decides(&self, direction: Direction) -> bool { - let state = self.state.read(); - let (_, scope) = state.parts(direction); - scope.is_some() || matches!(state.acceptor, Acceptor::App(_)) + self.read(|link, state| { + let (_, scope) = link.parts(direction); + scope.is_some() || matches!(state.acceptor, Acceptor::App(_)) + }) } /// Whether the peer's grant alone allows `direction` at `path`, allowing everything /// while it is `None`: what bounds this side whatever authorized the request. #[cfg_attr(not(feature = "async"), allow(dead_code))] pub(crate) fn within_grant(&self, direction: Direction, path: &str) -> bool { - let state = self.state.read(); - let (granted, _) = state.parts(direction); - granted.is_none_or(|granted| granted.matches(path)) + self.read(|link, _| { + let (granted, _) = link.parts(direction); + granted.is_none_or(|granted| granted.matches(path)) + }) } /// Whether the scope alone allows `direction` at `path`: for a route the peer /// offers us, which our own grant does not decide. #[cfg_attr(not(feature = "async"), allow(dead_code))] pub(crate) fn within_limit(&self, direction: Direction, path: &str) -> bool { - let state = self.state.read(); - let (_, scope) = state.parts(direction); - scope.is_none_or(|scope| scope.matches(path)) + self.read(|link, _| { + let (_, scope) = link.parts(direction); + scope.is_none_or(|scope| scope.matches(path)) + }) } /// The peer turned out not to negotiate AUTH, leaving the union unknown. On /// moq-transport every token we presented stays, with an unknown grant, for the - /// requests that name it; elsewhere each fails as unsupported. The peer's tokens can - /// still ride its requests, so their queue stays open. + /// requests that name it; elsewhere each fails as unsupported, which ends the handle's + /// tokens for good. The peer's tokens can still ride its requests, so their queue + /// stays open. #[cfg_attr(not(feature = "async"), allow(dead_code))] pub(crate) fn unsupported(&self) { - let mut state = self.state.lock(); - state.supported = false; - state.opening.clear(); - let requests = state.requests; - for slot in state.tokens.values_mut() { - if requests { - slot.answered.get_or_insert(Ok(())); - continue; + self.with_state(|state, link| { + let link = attached(&mut state.links, link); + link.supported = false; + link.opening.clear(); + let requests = link.requests; + let mut answers = Vec::new(); + for (id, slot) in &mut link.slots { + if slot.withdrawn { + continue; + } + let answer = match requests { + true => Ok(()), + false => Err(Error::Unsupported), + }; + slot.answered.get_or_insert(answer.clone()); + if let Err(err) = &answer { + slot.ended.get_or_insert(err.clone()); + } + if !slot.setup { + answers.push((*id, answer)); + } } - slot.answered.get_or_insert(Err(Error::Unsupported)); - slot.ended.get_or_insert(Error::Unsupported); - } - // Nothing will read a withdrawn slot again. - state.tokens.retain(|_, slot| !slot.withdrawn); + // Nothing will read a withdrawn slot again. + link.slots.retain(|_, slot| !slot.withdrawn); + for (id, answer) in answers { + state.answer(id, answer); + } + }); } - /// End the session: fail every pending token, end every watch, and close the - /// requests. + /// Why the session ended, once it has. + #[cfg_attr(not(feature = "async"), allow(dead_code))] + fn closed(&self) -> Option { + self.read(|link, _| link.closed.clone()) + } + + /// End the session: fail every pending token here, leaving the handle's tokens for the + /// next session. A handle the session created for itself ends with it. #[cfg_attr(not(feature = "async"), allow(dead_code))] pub(crate) fn close(&self, err: Error) { - let mut state = self.state.lock(); - if state.closed.is_some() { - return; - } - state.closed = Some(err.clone()); - state.opening.clear(); - for slot in state.tokens.values_mut() { - slot.grant = None; - slot.answered.get_or_insert(Err(err.clone())); - slot.ended.get_or_insert(err.clone()); - } - state.recompute(); - let queue = match &state.acceptor { - Acceptor::App(queue) => Some(queue.clone()), - _ => None, - }; - // Refusing the queued tokens takes this lock again, to drop them from the scope. - drop(state); + let queue = self.with_state(|state, link| { + let link = attached(&mut state.links, link); + if link.closed.is_some() { + return None; + } + link.closed = Some(err.clone()); + link.opening.clear(); + for slot in link.slots.values_mut() { + slot.grant = None; + slot.answered.get_or_insert(Err(err.clone())); + slot.ended.get_or_insert(err.clone()); + } + link.recompute(); + match state.bound { + true => state.abort(err), + false => None, + } + }); + // Refusing the queued tokens takes the lock again, to drop them from the scope. if let Some(queue) = queue { queue.close(); } } } +/// A session's side of the handle, which lives as long as any [`Link`] to it. +fn attached(links: &mut BTreeMap, id: u64) -> &mut Attached { + links.get_mut(&id).expect("a link outlives its session's state") +} + /// One credential this side presented, returned by [`Handle`]'s `add`. /// -/// Cheap to clone, and every clone names the same credential. It stays presented -/// until the last clone drops, which closes its stream and removes its grant from -/// the union. A [`Route`](crate::origin::Route) holding one keeps it presented while -/// the route stands. +/// Cheap to clone, and every clone names the same credential. It stays presented on +/// every session of its handle until the last clone drops, which closes its streams and +/// removes its grant from the union. A [`Route`](crate::origin::Route) holding one keeps +/// it presented while the route stands, across the handle's sessions. #[derive(Clone)] pub struct Auth { presented: std::sync::Arc, @@ -804,45 +1289,64 @@ impl Auth { } } -/// The slot every clone of one [`Auth`] shares, withdrawn when the last drops. +/// The token every clone of one [`Auth`] shares, withdrawn when the last drops. struct Presented { state: kio::Shared, id: u64, + /// The one session that presents it: a session's own credential. `None` for a + /// handle's token, presented on every session. + link: Option, +} + +impl Presented { + fn selector(&self) -> Selector { + match self.link { + Some(link) => Selector::Setup { link, id: self.id }, + None => Selector::Token(self.id), + } + } } impl Auth { - /// This credential's own grant: `None` until the peer answers, forever on - /// moq-transport without AUTH, and again once the credential ends. + /// This credential's own grant on the live session: `None` until its peer answers, + /// while no session is live, forever on moq-transport without AUTH, and again once + /// the credential ends. pub fn grant(&self) -> Watch { - Watch::new(self.presented.state.clone(), Selector::Slot(self.presented.id)) + Watch::new(self.presented.state.clone(), self.presented.selector()) } - /// Wait for the credential to end: the peer's revocation as [`Error::Session`], - /// [`Error::Cancel`] when the peer closed it without one, or the session's - /// close. + /// Wait for the credential to end for good: a peer's refusal or revocation as + /// [`Error::Session`], [`Error::Cancel`] when the peer closed it without one, or the + /// handle's end. A session ending is not one: the next session presents it again. #[cfg(feature = "async")] pub async fn closed(&self) -> Error { - let Presented { state, id } = &*self.presented; + let selector = self.presented.selector(); kio::wait(|waiter| { - let state = ready_or!(state.poll(waiter, |state| match state.tokens.get(id) { - Some(slot) if slot.ended.is_none() => Poll::Pending, - _ => Poll::Ready(()), + let state = ready_or!(self.presented.state.poll(waiter, |state| { + match selector.ended(state) { + Some(_) => Poll::Ready(()), + None => Poll::Pending, + } })); - let err = state.tokens.get(id).and_then(|slot| slot.ended.clone()); - Poll::Ready(err.unwrap_or(Error::Cancel)) + Poll::Ready(selector.ended(&state).unwrap_or(Error::Cancel)) }) .await } + /// Poll for the first verdict on a handle's token: a grant on any session, or the + /// refusal or end that settles it. #[cfg_attr(not(feature = "async"), allow(dead_code))] fn poll_answered(&self, waiter: &kio::Waiter) -> Poll> { - let Presented { state, id } = &*self.presented; - let state = ready_or!(state.poll(waiter, |state| match state.tokens.get(id) { - Some(slot) if slot.answered.is_none() => Poll::Pending, - _ => Poll::Ready(()), + let id = self.presented.id; + let answered = |state: &State| match state.tokens.get(&id) { + Some(token) => token.answered.clone(), + None => Some(Err(Error::Cancel)), + }; + let state = ready_or!(self.presented.state.poll(waiter, |state| match answered(state) { + Some(_) => Poll::Ready(()), + None => Poll::Pending, })); - let answered = state.tokens.get(id).and_then(|slot| slot.answered.clone()); - Poll::Ready(answered.unwrap_or(Err(Error::Cancel))) + Poll::Ready(answered(&state).unwrap_or(Err(Error::Cancel))) } } @@ -863,26 +1367,64 @@ impl std::fmt::Debug for Auth { impl Drop for Presented { fn drop(&mut self) { - let mut state = self.state.lock(); - let Some(slot) = state.tokens.get_mut(&self.id) else { - return; - }; - slot.withdrawn = true; - // Already over, or no stream whose close would end it: nothing left to close. - if slot.ended.is_some() || !slot.streamed { - state.opening.retain(|id| *id != self.id); - state.tokens.remove(&self.id); - state.recompute(); + let mut guard = self.state.lock(); + let state = &mut *guard; + match self.link { + Some(link) => { + if let Some(link) = state.links.get_mut(&link) { + link.withdraw(self.id); + } + } + None => { + state.tokens.remove(&self.id); + for link in state.links.values_mut() { + link.withdraw(self.id); + } + } } } } +/// Which grant a [`Watch`] follows. +#[derive(Clone, Copy)] enum Selector { + /// The live session's union. Union, - Slot(u64), + /// A handle's token, on the live session. + Token(u64), + /// A session's own credential, on that session. + Setup { link: u64, id: u64 }, } -/// Watches a grant: the session's union, or one credential's. +impl Selector { + fn current<'a>(&self, state: &'a State) -> Option<&'a Grant> { + match *self { + Self::Union => state.live()?.union.as_ref(), + Self::Token(id) => state.live()?.slots.get(&id)?.grant.as_ref(), + Self::Setup { link, id } => state.links.get(&link)?.slots.get(&id)?.grant.as_ref(), + } + } + + /// Why nothing will change the grant again, once nothing will. + fn ended(&self, state: &State) -> Option { + if let Some(err) = &state.closed { + return Some(err.clone()); + } + match *self { + Self::Union => None, + Self::Token(id) => match state.tokens.get(&id) { + Some(token) => token.ended.clone(), + None => Some(Error::Cancel), + }, + Self::Setup { link, id } => match state.links.get(&link).and_then(|link| link.slots.get(&id)) { + Some(slot) => slot.ended.clone(), + None => Some(Error::Cancel), + }, + } + } +} + +/// Watches a grant: the live session's union, or one credential's. /// /// Created by [`Handle::grant`] and [`Auth::grant`]. pub struct Watch { @@ -902,61 +1444,31 @@ impl Watch { watch } - fn current(&self, state: &State) -> Option { - match self.selector { - Selector::Union => state.union.clone(), - Selector::Slot(id) => state.tokens.get(&id).and_then(|slot| slot.grant.clone()), - } - } - - /// Whether nothing will change the grant again. - fn ended(&self, state: &State) -> Option { - if let Some(err) = state.closed() { - return Some(err); - } - match self.selector { - Selector::Union => None, - Selector::Slot(id) => match state.tokens.get(&id) { - Some(slot) => slot.ended.clone(), - None => Some(Error::Cancel), - }, - } - } - /// The grant right now. pub fn peek(&self) -> Option { - self.current(&self.state.read()) + self.selector.current(&self.state.read()).cloned() } /// Poll for the grant to differ from the last one this watch returned (or saw /// at creation). /// - /// `Err` once nothing will change it again: the session closed, or for a - /// token's watch, the token ended. + /// `Err` once nothing will change it again: the handle ended, or for a + /// token's watch, the token ended for good. pub fn poll_changed(&mut self, waiter: &kio::Waiter) -> Poll>> { let last = &self.last; - let selector = &self.selector; + let selector = self.selector; let state = ready_or!(self.state.poll(waiter, |state| { - let current = match selector { - Selector::Union => state.union.as_ref(), - Selector::Slot(id) => state.tokens.get(id).and_then(|slot| slot.grant.as_ref()), - }; - let ended = state.closed.is_some() - || match selector { - Selector::Union => false, - Selector::Slot(id) => state.tokens.get(id).is_none_or(|slot| slot.ended.is_some()), - }; - match current != last.as_ref() || ended { + match selector.current(state) != last.as_ref() || selector.ended(state).is_some() { true => Poll::Ready(()), false => Poll::Pending, } })); - let current = self.current(&state); + let current = selector.current(&state).cloned(); if current != self.last { self.last = current.clone(); return Poll::Ready(Ok(current)); } - Poll::Ready(Err(self.ended(&state).unwrap_or(Error::Cancel))) + Poll::Ready(Err(selector.ended(&state).unwrap_or(Error::Cancel))) } /// Wait for the grant to change, returning the new one. @@ -1084,7 +1596,7 @@ pub(crate) struct Issue { taken: bool, /// An AUTH token's place in the session's scope, which its grant joins. A token that rode /// on a request has none: its grant covers only that request. - scoped: Option<(Handle, u64)>, + scoped: Option<(Link, u64)>, } impl Issue { @@ -1121,15 +1633,15 @@ pub(crate) type Hold = std::sync::Arc; #[cfg_attr(not(feature = "async"), allow(dead_code))] pub(crate) struct Serving { pub issue: kio::Shared, - handle: Handle, + handle: Link, } impl Serving { /// A fresh stream's shared state, settled by the session `handle` if the task drops. #[cfg_attr(not(feature = "async"), allow(dead_code))] - pub(crate) fn new(handle: Handle) -> Self { + pub(crate) fn new(handle: Link) -> Self { let id = { - let mut state = handle.state.lock(); + let mut state = handle.state().lock(); state.next_issue += 1; state.next_issue }; @@ -1156,7 +1668,7 @@ impl Serving { impl Drop for Serving { fn drop(&mut self) { - let err = self.handle.state.read().closed().unwrap_or(Error::Cancel); + let err = self.handle.closed().unwrap_or(Error::Cancel); self.end(err); } } @@ -1264,7 +1776,7 @@ impl RequestKind { } } -/// The verdict on a request-borne token, from [`Handle::verify_request`]. +/// The verdict on a request-borne token, from [`Link::verify_request`]. /// /// Holding it keeps the request's grant alive; dropping it tells the acceptor the request /// is over, so the grant lives exactly as long as the request and never outlives it. @@ -1447,7 +1959,7 @@ impl RequestGrant { } /// Start verifying a renewal's token, holding the request's slot while it runs. - pub(crate) fn verify_renewal(&self, handle: &Handle, token: crate::setup::Token) -> RequestVerdict { + pub(crate) fn verify_renewal(&self, handle: &Link, token: crate::setup::Token) -> RequestVerdict { let mut held = self.held(); let verdict = handle.verify_request(token, held.hold.clone()); held.renewing = verdict.issue.clone(); @@ -1639,7 +2151,7 @@ pub(crate) enum Direction { /// only re-matched when the union or the limit changes. #[cfg_attr(not(feature = "async"), allow(dead_code))] pub(crate) struct Gate { - handle: Handle, + handle: Link, path: crate::PathOwned, direction: Direction, epoch: u64, @@ -1675,7 +2187,7 @@ enum Part { impl Gate { #[cfg_attr(not(feature = "async"), allow(dead_code))] - pub(crate) fn new(handle: Handle, path: crate::PathOwned, direction: Direction) -> Self { + pub(crate) fn new(handle: Link, path: crate::PathOwned, direction: Direction) -> Self { let within_limit = handle.within_limit(direction, path.as_str()); Self { handle, @@ -1700,7 +2212,7 @@ impl Gate { /// the announce stands in for the scope, as its own token may have authorized it. #[cfg_attr(not(feature = "async"), allow(dead_code))] pub(crate) fn announced( - handle: Handle, + handle: Link, path: crate::PathOwned, direction: Direction, live: kio::Consumer<()>, @@ -1814,7 +2326,7 @@ impl Gate { /// the session's origin handles allow, within the limit as it changes. #[cfg_attr(not(feature = "async"), allow(dead_code))] pub(crate) struct DefaultGrant { - handle: Handle, + handle: Link, base: Grant, epoch: u64, limit: Option, @@ -1824,8 +2336,8 @@ pub(crate) struct DefaultGrant { impl DefaultGrant { #[cfg_attr(not(feature = "async"), allow(dead_code))] - pub(crate) fn new(handle: Handle, base: Grant) -> Self { - let limit = handle.state.read().limit.clone(); + pub(crate) fn new(handle: Link, base: Grant) -> Self { + let limit = handle.state().read().limit.clone(); Self { handle, base, @@ -1888,7 +2400,7 @@ impl Enforce { #[cfg_attr(not(feature = "async"), allow(dead_code))] pub(crate) fn poll( &mut self, - handle: &Handle, + handle: &Link, announced: &mut crate::announce::Consumer, waiter: &kio::Waiter, ) -> Poll> { @@ -1956,7 +2468,7 @@ mod tests { #[moq_clock::test] async fn a_request_waits_for_an_unanswered_token() { - let handle = Handle::new(true); + let handle = Link::new(true); let setup = handle.present(Bytes::new(), true).unwrap(); handle.granted(setup.presented.id, Grant::default()); let token = handle.present(Bytes::from_static(b"new"), false).unwrap(); @@ -1979,7 +2491,7 @@ mod tests { /// is refused against the union as it stands. #[test] fn a_withdrawn_route_ends_the_wait() { - let handle = Handle::new(true); + let handle = Link::new(true); let setup = handle.present(Bytes::new(), true).unwrap(); handle.granted(setup.presented.id, Grant::default()); let _unanswered = handle.present(Bytes::from_static(b"new"), false).unwrap(); @@ -1999,7 +2511,7 @@ mod tests { #[test] fn an_empty_token_never_holds_a_request() { - let handle = Handle::new(true); + let handle = Link::new(true); let _setup = handle.present(Bytes::new(), true).unwrap(); let mut gate = Gate::new(handle, crate::Path::new("room").to_owned(), Direction::Subscribe); assert!(gate.poll_ready(&kio::Waiter::noop()).is_ready()); @@ -2007,7 +2519,7 @@ mod tests { #[test] fn a_covered_request_never_waits_for_an_unanswered_token() { - let handle = Handle::new(true); + let handle = Link::new(true); let first = handle.present(Bytes::from_static(b"first"), false).unwrap(); handle.granted(first.presented.id, Grant::all()); let _unanswered = handle.present(Bytes::from_static(b"renewal"), false).unwrap(); @@ -2017,8 +2529,8 @@ mod tests { #[test] fn an_auth_ok_is_queued_after_its_scope_widens() { - let handle = Handle::new(true); - handle.authorize(&Grant::default()); + let handle = Link::new(true); + handle.handle().authorize(&Grant::default()); let serving = Serving::new(handle.clone()); let request = Request::new(Bytes::from_static(b"token"), serving.issue.clone()); let _issued = request.accept(Grant::all()); @@ -2031,8 +2543,8 @@ mod tests { /// how many wait; answering it gives the slot back. #[test] fn a_queued_request_token_holds_its_slot() { - let handle = Handle::new(true); - let mut requests = handle.requests().unwrap(); + let handle = Link::new(true); + let mut requests = handle.handle().requests().unwrap(); let slot: Hold = std::sync::Arc::new(()); let token = crate::setup::Token { kind: crate::setup::Token::OUT_OF_BAND, @@ -2060,8 +2572,8 @@ mod tests { /// withdrawing cannot grow it past what its credit holds open. #[test] fn withdrawn_tokens_leave_the_queue() { - let handle = Handle::new(true); - let mut requests = handle.requests().unwrap(); + let handle = Link::new(true); + let mut requests = handle.handle().requests().unwrap(); let queue = handle.acceptor().unwrap(); queue.push(Request::new(Bytes::from_static(b"live"), Default::default())); let withdrawn = kio::Shared::::default(); @@ -2096,22 +2608,22 @@ mod tests { /// without AUTH too. #[test] fn a_limit_replaces_the_last() { - let handle = Handle::new(false); + let handle = Link::new(false); assert!(handle.allows(Direction::Publish, "room/alice/audio")); - handle.authorize(&grant(&["room/bob"], &["room/alice"])); + handle.handle().authorize(&grant(&["room/bob"], &["room/alice"])); // What we send is what the peer may subscribe to, and the other way around. assert!(handle.allows(Direction::Publish, "room/alice/audio")); assert!(!handle.allows(Direction::Publish, "room/bob/cam")); assert!(handle.allows(Direction::Subscribe, "room/bob/cam")); assert!(!handle.allows(Direction::Subscribe, "room/alice/audio")); - handle.authorize(&grant(&[""], &["room/alice/video"])); + handle.handle().authorize(&grant(&[""], &["room/alice/video"])); assert!(!handle.allows(Direction::Publish, "room/alice/audio")); assert!(handle.allows(Direction::Publish, "room/alice/video")); assert!(handle.allows(Direction::Subscribe, "other")); - handle.authorize(&Grant::all()); + handle.handle().authorize(&Grant::all()); assert!(handle.allows(Direction::Publish, "room/alice/audio")); assert!(handle.allows(Direction::Subscribe, "other")); } @@ -2119,13 +2631,13 @@ mod tests { /// Our own grant does not decide what the peer may offer us, only the limit does. #[test] fn the_limit_alone_decides_what_the_peer_offers() { - let handle = Handle::new(true); + let handle = Link::new(true); let token = handle.present(Bytes::new(), true).unwrap(); handle.granted(0, grant(&[], &[])); assert!(!handle.allows(Direction::Subscribe, "room/bob/cam")); assert!(handle.within_limit(Direction::Subscribe, "room/bob/cam")); - handle.authorize(&grant(&["room/alice"], &[])); + handle.handle().authorize(&grant(&["room/alice"], &[])); assert!(!handle.within_limit(Direction::Subscribe, "room/bob/cam")); assert!(handle.within_limit(Direction::Subscribe, "room/alice/cam")); drop(token); @@ -2135,22 +2647,22 @@ mod tests { /// limit changes, narrower or wider, and stays quiet on a change that leaves it alone. #[test] fn the_default_grant_follows_the_limit() { - let handle = Handle::new(true); + let handle = Link::new(true); let mut default = DefaultGrant::new(handle.clone(), grant(&["room"], &["room"])); let waiter = kio::Waiter::noop(); assert_eq!(default.poll(&waiter), Poll::Ready(grant(&["room"], &["room"]))); assert!(default.poll(&waiter).is_pending()); - handle.authorize(&grant(&["room"], &["room/alice"])); + handle.handle().authorize(&grant(&["room"], &["room/alice"])); assert_eq!(default.poll(&waiter), Poll::Ready(grant(&["room"], &["room/alice"]))); // Wider again, but never past what the origin handles allow. - handle.authorize(&Grant::all()); + handle.handle().authorize(&Grant::all()); assert_eq!(default.poll(&waiter), Poll::Ready(grant(&["room"], &["room"]))); // A limit that leaves the grant as it is: nothing to tell the peer. - handle.authorize(&grant(&[""], &[""])); + handle.handle().authorize(&grant(&[""], &[""])); assert!(default.poll(&waiter).is_pending()); } @@ -2159,17 +2671,18 @@ mod tests { /// could carry it. #[test] fn a_request_carries_an_auth_without_the_auth_stream() { - let ietf = Handle::ietf(false); + let ietf = Link::ietf(false); let auth = ietf.present(Bytes::from_static(b"jwt"), false).unwrap(); assert!(matches!(auth.poll_answered(&kio::Waiter::noop()), Poll::Ready(Ok(())))); assert_eq!(auth.grant().peek(), None); assert_eq!(ietf.token(&auth).as_deref(), Some(b"jwt".as_slice())); - assert!(ietf.state.read().opening.is_empty(), "nothing to open a stream for"); + assert!(ietf.read(|link, _| link.opening.is_empty()), "nothing to open a stream for"); - let lite = Handle::new(false); + let lite = Link::new(false); + let auth = lite.present(Bytes::from_static(b"jwt"), false).unwrap(); assert!(matches!( - lite.present(Bytes::from_static(b"jwt"), false), - Err(Error::Unsupported) + auth.poll_answered(&kio::Waiter::noop()), + Poll::Ready(Err(Error::Unsupported)) )); } @@ -2177,7 +2690,7 @@ mod tests { /// requests rather than failing it. #[test] fn an_unsupported_peer_keeps_a_request_auth() { - let handle = Handle::ietf(true); + let handle = Link::ietf(true); let auth = handle.present(Bytes::from_static(b"jwt"), false).unwrap(); assert!(auth.poll_answered(&kio::Waiter::noop()).is_pending()); handle.unsupported(); @@ -2189,15 +2702,15 @@ mod tests { /// auth goes out under this session's scope. #[test] fn another_handle_does_not_carry_an_auth() { - let issuer = Handle::ietf(false); + let issuer = Link::ietf(false); let auth = issuer.present(Bytes::from_static(b"jwt"), false).unwrap(); - assert_eq!(Handle::ietf(false).token(&auth), None); + assert_eq!(Link::ietf(false).token(&auth), None); } /// Every clone names one credential, which stays presented until the last drops. #[test] fn the_last_clone_withdraws_the_auth() { - let handle = Handle::new(true); + let handle = Link::new(true); let auth = handle.present(Bytes::from_static(b"jwt"), false).unwrap(); let Poll::Ready(Some((id, _))) = handle.poll_opening(&kio::Waiter::noop()) else { panic!("nothing to open"); @@ -2218,6 +2731,68 @@ mod tests { drop(clone); assert!(handle.poll_withdrawn(id, &kio::Waiter::noop()).is_ready()); } + + fn opened(link: &Link) -> u64 { + match link.poll_opening(&kio::Waiter::noop()) { + Poll::Ready(Some((id, _))) => id, + _ => panic!("nothing to open"), + } + } + + /// Only the peer's verdict ends a handle's token for good: a session ending leaves it for + /// the next session, which presents it again, and one active session at a time. + #[test] + fn only_a_verdict_ends_a_shared_token() { + let handle = Handle::new(); + let first = Link::attach(&handle, true, false).unwrap(); + assert!(matches!(Link::attach(&handle, true, false), Err(Error::Duplicate))); + let auth = handle.present(Bytes::from_static(b"jwt")).unwrap(); + let id = opened(&first); + first.granted(id, Grant::all()); + assert!(matches!(auth.poll_answered(&kio::Waiter::noop()), Poll::Ready(Ok(())))); + assert_eq!(auth.grant().peek(), Some(Grant::all())); + + first.close(Error::Cancel); + assert_eq!(auth.grant().peek(), None, "no session is live"); + assert!(Selector::Token(id).ended(&handle.state.read()).is_none()); + + let second = Link::attach(&handle, true, false).unwrap(); + assert_eq!(opened(&second), id, "the next session presents it again"); + second.refused(id); + let refused = Error::Session(SessionError::Unauthorized); + assert_eq!(second.retry(id, &refused), None); + second.ended(id, refused); + assert!(matches!( + Selector::Token(id).ended(&handle.state.read()), + Some(Error::Session(SessionError::Unauthorized)) + )); + + second.close(Error::Cancel); + let third = Link::attach(&handle, true, false).unwrap(); + assert!(third.poll_opening(&kio::Waiter::noop()).is_pending(), "a refused token stays ended"); + } + + /// A refusal that may pass is presented again after a doubling backoff, and settles nothing. + #[test] + fn a_transient_refusal_backs_off() { + let link = Link::new(true); + let auth = link.present(Bytes::from_static(b"jwt"), false).unwrap(); + let id = opened(&link); + let busy = Error::Session(SessionError::TooManyRequests); + + link.refused(id); + assert_eq!(link.retry(id, &busy), Some(RETRY_INITIAL)); + assert!(auth.poll_answered(&kio::Waiter::noop()).is_pending()); + assert_eq!(link.reopen(id).as_deref(), Some(b"jwt".as_slice())); + link.refused(id); + assert_eq!(link.retry(id, &busy), Some(RETRY_INITIAL * 2)); + assert_eq!(link.retry(id, &busy), None, "only a refusal retries"); + + link.reopen(id); + link.granted(id, Grant::all()); + assert!(matches!(auth.poll_answered(&kio::Waiter::noop()), Poll::Ready(Ok(())))); + } + } #[cfg(all(test, feature = "async"))] @@ -2239,8 +2814,8 @@ mod request_token_tests { /// app answers is what the verdict resolves to. #[moq_clock::test] async fn a_request_token_reaches_the_acceptor() { - let handle = Handle::new(true); - let mut requests = handle.requests().expect("take the requests"); + let handle = Link::new(true); + let mut requests = handle.handle().requests().expect("take the requests"); let verdict = handle.verify_request(token(b"jwt", 0), None); let request = requests.next().await.expect("a request"); @@ -2255,8 +2830,8 @@ mod request_token_tests { /// the acceptor as one: it is unsupported, never handed over as if it were a plain token. #[moq_clock::test] async fn a_typed_request_token_is_unsupported() { - let handle = Handle::new(true); - let mut requests = handle.requests().unwrap(); + let handle = Link::new(true); + let mut requests = handle.handle().requests().unwrap(); let verdict = handle.verify_request(token(b"cat", crate::setup::Token::CAT), None); let err = verdict.grant().await.expect_err("a typed token"); assert!(matches!(err.error, Error::Unsupported), "{err:?}"); @@ -2270,8 +2845,8 @@ mod request_token_tests { /// connection's own credential. #[test] fn an_empty_request_token_is_unsupported() { - let handle = Handle::new(true); - let mut requests = handle.requests().unwrap(); + let handle = Link::new(true); + let mut requests = handle.handle().requests().unwrap(); let verdict = handle.verify_request(token(b"", crate::setup::Token::OUT_OF_BAND), None); assert!(matches!( verdict.poll_grant(&kio::Waiter::noop()), @@ -2292,8 +2867,8 @@ mod request_token_tests { /// a standard moq-transport peer presents when it does not negotiate the AUTH extension. #[moq_clock::test] async fn a_request_token_is_verified_without_the_auth_extension() { - let handle = Handle::new(false); - let mut requests = handle.requests().expect("take the requests"); + let handle = Link::new(false); + let mut requests = handle.handle().requests().expect("take the requests"); handle.unsupported(); let verdict = handle.verify_request(token(b"jwt", 0), None); let request = requests.next().await.expect("a request reaches the acceptor"); @@ -2308,7 +2883,7 @@ mod request_token_tests { /// is Unsupported and the caller refuses the request NOT_SUPPORTED. #[moq_clock::test] async fn no_consumer_refuses_a_request_token_as_unsupported() { - let handle = Handle::new(true); + let handle = Link::new(true); let verdict = handle.verify_request(token(b"jwt", 0), None); let err = verdict.grant().await.expect_err("no verifier"); assert!(matches!(err.error, Error::Unsupported), "{err:?}"); @@ -2318,8 +2893,8 @@ mod request_token_tests { /// UNAUTHORIZED. #[moq_clock::test] async fn a_refused_request_token_is_unauthorized() { - let handle = Handle::new(true); - let mut requests = handle.requests().unwrap(); + let handle = Link::new(true); + let mut requests = handle.handle().requests().unwrap(); let verdict = handle.verify_request(token(b"jwt", 0), None); requests.next().await.unwrap().reject(SessionError::Unauthorized, "no"); let err = verdict.grant().await.expect_err("refused"); @@ -2333,8 +2908,8 @@ mod request_token_tests { /// as it stands now, never as first issued. #[moq_clock::test] async fn a_grant_ended_before_admission_is_refused() { - let handle = Handle::new(true); - let mut requests = handle.requests().unwrap(); + let handle = Link::new(true); + let mut requests = handle.handle().requests().unwrap(); let verdict = handle.verify_request(token(b"jwt", 0), None); let issued = requests.next().await.unwrap().accept(Grant::all()); @@ -2364,8 +2939,8 @@ mod request_token_tests { /// acceptor's issued grant, so it never outlives the request nor joins the union. #[moq_clock::test] async fn dropping_the_verdict_ends_the_grant() { - let handle = Handle::new(true); - let mut requests = handle.requests().unwrap(); + let handle = Link::new(true); + let mut requests = handle.handle().requests().unwrap(); let verdict = handle.verify_request(token(b"jwt", 0), None); let issued = requests.next().await.unwrap().accept(Grant::all()); verdict.grant().await.expect("granted"); @@ -2378,14 +2953,14 @@ mod request_token_tests { /// credential earned, not what a request token granted. #[moq_clock::test] async fn a_request_token_never_joins_the_union() { - let handle = Handle::new(true); - let mut requests = handle.requests().unwrap(); + let handle = Link::new(true); + let mut requests = handle.handle().requests().unwrap(); let verdict = handle.verify_request(token(b"jwt", 0), None); let _issued = requests.next().await.unwrap().accept(Grant::all()); verdict.grant().await.expect("granted"); // The union only reflects tokens presented on this side (none here), never a // request token the acceptor answered. - assert_eq!(handle.grant().peek(), None, "a request token must not widen the union"); + assert_eq!(handle.handle().grant().peek(), None, "a request token must not widen the union"); } fn subscribe_only(prefix: &str) -> Grant { @@ -2401,9 +2976,9 @@ mod request_token_tests { /// request never widens what tokenless requests may do. #[moq_clock::test] async fn the_scope_is_session_tokens_only() { - let handle = Handle::new(true); - let mut requests = handle.requests().unwrap(); - handle.authorize(&subscribe_only("room/alice")); + let handle = Link::new(true); + let mut requests = handle.handle().requests().unwrap(); + handle.handle().authorize(&subscribe_only("room/alice")); assert!(!handle.allows(Direction::Publish, "room/bob")); // A token on a request admits that request, and nothing else. @@ -2447,9 +3022,9 @@ mod request_token_tests { /// Revoking or dropping an AUTH token's grant takes it out of the scope. #[moq_clock::test] async fn an_ended_auth_grant_leaves_the_scope() { - let handle = Handle::new(true); - let mut requests = handle.requests().unwrap(); - handle.authorize(&Grant::default()); + let handle = Link::new(true); + let mut requests = handle.handle().requests().unwrap(); + handle.handle().authorize(&Grant::default()); for revoke in [true, false] { let serving = Serving::new(handle.clone()); requests @@ -2469,8 +3044,8 @@ mod request_token_tests { /// tokens were granted: the scope only exists once the connection credential has a grant. #[moq_clock::test] async fn an_unauthorized_session_has_no_scope() { - let handle = Handle::new(true); - let mut requests = handle.requests().unwrap(); + let handle = Link::new(true); + let mut requests = handle.handle().requests().unwrap(); let serving = Serving::new(handle.clone()); requests .queue @@ -2493,8 +3068,8 @@ mod request_token_tests { #[moq_clock::test] async fn a_request_grant_has_the_acceptors_expiry() { let runtime = crate::time::sim(); - let handle = Handle::new(true); - let mut requests = handle.requests().unwrap(); + let handle = Link::new(true); + let mut requests = handle.handle().requests().unwrap(); let verdict = handle.verify_request(token(b"jwt", 0), None); let grant = grant_in(&runtime, 60); let expires = grant.expires; @@ -2518,8 +3093,8 @@ mod request_token_tests { #[moq_clock::test] async fn an_already_expired_grant_is_refused() { let runtime = crate::time::sim(); - let handle = Handle::new(true); - let mut requests = handle.requests().unwrap(); + let handle = Link::new(true); + let mut requests = handle.handle().requests().unwrap(); let expired = || Grant { expires: Some(runtime.now()), ..grant_in(&runtime, 0) @@ -2566,8 +3141,8 @@ mod request_token_tests { #[moq_clock::test] async fn a_renewal_token_replaces_the_grant_and_expiry() { let runtime = crate::time::sim(); - let handle = Handle::new(true); - let mut requests = handle.requests().unwrap(); + let handle = Link::new(true); + let mut requests = handle.handle().requests().unwrap(); let verdict = handle.verify_request(token(b"jwt", 0), None); let first = grant_in(&runtime, 60); @@ -2604,8 +3179,8 @@ mod request_token_tests { #[moq_clock::test] async fn the_deadline_expires_the_request_without_renewal() { let runtime = crate::time::sim(); - let handle = Handle::new(true); - let mut requests = handle.requests().unwrap(); + let handle = Link::new(true); + let mut requests = handle.handle().requests().unwrap(); let verdict = handle.verify_request(token(b"jwt", 0), None); // Held for the request's life, so only the deadline (not a drop) ends it. let _issued = requests.next().await.unwrap().accept(grant_in(&runtime, 60)); @@ -2622,7 +3197,7 @@ mod request_token_tests { let err = kio::wait(|waiter| request_grant.poll_ended(waiter)).await.error; assert!(matches!(err, Error::Expired), "{err:?}"); - assert_eq!(handle.grant().peek(), None, "the session grant is untouched"); + assert_eq!(handle.handle().grant().peek(), None, "the session grant is untouched"); } /// A renewal the acceptor granted before the old deadline lapsed keeps the request, even @@ -2630,8 +3205,8 @@ mod request_token_tests { #[moq_clock::test] async fn a_granted_renewal_outlives_the_old_deadline() { let runtime = crate::time::sim(); - let handle = Handle::new(true); - let mut requests = handle.requests().unwrap(); + let handle = Link::new(true); + let mut requests = handle.handle().requests().unwrap(); let verdict = handle.verify_request(token(b"jwt", 0), None); let _issued = requests.next().await.unwrap().accept(grant_in(&runtime, 60)); let answered = verdict.grant().await.unwrap(); @@ -2668,8 +3243,8 @@ mod request_token_tests { #[moq_clock::test] async fn a_refused_renewal_leaves_the_grant() { let runtime = crate::time::sim(); - let handle = Handle::new(true); - let mut requests = handle.requests().unwrap(); + let handle = Link::new(true); + let mut requests = handle.handle().requests().unwrap(); let verdict = handle.verify_request(token(b"jwt", 0), None); let first = grant_in(&runtime, 60); let first_expires = first.expires; @@ -2714,8 +3289,8 @@ mod request_token_tests { #[moq_clock::test] async fn an_update_that_stops_covering_ends_the_request() { let runtime = crate::time::sim(); - let handle = Handle::new(true); - let mut requests = handle.requests().unwrap(); + let handle = Link::new(true); + let mut requests = handle.handle().requests().unwrap(); let verdict = handle.verify_request(token(b"jwt", 0), None); let issued = requests.next().await.unwrap().accept(Grant::all()); let answered = verdict.grant().await.unwrap(); @@ -2797,8 +3372,8 @@ mod request_token_tests { #[moq_clock::test] async fn an_acceptor_revoke_ends_the_request() { let runtime = crate::time::sim(); - let handle = Handle::new(true); - let mut requests = handle.requests().unwrap(); + let handle = Link::new(true); + let mut requests = handle.handle().requests().unwrap(); let verdict = handle.verify_request(token(b"jwt", 0), None); // A grant that never expires, so only the revoke can end the request. let issued = requests.next().await.unwrap().accept(Grant::all()); @@ -2820,6 +3395,6 @@ mod request_token_tests { "{refusal:?}" ); assert_eq!(refusal.reason, "revoked", "the acceptor's reason is kept"); - assert_eq!(handle.grant().peek(), None, "the session grant is untouched"); + assert_eq!(handle.handle().grant().peek(), None, "the session grant is untouched"); } } diff --git a/rs/moq-net/src/client.rs b/rs/moq-net/src/client.rs index fa06045ed8b..a9cb91c1505 100644 --- a/rs/moq-net/src/client.rs +++ b/rs/moq-net/src/client.rs @@ -20,6 +20,7 @@ pub struct Client { peer_hop: Option, limits: crate::session::Limits, extensions: setup::Extensions, + auth: Option, } impl Client { @@ -95,6 +96,18 @@ impl Client { self } + /// Build every session on `auth`, so each presents the tokens it holds and a route + /// naming one of its [`Auth`](crate::auth::Auth)s keeps it across a reconnect. Omit to + /// give each session a handle of its own, which ends with it. + /// + /// The handle backs one active session at a time, plus the ones draining after a + /// GOAWAY: connecting another while one is still active fails with + /// [`Error::Duplicate`]. + pub fn with_auth(mut self, auth: crate::auth::Handle) -> Self { + self.auth = Some(auth); + self + } + /// Choose which extensions to offer in SETUP. Defaults to every moq-transport one, and /// not [`ietf`](setup::Extensions::ietf), which only relays turn on. pub fn with_extensions(mut self, extensions: setup::Extensions) -> Self { @@ -151,6 +164,16 @@ impl Client { self.peer_hop.unwrap_or_else(crate::Hop::random) } + /// The new session's side of its auth handle: the one from [`with_auth`](Self::with_auth), + /// or one of its own. `requests` for moq-transport, where a request carries a token. + fn link(&self, supported: bool, requests: bool) -> Result { + match &self.auth { + Some(handle) => crate::auth::Link::attach(handle, supported, requests), + None if requests => Ok(crate::auth::Link::ietf(supported)), + None => Ok(crate::auth::Link::new(supported)), + } + } + /// The origin pair a session attaches, tagged and filtered. /// /// Reads through the publish (egress) consumer and writes through the @@ -219,7 +242,7 @@ impl Client { version, our_setup, peer_setup: None, - auth: crate::auth::Handle::new(version.has_auth()), + auth: self.link(version.has_auth(), false)?, })?; Ok(Session::new( @@ -293,7 +316,7 @@ impl Client { // We advertise the request path in our SETUP for URL-less transports. // The peer's SETUP decides whether AUTH is negotiated. A handle that does not // offer it keeps the option out of our SETUP and presents no credential. - let auth = crate::auth::Handle::ietf(self.extensions.auth); + let auth = self.link(self.extensions.auth, true)?; let (protocol, goaway) = ietf::start(ietf::Config { runtime: runtime.clone(), limits: self.limits, @@ -439,7 +462,7 @@ impl Client { our_setup: lite::Setup::default(), peer_setup: None, // Negotiated over the bidi SETUP: lite 01/02, which carry no AUTH. - auth: crate::auth::Handle::new(v.has_auth()), + auth: self.link(v.has_auth(), false)?, })?; ( @@ -467,7 +490,7 @@ impl Client { let stream = stream.with_version(v); // Draft 14-16 carry no AUTH, but the session is still limited through this handle. - let auth = crate::auth::Handle::ietf(false); + let auth = self.link(false, true)?; // Draft 14-16: the path rode in the bidi SETUP above, not the uni one. let (protocol, goaway) = ietf::start(ietf::Config { runtime: runtime.clone(), diff --git a/rs/moq-net/src/goaway.rs b/rs/moq-net/src/goaway.rs index f1659e60c9c..f99b17b0961 100644 --- a/rs/moq-net/src/goaway.rs +++ b/rs/moq-net/src/goaway.rs @@ -398,6 +398,11 @@ impl Handle { pub fn consumer(&self) -> Consumer { self.consumer.clone() } + + /// Whether either side sent a GOAWAY, so the session is draining. + pub fn draining(&self) -> bool { + self.trigger.read().is_some() || self.consumer.state.read().is_some() + } } #[cfg(test)] diff --git a/rs/moq-net/src/ietf/auth.rs b/rs/moq-net/src/ietf/auth.rs index 71ecc4097f0..9394dc41b7e 100644 --- a/rs/moq-net/src/ietf/auth.rs +++ b/rs/moq-net/src/ietf/auth.rs @@ -13,7 +13,7 @@ use std::time::Duration; use bytes::Bytes; -use crate::auth::{Grant, Handle, Issue, Reply, Request, Serving}; +use crate::auth::{Grant, Issue, Link, Reply, Request, Serving}; use crate::coding::{Decode, DecodeError, Decoder, Encode, EncodeError, Encoder}; use crate::{Error, Path, Pattern, Patterns, SessionError}; @@ -257,7 +257,7 @@ pub(super) async fn run_present( runtime: crate::time::Deadlines, session: S, control: Control, - handle: Handle, + handle: Link, peer_setup: peer::PeerSetup, version: Version, going_away: crate::goaway::GoingAway, @@ -295,10 +295,27 @@ where let going_away = going_away.clone(); let violation = violation.clone(); tasks.push(async move { - // After a GOAWAY the peer must not see new requests. - let err = match going_away.is_set() { - true => Error::GoingAway, - false => present.run(token).await, + let mut token = token; + let err = loop { + // After a GOAWAY the peer must not see new requests. + if going_away.is_set() { + break Error::GoingAway; + } + let err = present.run(token).await; + let Some(delay) = present.handle.retry(present.id, &err) else { + break err; + }; + tracing::debug!(?delay, "auth token refused transiently; presenting it again"); + let mut deadline = crate::time::Deadline::after(&present.runtime, delay); + kio::wait(|waiter| match present.handle.poll_withdrawn(present.id, waiter) { + Poll::Ready(()) => Poll::Ready(()), + Poll::Pending => deadline.poll(waiter), + }) + .await; + match present.handle.reopen(present.id) { + Some(next) => token = next, + None => break Error::Cancel, + } }; let err = crate::auth::protocol_error(err); if matches!(err, Error::ProtocolViolation) { @@ -323,7 +340,7 @@ struct Present { runtime: crate::time::Deadlines, session: S, control: Control, - handle: Handle, + handle: Link, version: Version, id: u64, } @@ -433,7 +450,7 @@ fn decode_reply(body: &super::Body, version: Version) -> Result { version: Version, // Our grant (MoQ Auth): only what it lets us publish is advertised and served, and a // shrink withdraws what it no longer covers. - auth: crate::auth::Handle, + auth: crate::auth::Link, // Dispatched finite serves, including those not yet polled. pub(super) owed: Arc, // Subscriptions the peer may hold at once (`session::Limits::subscriptions`). @@ -664,7 +664,7 @@ pub(super) struct Publisher { /// Authorization selected by dispatch: a standalone path or a live parent subscription. enum FetchGate { Standalone(crate::auth::Gate), - Joining { id: RequestId, auth: crate::auth::Handle }, + Joining { id: RequestId, auth: crate::auth::Link }, } struct Serve(Arc); @@ -735,7 +735,7 @@ where peer_setup, joins: Default::default(), version, - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), owed: Default::default(), subscriptions: Default::default(), fetches: Default::default(), @@ -744,7 +744,7 @@ where } /// Bound what we publish by the grant this session's tokens earn (MoQ Auth). - pub fn with_auth(mut self, auth: crate::auth::Handle) -> Self { + pub fn with_auth(mut self, auth: crate::auth::Link) -> Self { self.auth = auth; self } @@ -7536,7 +7536,7 @@ mod serve_tests { )); assert!(futures::poll!(running.as_mut()).is_pending()); let mark = session.log.writes.lock().unwrap().len(); - h.publisher.auth.authorize(&crate::auth::Grant::default()); + h.publisher.auth.handle().authorize(&crate::auth::Grant::default()); // An otherwise valid no-op update is ready at the same time as revocation. session.push(&[0x02, 0, 2, 2, 0]); assert!(futures::poll!(running.as_mut()).is_pending()); @@ -8073,8 +8073,8 @@ mod serve_tests { for version in ALL_VERSIONS { let mut h = serve(version); let claim = h._origin.dynamic("live", crate::origin::Route::default()).unwrap(); - let auth = crate::auth::Handle::new(false); - auth.authorize(¬hing()); + let auth = crate::auth::Link::new(false); + auth.handle().authorize(¬hing()); h.publisher = h.publisher.clone().with_auth(auth); let code = moq_clock::timeout( std::time::Duration::from_secs(1), @@ -8093,7 +8093,7 @@ mod serve_tests { for version in ALL_VERSIONS { let mut h = serve(version); let claim = h._origin.dynamic("live", crate::origin::Route::default()).unwrap(); - let auth = crate::auth::Handle::new(false); + let auth = crate::auth::Link::new(false); h.publisher = h.publisher.clone().with_auth(auth.clone()); let stream = Stream::open(&mut h.session.clone(), version).await.unwrap(); @@ -8114,7 +8114,7 @@ mod serve_tests { // Parked resolving the broadcast when the grant narrows. let request = claim.requested_broadcast().await.unwrap(); - auth.authorize(¬hing()); + auth.handle().authorize(¬hing()); let output = crate::broadcast::Info::new().produce(); let _track = output.create_track("video", None).unwrap(); request.accept(&output); @@ -8136,8 +8136,8 @@ mod serve_tests { }; for version in [Version::Draft14, Version::Draft22] { let mut h = serve(version); - let auth = crate::auth::Handle::new(false); - auth.authorize(¬hing); + let auth = crate::auth::Link::new(false); + auth.handle().authorize(¬hing); h.publisher = h.publisher.clone().with_auth(auth); let mut body = track_status("room", true, version); @@ -8164,7 +8164,7 @@ mod serve_tests { let session = SinkSession::gated_bi(bi.consume()); let peer_setup = peer::PeerSetup::default(); peer_setup.set(peer::Peer::default()); - let auth = crate::auth::Handle::new(false); + let auth = crate::auth::Link::new(false); let publisher = Publisher::new( crate::time::sim(), session.clone(), @@ -8199,7 +8199,7 @@ mod serve_tests { moq_clock::sim::yield_now().await; } - auth.authorize(&crate::auth::Grant { + auth.handle().authorize(&crate::auth::Grant { publish: Default::default(), subscribe: Default::default(), expires: None, @@ -10302,8 +10302,8 @@ mod serve_tests { let mut h = serve(version); publish_pairs(&mut h, 1, None); settle().await; - let auth = crate::auth::Handle::new(false); - auth.authorize(¬hing); + let auth = crate::auth::Link::new(false); + auth.handle().authorize(¬hing); h.publisher = h.publisher.clone().with_auth(auth); let buf = standalone_fetch(&h, location, location, GroupOrder::Ascending).await; @@ -10319,7 +10319,7 @@ mod serve_tests { let h = serve(version); let _group = h.track.clone().create_group(group::Info { sequence: 0 }).unwrap(); settle().await; - let auth = crate::auth::Handle::new(false); + let auth = crate::auth::Link::new(false); let mut h = h; h.publisher = h.publisher.clone().with_auth(auth.clone()); @@ -10333,7 +10333,7 @@ mod serve_tests { futures::poll!(fetch.as_mut()).is_pending(), "{version}: served an unwritten group" ); - auth.authorize(¬hing); + auth.handle().authorize(¬hing); let buf = moq_clock::timeout(std::time::Duration::from_secs(1), fetch) .await .unwrap_or_else(|_| panic!("{version}: still loading after the grant narrowed")); @@ -10364,7 +10364,7 @@ mod serve_tests { let session = SinkSession::gated_bi(bi.consume()).with_open_uni_gate(credit.consume()); let peer_setup = peer::PeerSetup::default(); peer_setup.set(peer::Peer::default()); - let auth = crate::auth::Handle::new(false); + let auth = crate::auth::Link::new(false); let publisher = Publisher::new( crate::time::sim(), session.clone(), @@ -10413,7 +10413,7 @@ mod serve_tests { "{version}: FETCH_OK never went out" ); - auth.authorize(&crate::auth::Grant { + auth.handle().authorize(&crate::auth::Grant { publish: Default::default(), subscribe: Default::default(), expires: None, @@ -10457,7 +10457,7 @@ mod serve_tests { let session = SinkSession::gated_bi(bi.consume()).with_open_uni_gate(credit.consume()); let peer_setup = peer::PeerSetup::default(); peer_setup.set(peer::Peer::default()); - let auth = crate::auth::Handle::new(false); + let auth = crate::auth::Link::new(false); let publisher = Publisher::new( crate::time::sim(), session.clone(), @@ -10515,7 +10515,7 @@ mod serve_tests { "{version}: FETCH_OK never went out" ); - auth.authorize(&crate::auth::Grant { + auth.handle().authorize(&crate::auth::Grant { publish: Default::default(), subscribe: Default::default(), expires: None, @@ -10561,10 +10561,10 @@ mod serve_tests { let session = SinkSession::gated_bi(bi.consume()).with_open_uni_gate(credit.consume()); let peer_setup = peer::PeerSetup::default(); peer_setup.set(peer::Peer::default()); - let auth = crate::auth::Handle::new(false); - let mut requests = auth.requests().unwrap(); + let auth = crate::auth::Link::new(false); + let mut requests = auth.handle().requests().unwrap(); // A limit of nothing, so only the token admits the subscription. - auth.authorize(&match token { + auth.handle().authorize(&match token { true => crate::auth::Grant::default(), false => crate::auth::Grant::all(), }); @@ -10709,10 +10709,10 @@ mod serve_tests { let session = SinkSession::gated_bi(bi.consume()).with_open_uni_gate(credit.consume()); let peer_setup = peer::PeerSetup::default(); peer_setup.set(peer::Peer::default()); - let auth = crate::auth::Handle::new(false); - let mut requests = auth.requests().unwrap(); + let auth = crate::auth::Link::new(false); + let mut requests = auth.handle().requests().unwrap(); // A limit of nothing, so only the token admits the subscription. - auth.authorize(&crate::auth::Grant::default()); + auth.handle().authorize(&crate::auth::Grant::default()); let publisher = Publisher::new( crate::time::sim(), session.clone(), @@ -11150,13 +11150,13 @@ mod serve_tests { /// Like [`serve`], but with an app auth acceptor wired in and the subscribe stream's /// reader scripted with `first_script` (a REQUEST_UPDATE, for the renewal tests). - fn serve_with_auth(version: Version, auth: crate::auth::Handle, first_script: Vec) -> Serve { + fn serve_with_auth(version: Version, auth: crate::auth::Link, first_script: Vec) -> Serve { serve_on(version, auth, ScriptedSession::per_stream(vec![first_script])) } /// [`serve_with_auth`] over a given session, such as one that finishes the stream after /// its script. - fn serve_on(version: Version, auth: crate::auth::Handle, session: ScriptedSession) -> Serve { + fn serve_on(version: Version, auth: crate::auth::Link, session: ScriptedSession) -> Serve { let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); let broadcast = origin.publish("room", crate::origin::Route::default()).unwrap(); let track = broadcast.create_track("video", crate::track::Info::timed()).unwrap(); @@ -11199,13 +11199,13 @@ mod serve_tests { /// An auth handle whose session grant covers only `other`, so a SUBSCRIBE for `room` /// falls to its request token. The presented credential is kept alive by the returned /// [`crate::auth::Auth`]. - fn auth_covering_other() -> (crate::auth::Handle, crate::auth::Requests, crate::auth::Auth) { - let auth = crate::auth::Handle::new(true); - let requests = auth.requests().unwrap(); + fn auth_covering_other() -> (crate::auth::Link, crate::auth::Requests, crate::auth::Auth) { + let auth = crate::auth::Link::new(true); + let requests = auth.handle().requests().unwrap(); let cred = auth.present(bytes::Bytes::from_static(b"cred"), true).unwrap(); // The peer lets us publish everything, and we let it read only "other". auth.granted(0, crate::auth::Grant::all()); - auth.authorize(&crate::auth::Grant { + auth.handle().authorize(&crate::auth::Grant { publish: crate::Patterns::new(), subscribe: crate::Pattern::subtree("other").unwrap().into(), expires: None, @@ -12292,9 +12292,9 @@ mod serve_tests { #[moq_clock::test] async fn a_request_token_on_a_no_auth_session_is_verified() { const VERSION: Version = Version::Draft18; - let auth = crate::auth::Handle::new(false); - let mut requests = auth.requests().unwrap(); - auth.authorize(&crate::auth::Grant::default()); + let auth = crate::auth::Link::new(false); + let mut requests = auth.handle().requests().unwrap(); + auth.handle().authorize(&crate::auth::Grant::default()); let h = serve_with_auth(VERSION, auth, Vec::new()); let mut group = h.track.create_group(group::Info { sequence: 0 }).unwrap(); @@ -12341,12 +12341,12 @@ mod serve_tests { async fn serve_token_subscribe( grant: crate::auth::Grant, limit: Option, - ) -> (crate::auth::Handle, Serve, Serving) { + ) -> (crate::auth::Link, Serve, Serving) { const VERSION: Version = Version::Draft18; - let auth = crate::auth::Handle::new(false); - let mut requests = auth.requests().unwrap(); + let auth = crate::auth::Link::new(false); + let mut requests = auth.handle().requests().unwrap(); if let Some(limit) = &limit { - auth.authorize(limit); + auth.handle().authorize(limit); } let h = serve_with_auth(VERSION, auth.clone(), Vec::new()); let mut group = h.track.create_group(group::Info { sequence: 0 }).unwrap(); @@ -12416,7 +12416,7 @@ mod serve_tests { still_serving(&mut serving).await, "a limit of nothing defers to the token" ); - auth.authorize(&crate::auth::Grant { + auth.handle().authorize(&crate::auth::Grant { publish: crate::Patterns::new(), subscribe: crate::Pattern::subtree("other").unwrap().into(), expires: None, @@ -12433,8 +12433,8 @@ mod serve_tests { #[moq_clock::test] async fn a_covering_peer_grant_never_skips_the_verifier() { const VERSION: Version = Version::Draft18; - let auth = crate::auth::Handle::new(true); - let mut requests = auth.requests().unwrap(); + let auth = crate::auth::Link::new(true); + let mut requests = auth.handle().requests().unwrap(); let _cred = auth.present(bytes::Bytes::from_static(b"cred"), true).unwrap(); auth.granted(0, crate::auth::Grant::all()); let h = serve_with_auth(VERSION, auth, Vec::new()); @@ -12483,7 +12483,7 @@ mod serve_tests { let (auth, _h, mut serving) = serve_token_subscribe(crate::auth::Grant::all(), Some(limit("room"))).await; assert!(still_serving(&mut serving).await, "the verified token admits it"); - auth.authorize(&limit("other")); + auth.handle().authorize(&limit("other")); assert!( still_serving(&mut serving).await, "narrowing the limit leaves the token-verified subscription" @@ -12495,8 +12495,8 @@ mod serve_tests { #[moq_clock::test] async fn a_token_on_a_subscription_admitted_without_one_is_refused() { for version in [Version::Draft16, Version::Draft22] { - let auth = crate::auth::Handle::new(false); - auth.authorize(&crate::auth::Grant::all()); + let auth = crate::auth::Link::new(false); + auth.handle().authorize(&crate::auth::Grant::all()); let h = serve_with_auth(version, auth, Vec::new()); let mut session = ScriptedSession::per_stream(vec![update_with_tokens(version, &[TOKEN])]); let mut stream = Stream::open(&mut session, version).await.unwrap(); @@ -12550,7 +12550,7 @@ mod serve_tests { a_token_without_a_verifier_is_refused(VERSION, limit).await; } - let unlimited = crate::auth::Handle::new(false); + let unlimited = crate::auth::Link::new(false); let h = serve_with_auth(VERSION, unlimited, Vec::new()); let mut group = h.track.create_group(group::Info { sequence: 0 }).unwrap(); group.write_frame(timestamp(), b"frame".as_slice()).unwrap(); @@ -12569,8 +12569,8 @@ mod serve_tests { /// Serve a token SUBSCRIBE on a session limited to `limit` with no verifier, and check it /// is refused NOT_SUPPORTED. async fn a_token_without_a_verifier_is_refused(version: Version, limit: crate::auth::Grant) { - let auth = crate::auth::Handle::new(false); - auth.authorize(&limit); + let auth = crate::auth::Link::new(false); + auth.handle().authorize(&limit); let h = serve_with_auth(version, auth, Vec::new()); let stream = Stream::open(&mut h.session.clone(), version).await.unwrap(); let mut serving = std::pin::pin!(h.publisher.clone().run_subscribe_stream(stream, token_subscribe())); @@ -12610,8 +12610,8 @@ mod serve_tests { const VERSION: Version = Version::Draft18; // Withdrawn before the application looked. - let auth = crate::auth::Handle::new(false); - let _requests = auth.requests().unwrap(); + let auth = crate::auth::Link::new(false); + let _requests = auth.handle().requests().unwrap(); let mut h = serve_with_auth(VERSION, auth, Vec::new()); h.publisher.subscriptions = crate::session::Slots::new(1); let stream = Stream::open(&mut h.session.clone(), VERSION).await.unwrap(); @@ -12632,8 +12632,8 @@ mod serve_tests { ); // Withdrawn after the application took it. - let auth = crate::auth::Handle::new(false); - let mut requests = auth.requests().unwrap(); + let auth = crate::auth::Link::new(false); + let mut requests = auth.handle().requests().unwrap(); let mut h = serve_with_auth(VERSION, auth, Vec::new()); h.publisher.subscriptions = crate::session::Slots::new(1); let stream = Stream::open(&mut h.session.clone(), VERSION).await.unwrap(); @@ -12672,8 +12672,8 @@ mod serve_tests { #[moq_clock::test] async fn a_request_token_cannot_exceed_the_peers_grant() { const VERSION: Version = Version::Draft18; - let auth = crate::auth::Handle::new(true); - let mut requests = auth.requests().unwrap(); + let auth = crate::auth::Link::new(true); + let mut requests = auth.handle().requests().unwrap(); let _cred = auth.present(bytes::Bytes::from_static(b"cred"), true).unwrap(); auth.granted( 0, @@ -12683,7 +12683,7 @@ mod serve_tests { expires: None, }, ); - auth.authorize(&crate::auth::Grant::default()); + auth.handle().authorize(&crate::auth::Grant::default()); let h = serve_with_auth(VERSION, auth, Vec::new()); let stream = Stream::open(&mut h.session.clone(), VERSION).await.unwrap(); let res = h @@ -14380,7 +14380,7 @@ mod tests { /// `origin` unsolicited, with one script per stream it opens. fn auth_publisher( origin: &crate::origin::Producer, - auth: &crate::auth::Handle, + auth: &crate::auth::Link, scripts: Vec>, version: Version, ) -> ( @@ -14438,7 +14438,7 @@ mod tests { async fn swaps_a_route_auth(version: Version) { let origin = unheld_origin(); - let auth = crate::auth::Handle::ietf(false); + let auth = crate::auth::Link::ietf(false); let first = auth.present(bytes::Bytes::from_static(b"first-secret"), false).unwrap(); let second = auth .present(bytes::Bytes::from_static(b"second-secret"), false) @@ -14508,7 +14508,7 @@ mod tests { const VERSION: Version = Version::Draft16; let origin = unheld_origin(); - let auth = crate::auth::Handle::ietf(false); + let auth = crate::auth::Link::ietf(false); let first = auth.present(bytes::Bytes::from_static(b"first-secret"), false).unwrap(); let second = auth .present(bytes::Bytes::from_static(b"second-secret"), false) @@ -14544,7 +14544,7 @@ mod tests { const VERSION: Version = Version::Draft16; let origin = unheld_origin(); - let auth = crate::auth::Handle::ietf(false); + let auth = crate::auth::Link::ietf(false); let first = auth.present(bytes::Bytes::from_static(b"first-secret"), false).unwrap(); let second = auth .present(bytes::Bytes::from_static(b"second-secret"), false) @@ -14591,7 +14591,7 @@ mod tests { const VERSION: Version = Version::Draft19; let origin = unheld_origin(); - let other = crate::auth::Handle::ietf(false); + let other = crate::auth::Link::ietf(false); let foreign = other .present(bytes::Bytes::from_static(b"foreign-secret"), false) .unwrap(); @@ -14601,7 +14601,7 @@ mod tests { settle().await; let ok = publish_namespace_ok(VERSION).await; - let (publisher, log) = auth_publisher(&origin, &crate::auth::Handle::ietf(false), vec![ok], VERSION); + let (publisher, log) = auth_publisher(&origin, &crate::auth::Link::ietf(false), vec![ok], VERSION); let mut run = std::pin::pin!(publisher.run_publish_namespaces()); poll_until(&mut run, &log, b"cam", 1).await; @@ -14620,7 +14620,7 @@ mod tests { const VERSION: Version = Version::Draft19; let origin = unheld_origin(); - let auth = crate::auth::Handle::ietf(false); + let auth = crate::auth::Link::ietf(false); let token = auth.present(bytes::Bytes::from_static(b"late-secret"), false).unwrap(); let cam = origin.announce("cam", crate::origin::Route::default()).unwrap(); settle().await; @@ -14646,7 +14646,7 @@ mod tests { const VERSION: Version = Version::Draft14; let origin = unheld_origin(); - let auth = crate::auth::Handle::ietf(false); + let auth = crate::auth::Link::ietf(false); let first = auth.present(bytes::Bytes::from_static(b"first-secret"), false).unwrap(); let second = auth .present(bytes::Bytes::from_static(b"second-secret"), false) @@ -14680,7 +14680,7 @@ mod tests { const VERSION: Version = Version::Draft19; let origin = unheld_origin(); - let auth = crate::auth::Handle::ietf(false); + let auth = crate::auth::Link::ietf(false); let present = |token: &'static [u8]| auth.present(bytes::Bytes::from_static(token), false).unwrap(); let a = origin .announce("cam-a", crate::origin::Route::default().with_auth(present(b"a-first"))) @@ -14783,7 +14783,7 @@ mod tests { const VERSION: Version = Version::Draft19; let origin = unheld_origin(); - let auth = crate::auth::Handle::ietf(false); + let auth = crate::auth::Link::ietf(false); let present = |token: &'static [u8]| auth.present(bytes::Bytes::from_static(token), false).unwrap(); let cam = origin .announce("cam", crate::origin::Route::default().with_auth(present(b"first"))) @@ -14838,7 +14838,7 @@ mod tests { async fn advertises_again_unanswered(version: Version) { let origin = unheld_origin(); - let auth = crate::auth::Handle::ietf(false); + let auth = crate::auth::Link::ietf(false); let present = |token: &'static [u8]| auth.present(bytes::Bytes::from_static(token), false).unwrap(); let cam = origin .announce("cam", crate::origin::Route::default().with_auth(present(b"first"))) diff --git a/rs/moq-net/src/ietf/session.rs b/rs/moq-net/src/ietf/session.rs index 8c7b01bd03e..17b732f07f7 100644 --- a/rs/moq-net/src/ietf/session.rs +++ b/rs/moq-net/src/ietf/session.rs @@ -76,7 +76,7 @@ pub struct Config { /// peer's token requests during its handshake. Supports AUTH exactly when the /// version can negotiate it and this side offers it; the peer's SETUP decides whether /// it does. - pub auth: crate::auth::Handle, + pub auth: crate::auth::Link, /// Uni streams that arrived before that pre-read SETUP (see [`PeerSetup::early`]), /// classified by the session before it accepts any more. @@ -1475,7 +1475,7 @@ async fn read_goaways( /// Closes the auth handle when the session driver ends without finishing, releasing /// anything still waiting on a token. -struct AuthClose(crate::auth::Handle); +struct AuthClose(crate::auth::Link); impl Drop for AuthClose { fn drop(&mut self) { @@ -1487,7 +1487,7 @@ impl Drop for AuthClose { /// instead of leaving it to wait for a subscription that never comes. See /// [`crate::auth::Enforce`]. async fn enforce_grant( - auth: crate::auth::Handle, + auth: crate::auth::Link, origin: origin::Consumer, mut session: S, ) -> Result<(), Error> { @@ -1583,7 +1583,7 @@ mod tests { auth: true, ..Default::default() }), - auth: crate::auth::Handle::new(true), + auth: crate::auth::Link::new(true), early_unis: Vec::new(), extensions: Default::default(), }) @@ -1638,7 +1638,7 @@ mod tests { auth: true, ..Default::default() }), - auth: crate::auth::Handle::new(true), + auth: crate::auth::Link::new(true), early_unis: Vec::new(), extensions: Default::default(), }) @@ -1684,7 +1684,7 @@ mod tests { auth: true, ..Default::default() }), - auth: crate::auth::Handle::new(true), + auth: crate::auth::Link::new(true), early_unis: Vec::new(), extensions: Default::default(), }) @@ -1783,7 +1783,7 @@ mod tests { }, ..Default::default() }), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), early_unis: Vec::new(), extensions: Default::default(), }) @@ -1839,7 +1839,7 @@ mod tests { peer_setup_stream: None, // The requests wait on the peer's SETUP (MoQ Hidden). peer_declared: Some(peer::Peer::default()), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), early_unis: Vec::new(), extensions: Default::default(), }) @@ -1914,7 +1914,7 @@ mod tests { solicit, ..Default::default() }), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), early_unis: Vec::new(), extensions: Default::default(), }) @@ -1999,7 +1999,7 @@ mod tests { authority: None, peer_setup_stream: None, peer_declared, - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), early_unis: Vec::new(), extensions: Default::default(), }) @@ -2068,7 +2068,7 @@ mod tests { /// A publishing draft-18 session with AUTH available locally, against a peer that /// declared `auth`. Everything the session needs to keep running is held here. struct AuthSession { - handle: crate::auth::Handle, + handle: crate::auth::Link, log: crate::lite::test_transport::Log, _origin: crate::origin::Producer, _cam: crate::AnnounceProducer, @@ -2085,7 +2085,7 @@ mod tests { let session = crate::lite::test_transport::SinkSession::gated_bi(gate.consume()); let log = session.log.clone(); - let handle = crate::auth::Handle::ietf(true); + let handle = crate::auth::Link::ietf(true); let (driver, goaway) = start(Config { runtime: crate::time::sim(), session, @@ -2129,12 +2129,12 @@ mod tests { let session = auth_session(false); let (handle, log) = (&session.handle, &session.log); - let auth = moq_clock::timeout(std::time::Duration::from_secs(1), handle.add("token")) + let auth = moq_clock::timeout(std::time::Duration::from_secs(1), handle.handle().add("token")) .await .expect("resolved promptly") .expect("kept for requests"); assert_eq!(auth.grant().peek(), None, "no grant without the extension"); - assert_eq!(handle.grant().peek(), None, "no grant without the extension"); + assert_eq!(handle.handle().grant().peek(), None, "no grant without the extension"); for _ in 0..ANNOUNCE_TURNS { if occurrences(log, b"solo-cam") > 0 { @@ -2165,7 +2165,7 @@ mod tests { } /// What the draft-18 SETUP that `start` writes says about MoQ Auth and MoQ Solicit. - async fn our_setup(client: bool, auth: crate::auth::Handle, extensions: crate::setup::Extensions) -> (bool, bool) { + async fn our_setup(client: bool, auth: crate::auth::Link, extensions: crate::setup::Extensions) -> (bool, bool) { const VERSION: Version = Version::Draft18; let session = crate::lite::test_transport::ScriptedSession::new(Vec::new()); @@ -2216,13 +2216,13 @@ mod tests { no_solicit.solicit = false; // A client's handle supports AUTH exactly when it offers it. - assert_eq!(our_setup(true, crate::auth::Handle::new(true), all).await, (true, true)); + assert_eq!(our_setup(true, crate::auth::Link::new(true), all).await, (true, true)); assert_eq!( - our_setup(true, crate::auth::Handle::new(false), no_auth).await, + our_setup(true, crate::auth::Link::new(false), no_auth).await, (false, true) ); assert_eq!( - our_setup(true, crate::auth::Handle::new(true), no_solicit).await, + our_setup(true, crate::auth::Link::new(true), no_solicit).await, (true, false) ); } @@ -2233,11 +2233,11 @@ mod tests { async fn a_server_offers_auth_only_to_a_client_that_did() { let all = crate::setup::Extensions::default(); assert_eq!( - our_setup(false, crate::auth::Handle::new(true), all).await, + our_setup(false, crate::auth::Link::new(true), all).await, (true, true) ); assert_eq!( - our_setup(false, crate::auth::Handle::new(false), all).await, + our_setup(false, crate::auth::Link::new(false), all).await, (false, true) ); } @@ -2286,7 +2286,7 @@ mod tests { ..Default::default() }), // As `Server` builds it: offered by the client, so supported iff we offer it. - auth: crate::auth::Handle::new(offered), + auth: crate::auth::Link::new(offered), early_unis: Vec::new(), extensions: crate::setup::Extensions::default(), }) @@ -2373,7 +2373,7 @@ mod tests { // Pre-settled, so nothing waits on a SETUP the dead stream will never // carry and the dispatch loop actually runs. peer_declared: Some(peer::Peer::default()), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), early_unis: Vec::new(), extensions: Default::default(), }) @@ -2414,7 +2414,7 @@ mod tests { authority: Some(String::from_utf8(AUTHORITY.to_vec()).unwrap()), peer_setup_stream: None, peer_declared: Some(peer::Peer::default()), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), early_unis: Vec::new(), extensions: Default::default(), }) @@ -2681,7 +2681,7 @@ mod tests { authority: None, peer_setup_stream: None, peer_declared: Some(peer::Peer::default()), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), early_unis: Vec::new(), extensions: Default::default(), }) @@ -2824,7 +2824,7 @@ mod tests { authority: None, peer_setup_stream: None, peer_declared: Some(peer::Peer::default()), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), early_unis: Vec::new(), extensions: Default::default(), }) @@ -2891,7 +2891,7 @@ mod tests { authority: None, peer_setup_stream: None, peer_declared: Some(peer::Peer::default()), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), early_unis: Vec::new(), extensions: Default::default(), limits: Default::default(), @@ -2994,7 +2994,7 @@ mod tests { authority: None, peer_setup_stream: None, peer_declared: None, - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), early_unis: Vec::new(), extensions: Default::default(), }) @@ -3116,7 +3116,7 @@ mod tests { authority: None, peer_setup_stream: None, peer_declared: Some(peer::Peer::default()), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), early_unis: Vec::new(), extensions: Default::default(), }) @@ -3347,7 +3347,7 @@ mod tests { authority: None, peer_setup_stream: None, peer_declared: None, - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), early_unis: Vec::new(), extensions: Default::default(), }) @@ -3449,7 +3449,7 @@ mod tests { authority: None, peer_setup_stream: None, peer_declared: Some(peer::Peer::default()), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), early_unis: Vec::new(), extensions: Default::default(), }) diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index d590a411019..5e3d79bea57 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -753,7 +753,7 @@ pub(super) struct Subscriber { // fail requests that land before the replacement is up. going_away: crate::goaway::GoingAway, // Our grant (MoQ Auth): a subscription it stops covering is cancelled. - auth: crate::auth::Handle, + auth: crate::auth::Link, // What this session may allocate up front for objects still arriving. frames: frame::Budget, // Namespaces the peer may have announced at once (`session::Limits::announces`). @@ -847,7 +847,7 @@ where tasks, version, going_away, - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), frames: Default::default(), announces: Default::default(), validity: Default::default(), @@ -857,7 +857,7 @@ where /// Bound what we subscribe to by the grant this session's tokens earn (MoQ Auth), /// and what the peer may publish to us by the session's limit, as either changes. - pub fn with_auth(mut self, auth: crate::auth::Handle) -> Self { + pub fn with_auth(mut self, auth: crate::auth::Link) -> Self { self.auth = auth; let this = self.clone(); self.tasks.push(async move { this.run_limit().await }); @@ -6801,13 +6801,13 @@ mod tests { ) -> ( Subscriber, crate::lite::test_transport::Log, - crate::auth::Handle, + crate::auth::Link, Vec, crate::util::TaskSet, ) { let session = crate::lite::test_transport::ScriptedSession::new(Vec::new()); let log = session.log.clone(); - let auth = crate::auth::Handle::new(true); + let auth = crate::auth::Link::new(true); let setup = auth.present(bytes::Bytes::new(), true).unwrap(); auth.granted(setup.id(), crate::auth::Grant::default()); let unanswered = auth.present(bytes::Bytes::from_static(b"new"), false).unwrap(); @@ -9287,8 +9287,8 @@ mod tests { // Admitted by its own token, which holds the one slot for the request's life. let path = crate::Path::new("room/host").to_owned(); let hold: crate::auth::Hold = std::sync::Arc::new(subscriber.announces.acquire().unwrap()); - let auth = crate::auth::Handle::new(true); - let mut requests = auth.requests().unwrap(); + let auth = crate::auth::Link::new(true); + let mut requests = auth.handle().requests().unwrap(); let token = crate::setup::Token { kind: crate::setup::Token::OUT_OF_BAND, value: vec![0x00, 0xff], @@ -9746,8 +9746,8 @@ mod tests { let peer_setup = peer::PeerSetup::default(); peer_setup.set(peer::Peer::default()); - let auth = crate::auth::Handle::new(true); - let requests = auth.requests().unwrap(); + let auth = crate::auth::Link::new(true); + let requests = auth.handle().requests().unwrap(); let cred = auth.present(bytes::Bytes::from_static(b"cred"), true).unwrap(); auth.granted( 0, @@ -9758,7 +9758,7 @@ mod tests { }, ); // We let the peer announce only "other". - auth.authorize(&crate::auth::Grant { + auth.handle().authorize(&crate::auth::Grant { publish: crate::Pattern::subtree("other").unwrap().into(), subscribe: crate::Patterns::new(), expires: None, @@ -10062,7 +10062,7 @@ mod tests { #[moq_clock::test] async fn a_covering_peer_grant_never_skips_the_verifier() { const VERSION: Version = Version::Draft18; - let auth = crate::auth::Handle::new(true); + let auth = crate::auth::Link::new(true); let _cred = auth.present(bytes::Bytes::from_static(b"cred"), true).unwrap(); auth.granted(0, crate::auth::Grant::all()); let (mut subscriber, mut requests, consumer, session) = announce_harness_with(VERSION, auth); @@ -10115,8 +10115,8 @@ mod tests { subscribe: crate::Patterns::new(), expires: None, }; - let auth = crate::auth::Handle::new(false); - auth.authorize(&limit("room")); + let auth = crate::auth::Link::new(false); + auth.handle().authorize(&limit("room")); let (mut subscriber, mut requests, consumer, session) = announce_harness_with(VERSION, auth.clone()); let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); let consulted = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)); @@ -10153,7 +10153,7 @@ mod tests { "the verifier ran though the limit covers the announce" ); - auth.authorize(&limit("other")); + auth.handle().authorize(&limit("other")); for _ in 0..50 { let _ = futures::poll!(acceptor.as_mut()); assert!(futures::poll!(run.as_mut()).is_pending(), "the announce ended"); @@ -10170,8 +10170,8 @@ mod tests { #[moq_clock::test] async fn a_token_authorized_announce_is_exempt_from_the_limit() { const VERSION: Version = Version::Draft18; - let auth = crate::auth::Handle::new(false); - auth.authorize(&crate::auth::Grant::default()); + let auth = crate::auth::Link::new(false); + auth.handle().authorize(&crate::auth::Grant::default()); let (mut subscriber, mut requests, consumer, session) = announce_harness_with(VERSION, auth.clone()); let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); let acceptor = async move { @@ -10198,7 +10198,7 @@ mod tests { } assert!(routed, "the token admits the announce outside the limit"); - auth.authorize(&crate::auth::Grant { + auth.handle().authorize(&crate::auth::Grant { publish: crate::Pattern::subtree("other").unwrap().into(), subscribe: crate::Patterns::new(), expires: None, @@ -10219,8 +10219,8 @@ mod tests { #[moq_clock::test] async fn a_token_authorized_announce_admits_its_subscriptions() { const VERSION: Version = Version::Draft18; - let auth = crate::auth::Handle::new(false); - auth.authorize(&crate::auth::Grant::default()); + let auth = crate::auth::Link::new(false); + auth.handle().authorize(&crate::auth::Grant::default()); // The announce's peer says nothing; the subscription's accepts it. let subscribe_ok = { let log = crate::lite::test_transport::Log::default(); @@ -11023,9 +11023,9 @@ mod tests { origin::Consumer, crate::lite::test_transport::ScriptedSession, ) { - let auth = crate::auth::Handle::new(false); + let auth = crate::auth::Link::new(false); if let Some(limit) = &limit { - auth.authorize(limit); + auth.handle().authorize(limit); } announce_harness_with(version, auth) } @@ -11033,7 +11033,7 @@ mod tests { /// An announce harness over `auth`, set up by the caller, taking its requests. fn announce_harness_with( version: Version, - auth: crate::auth::Handle, + auth: crate::auth::Link, ) -> ( Subscriber, crate::auth::Requests, @@ -11047,7 +11047,7 @@ mod tests { /// An announce harness over `auth` and `session`, both set up by the caller. fn announce_harness_on( version: Version, - auth: crate::auth::Handle, + auth: crate::auth::Link, session: crate::lite::test_transport::ScriptedSession, ) -> ( Subscriber, @@ -11062,7 +11062,7 @@ mod tests { let peer_setup = peer::PeerSetup::default(); peer_setup.set(peer::Peer::default()); - let requests = auth.requests().unwrap(); + let requests = auth.handle().requests().unwrap(); let subscriber = Subscriber::new( crate::time::sim(), @@ -14956,7 +14956,7 @@ mod joining_fetch_tests { .iter() .any(|(id, _)| *id == ietf::Fetch::ID) ); - run.subscriber.auth.authorize(&crate::auth::Grant::default()); + run.subscriber.auth.handle().authorize(&crate::auth::Grant::default()); settle().await; let mut received = (&mut run._hold.2) .now_or_never() diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index 2850cf0f487..5c8a1f91ff3 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -43,7 +43,7 @@ pub(super) struct PublisherConfig { pub peer_hop: Option, /// Our tokens' grants, which bound what we announce and serve, and the app's /// claim on answering the peer's tokens. - pub auth: crate::auth::Handle, + pub auth: crate::auth::Link, /// What the default acceptor grants the peer's connection credential. pub peer_grant: crate::auth::Grant, /// The supervisor's estimate, which a PROBE stream follows. @@ -77,7 +77,7 @@ struct Shared { priority: PriorityQueue, version: Version, goaway: crate::goaway::Protocol, - auth: crate::auth::Handle, + auth: crate::auth::Link, peer_grant: crate::auth::Grant, // Control streams still serving the peer data, which a draining close waits for. owed: AtomicUsize, @@ -269,7 +269,7 @@ impl Publisher { announced: &mut announce::Consumer, self_origin: Hop, version: Version, - auth: Option, + auth: Option, ) -> Result<(), Error> { let mut run = AnnounceRun::new(crate::PathOwned::default(), self_origin, version); run.auth = auth.map(|handle| { @@ -2674,8 +2674,8 @@ mod announce_test { let mut run = AnnounceRun::new(crate::Path::new("").to_owned(), consumer.hop(), Version::Lite07); let waiter = kio::Waiter::noop(); assert!(run.poll(&mut stream, &consumer, &mut announced, &waiter).is_pending()); - let auth = crate::auth::Handle::new(false); - auth.authorize(&crate::auth::Grant { + let auth = crate::auth::Link::new(false); + auth.handle().authorize(&crate::auth::Grant { subscribe: crate::Pattern::literal("b").unwrap().into(), ..Default::default() }); @@ -2713,8 +2713,8 @@ mod announce_test { let mut run = AnnounceRun::new(crate::Path::new("").to_owned(), consumer.hop(), Version::Lite07); let waiter = kio::Waiter::noop(); assert!(run.poll(&mut stream, &consumer, &mut announced, &waiter).is_pending()); - let auth = crate::auth::Handle::new(false); - auth.authorize(&crate::auth::Grant::default()); + let auth = crate::auth::Link::new(false); + auth.handle().authorize(&crate::auth::Grant::default()); let Poll::Ready(restricted) = auth.poll_permit(crate::auth::Direction::Publish, &mut 0, &waiter) else { panic!("the changed grant is ready"); }; @@ -2928,7 +2928,7 @@ mod announce_test { } /// [`harness_on`], announcing under `auth`'s grant. - async fn harness_with(version: Version, auth: Option) -> Harness { + async fn harness_with(version: Version, auth: Option) -> Harness { let origin = Hop::new(1).unwrap().produce(); let announcement = origin .announce( @@ -3132,8 +3132,8 @@ mod announce_test { expires: None, }; for anonymous in [false, true] { - let auth = crate::auth::Handle::new(false); - auth.authorize(&grant(&["cam"])); + let auth = crate::auth::Link::new(false); + auth.handle().authorize(&grant(&["cam"])); let mut h = harness_with(Version::Lite07, Some(auth.clone())).await; // Same chain and cost, so only the instance changed. Both land before the loop polls. @@ -3144,7 +3144,7 @@ mod announce_test { None => h.origin.clone().peer().announce("cam", route), } .unwrap(); - auth.authorize(&grant(&["cam", "other"])); + auth.handle().authorize(&grant(&["cam", "other"])); moq_clock::sleep(crate::origin::DEFAULT_UPDATE_HOLD).await; settle().await; match h.wire.take_announces().as_slice() { @@ -6509,7 +6509,7 @@ mod tests { peer_setup, goaway, peer_hop: Some(assigned), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), peer_grant: Default::default(), client: false, subscriptions: Default::default(), @@ -6680,7 +6680,7 @@ mod tests { peer_setup: crate::lite::PeerSetup::new(false, crate::transport::Binding::InBand), goaway, peer_hop: None, - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), peer_grant: Default::default(), client: false, subscriptions: Default::default(), @@ -6979,7 +6979,7 @@ mod tests { peer_setup, goaway, peer_hop: None, - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), peer_grant: Default::default(), client: false, subscriptions: Default::default(), @@ -7114,7 +7114,7 @@ mod tests { peer_hop: None, subscriptions: Default::default(), fetches: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), peer_grant: Default::default(), client: false, }); @@ -7201,7 +7201,7 @@ mod tests { peer_setup, goaway, peer_hop: None, - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), peer_grant: Default::default(), client: false, subscriptions: Default::default(), @@ -7322,7 +7322,7 @@ mod tests { peer_hop: None, subscriptions: Default::default(), fetches: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), peer_grant: Default::default(), client: false, }); @@ -7483,7 +7483,7 @@ mod tests { peer_hop: None, subscriptions: crate::session::Slots::new(0), fetches: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), peer_grant: Default::default(), client: false, }); @@ -7522,7 +7522,7 @@ mod tests { peer_setup, goaway, peer_hop: None, - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), peer_grant: Default::default(), client: false, subscriptions: Default::default(), @@ -7589,7 +7589,7 @@ mod tests { peer_setup, goaway, peer_hop: None, - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), peer_grant: Default::default(), client: false, subscriptions: crate::session::Slots::new(0), diff --git a/rs/moq-net/src/lite/session.rs b/rs/moq-net/src/lite/session.rs index 3e671e20999..c0af2829995 100644 --- a/rs/moq-net/src/lite/session.rs +++ b/rs/moq-net/src/lite/session.rs @@ -21,7 +21,7 @@ pub(crate) struct SessionStart { /// The session-side GOAWAY halves, stored on the public [`crate::Session`]. pub goaway: crate::goaway::Handle, /// The session's AUTH tokens and grants, stored on the public [`crate::Session`]. - pub auth: crate::auth::Handle, + pub auth: crate::auth::Link, } /// Server: read the peer's single SETUP message off its Setup Stream before starting @@ -186,7 +186,7 @@ pub struct Config { /// The session's auth handle, created before [`start`] so a server can take the /// peer's token requests during its handshake. Supports AUTH exactly when `version` /// does. - pub auth: crate::auth::Handle, + pub auth: crate::auth::Link, /// What the peer may make this session hold. pub limits: crate::session::Limits, } @@ -472,7 +472,7 @@ struct Present { runtime: crate::time::Deadlines, session: S, version: Version, - handle: crate::auth::Handle, + handle: crate::auth::Link, going_away: crate::goaway::GoingAway, tokens: kio::Tasks>, /// Set by a token whose peer broke the protocol, which ends the session. @@ -519,7 +519,7 @@ struct PresentToken { runtime: crate::time::Deadlines, session: S, version: Version, - handle: crate::auth::Handle, + handle: crate::auth::Link, going_away: crate::goaway::GoingAway, violation: kio::Shared>, id: u64, @@ -529,6 +529,8 @@ struct PresentToken { enum PresentState { Open { token: bytes::Bytes }, Run { stream: Stream, answered: bool }, + /// Refused for a reason that may pass: present it again once the deadline fires. + Retry { deadline: crate::time::Deadline }, Done, } @@ -641,10 +643,29 @@ impl PresentToken { self.handle.refused(self.id); } *answered = true; - return Poll::Ready(err); + let Some(delay) = self.handle.retry(self.id, &err) else { + return Poll::Ready(err); + }; + tracing::debug!(?delay, "auth token refused transiently; presenting it again"); + let retry = PresentState::Retry { + deadline: crate::time::Deadline::after(&self.runtime, delay), + }; + if let PresentState::Run { stream, .. } = std::mem::replace(&mut self.state, retry) { + stream.writer.abort(&Error::Cancel); + } } } } + PresentState::Retry { deadline } => { + if self.handle.poll_withdrawn(self.id, waiter).is_ready() { + return Poll::Ready(Error::Cancel); + } + ready!(deadline.poll(waiter)); + let Some(token) = self.handle.reopen(self.id) else { + return Poll::Ready(Error::Cancel); + }; + self.state = PresentState::Open { token }; + } PresentState::Done => return Poll::Ready(Error::Cancel), } } @@ -922,7 +943,7 @@ mod tests { version: Version::Lite07, our_setup: Setup::default(), peer_setup: None, - auth: crate::auth::Handle::new(true), + auth: crate::auth::Link::new(true), }) .unwrap(); let mut driver = start.driver; @@ -954,7 +975,7 @@ mod tests { version: Version::Lite07, our_setup: Setup::default(), peer_setup: None, - auth: crate::auth::Handle::new(true), + auth: crate::auth::Link::new(true), }) .unwrap(); let mut driver = start.driver; @@ -1040,7 +1061,7 @@ mod tests { version: Version::Lite07, our_setup: Setup::default(), peer_setup: None, - auth: crate::auth::Handle::new(true), + auth: crate::auth::Link::new(true), }) .unwrap(); let mut driver = start.driver; @@ -1108,7 +1129,7 @@ mod tests { our_setup: Setup::default(), peer_setup: None, client: false, - auth: crate::auth::Handle::new(version.has_auth()), + auth: crate::auth::Link::new(version.has_auth()), limits: Default::default(), }) .unwrap(); @@ -1162,7 +1183,7 @@ mod tests { our_setup: Setup::default(), peer_setup: None, client: false, - auth: crate::auth::Handle::new(version.has_auth()), + auth: crate::auth::Link::new(version.has_auth()), limits: Default::default(), }) .unwrap(); @@ -1194,7 +1215,7 @@ mod tests { version, our_setup: Setup::default(), peer_setup, - auth: crate::auth::Handle::new(version.has_auth()), + auth: crate::auth::Link::new(version.has_auth()), limits: Default::default(), }) .unwrap() diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index 825dcbe86ec..841a33cbe77 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -42,7 +42,7 @@ pub(super) struct SubscriberConfig { /// [`crate::origin::Cost::DRAIN`], so a replacement session outranks it. pub going_away: crate::goaway::GoingAway, /// Our tokens' grants: a subscription the union stops covering is cancelled. - pub auth: crate::auth::Handle, + pub auth: crate::auth::Link, } #[derive(Clone)] @@ -83,7 +83,7 @@ pub(super) struct Subscriber { /// [`SourceServe`] machines. sources: kio::Queue, going_away: crate::goaway::GoingAway, - auth: crate::auth::Handle, + auth: crate::auth::Link, /// What this session may allocate up front for frames still arriving. frames: frame::Budget, /// Broadcasts the peer may have announced at once (`session::Limits::announces`). @@ -2434,7 +2434,7 @@ mod tests { peer_hop: None, cost: None, going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let track = track::Producer::new( std::sync::Arc::new(crate::broadcast::Info::default()), @@ -2489,7 +2489,7 @@ mod tests { peer_hop: None, cost: None, going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let mut track = track::Producer::new( @@ -2564,7 +2564,7 @@ mod tests { peer_hop: None, cost: None, going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let pool = crate::cache::Pool::unbounded(); let track = track::Producer::new( @@ -2723,7 +2723,7 @@ mod tests { peer_hop: None, cost: None, going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let track = track::Producer::new( @@ -2792,7 +2792,7 @@ mod tests { peer_hop: None, cost: None, going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let track = track::Producer::new( @@ -2857,7 +2857,7 @@ mod tests { peer_hop: None, cost: None, going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let serve = TrackServe { validity: Default::default(), @@ -2929,7 +2929,7 @@ mod tests { peer_hop: None, cost: None, going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let serve = TrackServe { validity: Default::default(), @@ -2976,7 +2976,7 @@ mod tests { peer_hop: None, cost: None, going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let serve = TrackServe { validity: Default::default(), @@ -3075,7 +3075,7 @@ mod tests { peer_hop: None, cost: None, going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let broadcast = crate::broadcast::Info::new().produce(); @@ -3142,7 +3142,7 @@ mod tests { peer_hop: None, cost: None, going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let broadcast = crate::broadcast::Info::new().produce(); @@ -3202,7 +3202,7 @@ mod tests { peer_hop: None, cost: None, going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let broadcast = crate::broadcast::Info::new().produce(); @@ -3244,7 +3244,7 @@ mod tests { /// holding its task and track until a token that may never be answered. #[moq_clock::test] async fn an_unused_track_stops_waiting_for_auth() { - let auth = crate::auth::Handle::new(true); + let auth = crate::auth::Link::new(true); let _token = auth.present(bytes::Bytes::from_static(b"new"), false).unwrap(); let subscriber = Subscriber::new(SubscriberConfig { runtime: crate::time::sim(), @@ -3315,7 +3315,7 @@ mod tests { peer_hop: None, cost: None, going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let subscribes = subscriber.subscribes.clone(); let serve = TrackServe { @@ -3370,7 +3370,7 @@ mod tests { // Writes park, so the SUBSCRIBE is on an open stream but not yet flushed. let gate = kio::Producer::new(false); let session = SinkSession::gated_bi(gate.consume()); - let auth = crate::auth::Handle::new(false); + let auth = crate::auth::Link::new(false); let origin = origin::Config::new(crate::Hop::new(1).unwrap()).produce(); let subscriber = Subscriber::new(SubscriberConfig { @@ -3410,7 +3410,7 @@ mod tests { assert!(kio::Task::poll(&mut running, &kio::Waiter::noop()).is_pending()); assert_eq!(session.log.bi_opens(), 1, "the SUBSCRIBE stream is open"); - auth.authorize(&crate::auth::Grant::default()); + auth.handle().authorize(&crate::auth::Grant::default()); assert!(kio::Task::poll(&mut running, &kio::Waiter::noop()).is_ready()); let unauthorized = crate::StreamError::Unauthorized.to_code(); @@ -3446,7 +3446,7 @@ mod tests { peer_hop: None, cost: None, going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let broadcast = crate::broadcast::Info::new().produce(); let producer = broadcast @@ -3515,7 +3515,7 @@ mod tests { peer_hop: None, cost: None, going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let serve = TrackServe { validity: Default::default(), @@ -3565,7 +3565,7 @@ mod tests { peer_hop: None, cost: None, going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let serve = TrackServe { validity: Default::default(), @@ -4076,7 +4076,7 @@ mod tests { cost: None, peer_hop: Some(assigned), going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let path = Path::new("room/host").to_owned(); @@ -4131,7 +4131,7 @@ mod tests { cost: None, peer_hop: Some(crate::Hop::new(777).unwrap()), going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let mut announced = Announced::default(); @@ -4185,7 +4185,7 @@ mod tests { cost: None, peer_hop: Some(crate::Hop::new(777).unwrap()), going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let path = Path::new("pool").to_owned(); let mut announced = Announced::default(); @@ -4254,7 +4254,7 @@ mod tests { cost: None, peer_hop: Some(crate::Hop::new(777).unwrap()), going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let mut announced = Announced::default(); subscriber @@ -4310,7 +4310,7 @@ mod tests { cost: None, peer_hop: Some(assigned), going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let path = Path::new("room/host").to_owned(); @@ -4369,7 +4369,7 @@ mod tests { cost: None, peer_hop: Some(assigned), going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let path = Path::new("room/host").to_owned(); @@ -4428,7 +4428,7 @@ mod tests { cost: None, peer_hop: Some(assigned), going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); // The sender's identity is already in the chain: the route came back through it. @@ -4495,7 +4495,7 @@ mod tests { cost: None, peer_hop: Some(assigned), going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); // The path as advertised to the peer: our hop is already in the chain. @@ -4555,7 +4555,7 @@ mod tests { peer_hop: Some(assigned), cost: None, going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); // An announce with an empty chain and no responder id: the versions that @@ -4602,7 +4602,7 @@ mod tests { cost: None, peer_hop: Some(assigned), going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let hops = crate::Hops::try_from(vec![crate::Hop::UNKNOWN, crate::Hop::UNKNOWN]).unwrap(); @@ -4684,7 +4684,7 @@ mod tests { cost: None, peer_hop: None, going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); (subscriber, consumer) } @@ -4752,7 +4752,7 @@ mod tests { let mut cursor = consumer.announced(); cursor.expect_next_active("room/host").await; - auth.authorize(&crate::auth::Grant { + auth.handle().authorize(&crate::auth::Grant { publish: Default::default(), subscribe: [crate::Pattern::subtree("other").unwrap()].into_iter().collect(), expires: None, @@ -4793,7 +4793,7 @@ mod tests { cost: None, peer_hop: None, going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let path = Path::new("room/host").to_owned(); @@ -4877,7 +4877,7 @@ mod tests { cost: Some(1), peer_hop: None, going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let mut prefix = AnnouncePrefix::new(subscriber, Path::new("").to_owned()); let mut cursor = consumer.announced(); @@ -4944,7 +4944,7 @@ mod tests { cost: Some(1), peer_hop: None, going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); (AnnouncePrefix::new(subscriber, Path::new("").to_owned()), consumer) } @@ -5115,7 +5115,7 @@ mod tests { cost: Some(1), peer_hop: None, going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let slots = crate::session::Slots::new(2); subscriber.announces = slots.clone(); diff --git a/rs/moq-net/src/model/origin.rs b/rs/moq-net/src/model/origin.rs index 607e23345ab..0f6e6e64166 100644 --- a/rs/moq-net/src/model/origin.rs +++ b/rs/moq-net/src/model/origin.rs @@ -467,14 +467,14 @@ pub struct Route { /// The credential the request advertising this route carries, from /// [`auth::Handle`](crate::auth::Handle)'s `add`, or `None` for the session's scope. /// - /// Only a session the auth was presented on carries it, as the token on its + /// Only a session built on the handle that issued it carries it, as the token on its /// moq-transport PUBLISH_NAMESPACE: any other session, the inline NAMESPACE answering a /// solicited SUBSCRIBE_NAMESPACE from draft 16 on (drafts 14 and 15 answer with a /// PUBLISH_NAMESPACE, which carries it), and moq-lite (which rides the session's scope) /// advertise the route unnamed. Swapping it is an update that renews the request with - /// REQUEST_UPDATE from draft 16, or ends and makes it again before. After a - /// reconnect, add the token on the new session and name that. Local, like - /// [`Self::source`]: a relay never forwards it. + /// REQUEST_UPDATE from draft 16, or ends and makes it again before. A handle passed to + /// `Client::with_auth` presents it on every session it backs, so the route keeps it + /// across a reconnect. Local, like [`Self::source`]: a relay never forwards it. pub auth: Option, /// The announcing session's declared or assigned identity. @@ -7283,7 +7283,7 @@ mod tests { #[moq_clock::test] #[cfg(feature = "async")] async fn an_auth_swap_is_an_update() { - let handle = crate::auth::Handle::ietf(false); + let handle = crate::auth::Link::ietf(false); let first = handle.present(bytes::Bytes::from_static(b"first"), false).unwrap(); let second = handle.present(bytes::Bytes::from_static(b"second"), false).unwrap(); let producer = origin(1).produce(); diff --git a/rs/moq-net/src/server.rs b/rs/moq-net/src/server.rs index 27f5c9fc705..fd8449193e5 100644 --- a/rs/moq-net/src/server.rs +++ b/rs/moq-net/src/server.rs @@ -150,7 +150,7 @@ impl Server { version: lite::Version, client_setup: Option>, peer_hop: Option, - auth: crate::auth::Handle, + auth: crate::auth::Link, ) -> Result<(Session, crate::Driver), Error> where S: crate::transport::poll::Session, @@ -294,7 +294,7 @@ impl Server { _ => return Err(Error::Version), }; - let auth = crate::auth::Handle::new(match &handshake { + let auth = crate::auth::Link::new(match &handshake { PausedHandshake::LiteBare { version, .. } | PausedHandshake::LiteSetup { version, .. } => { version.has_auth() } @@ -462,8 +462,8 @@ impl Server { // Lite 01/02 and moq-transport 14-16 carry no AUTH, though a moq-transport request // still carries a token. auth: match version { - Version::Ietf(_) => crate::auth::Handle::ietf(false), - Version::Lite(_) => crate::auth::Handle::new(false), + Version::Ietf(_) => crate::auth::Link::ietf(false), + Version::Lite(_) => crate::auth::Link::new(false), }, inner: Some(RequestInner { server: self.clone(), @@ -503,7 +503,7 @@ impl Server { assigned_hop: crate::Hop::random(), // The client's SETUP already settled whether it offered MoQ Auth, so it is // negotiated exactly when we offer it too. - auth: crate::auth::Handle::ietf(peer_setup.declared.auth && self.extensions.auth), + auth: crate::auth::Link::ietf(peer_setup.declared.auth && self.extensions.auth), inner: Some(RequestInner { server: self.clone(), runtime, @@ -535,7 +535,7 @@ pub struct Handshake { assigned_hop: crate::Hop, /// The session's auth handle, available before [`Handshake::ok`] so the caller can /// take the peer's token requests before any arrive. - auth: crate::auth::Handle, + auth: crate::auth::Link, // Taken by `ok`/`close`; `Drop` rejects the handshake if neither ran. inner: Option>, } @@ -584,7 +584,7 @@ trait Paused: MaybeSend + MaybeSync { server: Server, runtime: Deadlines, peer_hop: Option, - auth: crate::auth::Handle, + auth: crate::auth::Link, ) -> Accept; /// Reject the handshake, closing the transport with `err`'s wire code. @@ -611,7 +611,7 @@ where server: Server, runtime: Deadlines, peer_hop: Option, - auth: crate::auth::Handle, + auth: crate::auth::Link, ) -> Accept { use crate::util::MaybeBoxedExt as _; async move { @@ -691,7 +691,7 @@ where server: Server, runtime: Deadlines, peer_hop: Option, - auth: crate::auth::Handle, + auth: crate::auth::Link, ) -> Accept { use crate::util::MaybeBoxedExt as _; async move { @@ -849,7 +849,7 @@ where /// Take [`requests`](crate::auth::Handle::requests) here to answer the client's /// tokens yourself; the choice is fixed once the session's driver first runs. pub fn auth(&self) -> crate::auth::Handle { - self.auth.clone() + self.auth.handle().clone() } /// The credential the client presented in its SETUP's `AUTHORIZATION TOKEN` option. @@ -1512,7 +1512,7 @@ mod tests { origin: None, token: None, assigned_hop: Hop::random(), - auth: crate::auth::Handle::ietf(false), + auth: crate::auth::Link::ietf(false), inner: Some(RequestInner { server: Server::new().with_publisher(&origin), runtime: Deadlines::new(moq_clock::now()), diff --git a/rs/moq-net/src/session.rs b/rs/moq-net/src/session.rs index 09a0d80bac5..5a464269406 100644 --- a/rs/moq-net/src/session.rs +++ b/rs/moq-net/src/session.rs @@ -271,6 +271,9 @@ impl Session { /// The tokens this side presented and the grant they earned, plus the tokens /// the peer presents. See [`auth`]. /// + /// The handle passed to [`Client::with_auth`](crate::Client::with_auth), or else one + /// this session created for itself, which ends with it. + /// /// On moq-lite-07-wip, and on moq-transport draft-17+ when both sides negotiate the /// MoQ Auth extension, each side presents its connection's credential right after /// setup. Older versions, and peers that do not negotiate it, leave the grant `None`. @@ -283,7 +286,7 @@ impl Session { pub(super) struct Handles { pub stats: crate::stats::Session, pub goaway: goaway::Handle, - pub auth: auth::Handle, + pub auth: auth::Link, /// Where the driver publishes [`Estimate`] changes, for a protocol that reports /// them to the peer (moq-lite PROBE). Sampled only while a consumer exists. pub estimate: Option>, @@ -307,6 +310,8 @@ impl Session { estimate, stats: context, } = handles; + let goaway = Arc::new(goaway); + auth.drain_with(goaway.clone()); let wire = session.wire().map(|counters| context.wire(counters)); let sample = snapshot(&session); if let Some(estimate) = &estimate @@ -346,6 +351,7 @@ impl Session { sampled: None, mode: SamplerMode::Idle, drain: Drain::Idle, + auth: auth.clone(), }; let session = Self { @@ -355,8 +361,8 @@ impl Session { version, send_bandwidth, recv_bandwidth, - goaway: Arc::new(goaway), - auth, + goaway, + auth: auth.handle().clone(), }; let driver = crate::Driver::new( runtime.clone(), @@ -403,6 +409,9 @@ pub(crate) struct Supervisor { drain: Drain, /// The transport's wire counters, registered under the session's root; dropping records the last reading. wire: Option, + /// This session's side of its auth handle, closed before the end is published so a + /// replacement session built on the same handle never finds this one still active. + auth: auth::Link, } /// A [`Session::close`] waiting for the protocol to deliver what it queued. @@ -443,11 +452,10 @@ impl Supervisor { Drain::Done(res) => Some(res), _ => None, }; + let err = self.version.transport_error(err); + self.auth.close(err.clone()); if let Ok(mut closed) = self.closed.write() { - *closed = Some(Ended { - err: self.version.transport_error(err), - drain, - }); + *closed = Some(Ended { err, drain }); } return Poll::Ready(()); } diff --git a/rs/moq-net/tests/integration/auth.rs b/rs/moq-net/tests/integration/auth.rs index 8f915dfb52b..630b710bc1f 100644 --- a/rs/moq-net/tests/integration/auth.rs +++ b/rs/moq-net/tests/integration/auth.rs @@ -70,6 +70,8 @@ cases!( a_live_subscription_survives_union_renewal, a_grant_update_too_large_for_one_message_is_unsupported, nothing_outside_the_grant_reaches_the_peer, + a_shared_handle_presents_its_token_on_every_session, + a_handle_backs_one_active_session, ); /// Run each case on moq-transport alone, whose namespace prefixes cannot carry every @@ -91,6 +93,12 @@ prefix_cases!( a_grant_too_large_for_one_message_is_unsupported, ); +/// moq-transport's AUTH_ERROR carries no code that may pass, so only lite retries. +#[moq_clock::test] +async fn lite_07_presents_a_transiently_refused_token_again() { + a_transient_refusal_is_presented_again(LITE_07).await +} + #[moq_clock::test] async fn lite_07_carries_pattern_grants() { pattern_grants_arrive_exactly(LITE_07).await @@ -145,6 +153,16 @@ async fn moqt_22_route_auth_renews_its_announce() { a_route_auth_renews_its_announce(MOQT_22).await } +#[moq_clock::test] +async fn moqt_16_route_auth_survives_a_reconnect() { + a_route_auth_survives_a_reconnect("moq-transport-16").await +} + +#[moq_clock::test] +async fn moqt_22_route_auth_survives_a_reconnect() { + a_route_auth_survives_a_reconnect(MOQT_22).await +} + #[moq_clock::test] async fn moqt_22_renewing_a_cancelled_announce_keeps_the_session() { within(async { @@ -280,24 +298,63 @@ async fn moqt_22_a_revoked_route_auth_remakes_its_announce() { /// request, and the server declines MoQ Solicit, so the client announces with /// PUBLISH_NAMESPACE rather than answering a SUBSCRIBE_NAMESPACE. async fn connect_route_auth(version: &'static str) -> (Pair, origin::Producer, origin::Producer) { + let client_origin = produce_origin(2); + let server_origin = produce_origin(1); + let pair = connect_route_auth_on(version, &client_origin, &server_origin, None).await; + (pair, client_origin, server_origin) +} + +/// [`connect_route_auth`] between existing origins, optionally on a shared client handle. +async fn connect_route_auth_on( + version: &'static str, + client_origin: &origin::Producer, + server_origin: &origin::Producer, + client_auth: Option, +) -> Pair { let declined = |f: fn(&mut moq_net::setup::Extensions)| { let mut extensions = moq_net::setup::Extensions::default(); f(&mut extensions); Some(extensions) }; - let client_origin = produce_origin(2); - let server_origin = produce_origin(1); - let pair = connect(Options { + connect(Options { version: Some(version), client_publish: Some(client_origin.clone()), server_subscribe: Some(server_origin.clone()), server_requests: true, client_extensions: declined(|e| e.auth = false), server_extensions: declined(|e| e.solicit = false), + client_auth, ..Default::default() }) - .await; - (pair, client_origin, server_origin) + .await +} + +/// A route naming a shared handle's auth carries it on the next session too, with no +/// re-add or re-announce from the application. +async fn a_route_auth_survives_a_reconnect(version: &'static str) { + within(async { + let handle = auth::Handle::new(); + let client_origin = produce_origin(2); + let server_origin = produce_origin(1); + let mut first = connect_route_auth_on(version, &client_origin, &server_origin, Some(handle.clone())).await; + let mut tokens = serve(first.requests.take().unwrap(), |_| Some(Grant::all())); + let token = handle.add("first").await.expect("kept for requests"); + let broadcast = client_origin.create_broadcast("room/x").unwrap(); + broadcast.announce(origin::Route::default().with_auth(token)).unwrap(); + let (token, _first) = tokens.next().await.expect("the announce's token"); + assert_eq!(token, b"first"); + + first.server.abort(Error::Cancel); + first.client.closed().await; + + let mut second = connect_route_auth_on(version, &client_origin, &server_origin, Some(handle)).await; + let mut tokens = serve(second.requests.take().unwrap(), |_| Some(Grant::all())); + let (token, _second) = tokens.next().await.expect("the announce's token on the new session"); + assert_eq!(token, b"first"); + wait_announced(&server_origin.consume(), "room/x", true).await; + }) + .await + .expect("timed out"); } /// A route's auth reaches the peer's acceptor on the announcement, and swapping it @@ -402,6 +459,8 @@ struct Options { client_extensions: Option, server_extensions: Option, version: Option<&'static str>, + /// The handle the client builds its session on, when not its own. + client_auth: Option, } struct Pair { @@ -430,6 +489,9 @@ async fn connect(opts: Options) -> Pair { if let Some(subscribe) = opts.client_subscribe { client = client.with_subscriber(subscribe); } + if let Some(handle) = opts.client_auth { + client = client.with_auth(handle); + } let mut server = Server::new().with_versions(version.into()); if let Some(extensions) = opts.server_extensions { @@ -889,7 +951,7 @@ async fn dropping_the_requests_refuses_queued_tokens(version: &'static str) { .expect("timed out"); } -/// A closed session holds no grant: every token ended with it. +/// A closed session holds no grant: no session is live, and every token ended with it. async fn a_closed_session_holds_no_grant(version: &'static str) { within(async { let pair = connect(Options { @@ -903,7 +965,7 @@ async fn a_closed_session_holds_no_grant(version: &'static str) { let token = pair.client.auth().grant(); pair.server.abort(Error::Cancel); - wait_for(token, |g| g == &Some(Grant::default())).await; + wait_for(token, Option::is_none).await; }) .await .expect("timed out"); @@ -1782,3 +1844,130 @@ async fn a_widening_brings_back_what_the_peer_published(version: &'static str) { .await .expect("timed out"); } + +/// A handle passed to the client presents its token on every session it backs: added before +/// any session, granted on the first, cleared while disconnected, and granted again on the +/// next, without the token ending in between. +async fn a_shared_handle_presents_its_token_on_every_session(version: &'static str) { + within(async { + let handle = auth::Handle::new(); + let added = moq_clock::sim::spawn({ + let handle = handle.clone(); + async move { handle.add("token").await } + }); + let options = || Options { + version: Some(version), + client_publish: Some(produce_origin(2)), + server_requests: true, + client_auth: Some(handle.clone()), + ..Default::default() + }; + + let mut first = connect(options()).await; + let mut tokens = serve(first.requests.take().unwrap(), |token| match token { + b"" => Some(Grant::default()), + _ => Some(grant(&["first"], &[])), + }); + let token = added.await.expect("joined").expect("granted on the first session"); + let _first = presented(&mut tokens).await; + wait_for(token.grant(), |g| g == &Some(grant(&["first"], &[]))).await; + + first.server.abort(Error::Cancel); + first.client.closed().await; + assert_eq!(token.grant().peek(), None, "no session is live"); + assert!(futures::FutureExt::now_or_never(token.closed()).is_none(), "a session ending is no verdict"); + + let mut second = connect(options()).await; + let mut tokens = serve(second.requests.take().unwrap(), |token| match token { + b"" => Some(Grant::default()), + _ => Some(grant(&["second"], &[])), + }); + let _second = presented(&mut tokens).await; + wait_for(token.grant(), |g| g == &Some(grant(&["second"], &[]))).await; + wait_for(handle.grant(), |g| g == &Some(grant(&["second"], &[]))).await; + }) + .await + .expect("timed out"); +} + +/// The setup token and the handle's token, in either order, once both are presented. +async fn presented( + tokens: &mut futures::channel::mpsc::UnboundedReceiver<(Vec, auth::Issued)>, +) -> Vec<(Vec, auth::Issued)> { + let mut both = vec![ + tokens.next().await.expect("a token"), + tokens.next().await.expect("another token"), + ]; + both.sort_by(|a, b| a.0.cmp(&b.0)); + assert_eq!(both[0].0, b""); + assert_eq!(both[1].0, b"token"); + both +} + +/// A handle backs one active session: another fails loudly until the first drains. +async fn a_handle_backs_one_active_session(version: &'static str) { + within(async { + let handle = auth::Handle::new(); + let first = connect(Options { + version: Some(version), + client_auth: Some(handle.clone()), + ..Default::default() + }) + .await; + + let parsed: Version = version.parse().unwrap(); + let mut mock = moq_net::transport::memory::Config::default(); + mock.protocol = Some(parsed.alpn().to_string()); + let (transport, _peer) = create_mock_session_pair(mock); + let concurrent = Client::new() + .with_versions(parsed.into()) + .with_auth(handle.clone()) + .connect(now(), transport) + .await; + assert!(matches!(concurrent, Err(Error::Duplicate)), "{:?}", concurrent.err()); + + first.server.drain().send(moq_net::goaway::Goaway::new()).unwrap(); + first.client.draining().recv().await.expect("the GOAWAY"); + let _second = connect(Options { + version: Some(version), + client_auth: Some(handle), + ..Default::default() + }) + .await; + }) + .await + .expect("timed out"); +} + +/// A refusal that may pass, such as an overloaded verifier, presents the token again +/// after a backoff rather than ending it. +async fn a_transient_refusal_is_presented_again(version: &'static str) { + within(async { + let mut pair = connect(Options { + version: Some(version), + client_publish: Some(produce_origin(2)), + server_requests: true, + ..Default::default() + }) + .await; + let mut requests = pair.requests.take().unwrap(); + let (tx, mut issued) = futures::channel::mpsc::unbounded(); + moq_clock::sim::spawn(async move { + let mut refused = false; + while let Some(request) = requests.next().await { + if request.token().is_empty() || refused { + let _ = tx.unbounded_send(request.accept(Grant::default())); + } else { + refused = true; + request.reject(SessionError::TooManyRequests, "busy"); + } + } + }); + let token = pair.client.auth().add("token").await.expect("granted on the retry"); + assert_eq!(token.grant().peek(), Some(Grant::default())); + let _setup = issued.next().await; + let _token = issued.next().await; + }) + .await + .expect("timed out"); +} From 74995f38adcb032b3feece06a16551b3db5d3829 Mon Sep 17 00:00:00 2001 From: Luke Curley <432854+kixelated@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:32:32 -0700 Subject: [PATCH 04/31] wip(moq-tokio): build every connection session on one moq-net auth handle Co-Authored-By: Claude Opus 5.5 --- rs/moq-tokio/src/client.rs | 2 +- rs/moq-tokio/src/connection.rs | 108 ++++- rs/moq-tokio/src/connection/auth.rs | 721 ---------------------------- 3 files changed, 89 insertions(+), 742 deletions(-) delete mode 100644 rs/moq-tokio/src/connection/auth.rs diff --git a/rs/moq-tokio/src/client.rs b/rs/moq-tokio/src/client.rs index 446c9f91712..c1f6de77ed7 100644 --- a/rs/moq-tokio/src/client.rs +++ b/rs/moq-tokio/src/client.rs @@ -41,7 +41,7 @@ impl Config { /// Create via [`crate::connect::Config::init`] or [`Client::new`]. #[derive(Clone)] pub struct Client { - moq: moq_net::Client, + pub(crate) moq: moq_net::Client, /// The single resolved set of protocol versions, used to advertise moq ALPNs across /// every transport (passed into the QUIC backend's `connect` and used directly for /// raw TCP/UDS qmux and WebSocket). Resolved once in [`Client::new`] so the ALPN list diff --git a/rs/moq-tokio/src/connection.rs b/rs/moq-tokio/src/connection.rs index 623d443de72..3be71111588 100644 --- a/rs/moq-tokio/src/connection.rs +++ b/rs/moq-tokio/src/connection.rs @@ -1,7 +1,5 @@ //! The reconnect loop around a session, and the [`Monitor`] that observes it. -pub mod auth; - use std::task::{Poll, ready}; use std::time::Duration; @@ -462,8 +460,6 @@ impl Resolved { /// distinguishes a permanent give-up from a graceful close. #[derive(Default)] struct State { - /// Tokens to queue before publishing a replacement session to its consumers. - tokens: std::sync::Mutex>>, /// Current connection status, or `None` before the first connect. status: Option, /// How many sessions have been live on this handle, counting the current one. @@ -502,6 +498,8 @@ struct Shared { send_bw: BandwidthProducer, recv_bw: BandwidthProducer, closed: CloseGuard, + /// The auth handle every session is built on, ended with the loop. + auth: moq_net::auth::Handle, } impl Shared { @@ -515,7 +513,6 @@ impl Shared { return; } if let Ok(mut state) = self.state.write() { - auth::replay(&state, session, state.connects + 1); // A migration replaces a live session without a disconnect in between, so // its end is counted here, keeping `started - ended` at 1 while connected. if state.session.is_some() { @@ -589,9 +586,20 @@ impl Drop for Shared { // is what closes the transport. self.disconnected(); // Nothing retries anymore, however the loop ended. - if let Ok(mut state) = self.state.write() { - state.failure = None; - } + let failed = match self.state.write() { + Ok(mut state) => { + state.failure = None; + state.error.clone() + } + Err(state) => state.error.clone(), + }; + // No session will present the tokens again, so they end with the loop. + let aborted = self.closed.lock().unwrap().clone(); + self.auth.abort(match (aborted, failed) { + (Some(err), _) | (None, Some(Error::MoqNet(err))) => err, + (None, Some(err)) if err.is_auth() => moq_net::Error::Unauthorized, + _ => moq_net::Error::Cancel, + }); } } @@ -689,8 +697,8 @@ pub struct Snapshot { pub struct Connection { task: std::sync::Arc, state: kio::Consumer, - /// The reconnect pacing, which retained tokens reuse to retry a transient refusal. - pacing: Pacing, + /// The auth handle every session of the loop is built on. + auth: moq_net::auth::Handle, /// Persistent send-bitrate estimate, fed by the loop from each live session. send_bandwidth: BandwidthConsumer, /// Persistent recv-bitrate estimate, fed by the loop from each live session. @@ -718,10 +726,11 @@ struct Task { type CloseGuard = std::sync::Arc>>; impl Connection { - pub(crate) fn new(client: Client, addrs: Addrs) -> Self { + pub(crate) fn new(mut client: Client, addrs: Addrs) -> Self { let producer = kio::Producer::::default(); let state = producer.consume(); - let pacing = Pacing::new(&client.backoff); + let auth = moq_net::auth::Handle::new(); + client.moq = client.moq.with_auth(auth.clone()); // The loop feeds these across every reconnect, so a consumer's handle survives session churn // (unlike a session's own bandwidth consumer, which dies with the session). @@ -732,6 +741,7 @@ impl Connection { let closed: CloseGuard = Default::default(); let task_closed = closed.clone(); + let task_auth = auth.clone(); let task = tokio::spawn(async move { let reconnect = client.reconnect; @@ -740,6 +750,7 @@ impl Connection { send_bw, recv_bw, closed: task_closed, + auth: task_auth, }; // Isolated inside `shared`, so a panic still records a terminal error // instead of closing the channels as if the caller had stopped it. @@ -763,7 +774,7 @@ impl Connection { closed, }), state, - pacing, + auth, send_bandwidth, recv_bandwidth, last_reported: None, @@ -1256,13 +1267,12 @@ impl Connection { kio::wait(|waiter| self.poll_closed(waiter)).await } - /// Present tokens that survive session replacement and observe the live grant union. - /// A route names a session's own `moq_net::auth::Auth` instead, re-added after a reconnect. - pub fn auth(&self) -> auth::Handle { - auth::Handle { - connection: self.state.clone(), - pacing: self.pacing, - } + /// The auth handle every session of this connection is built on: its tokens are + /// presented on each reconnect, so a route naming one keeps it, and its grant follows + /// the live session. It ends with the connection: [`Error::MoqNet`]'s error, or + /// [`moq_net::Error::Unauthorized`] for a rejection, or [`moq_net::Error::Cancel`]. + pub fn auth(&self) -> moq_net::auth::Handle { + self.auth.clone() } /// Observe the connection's statistics and presence across reconnects. @@ -2148,6 +2158,7 @@ mod tests { send_bw: BandwidthProducer::new(), recv_bw: BandwidthProducer::new(), closed: CloseGuard::default(), + auth: moq_net::auth::Handle::new(), } } @@ -2391,6 +2402,63 @@ mod tests { assert!(monitor.failure().is_none(), "nothing is retrying"); } + /// The connection's auth handle presents its token on every session the loop makes, + /// without the application adding it again. + #[cfg(feature = "tcp")] + #[tokio::test] + async fn a_token_is_presented_on_every_session() { + const WAIT: Duration = Duration::from_secs(10); + let version: moq_net::Version = "moq-lite-07-wip".parse().unwrap(); + + let mut config = crate::listen::Config::default(); + config.tcp.bind = Some("127.0.0.1:0".parse().unwrap()); + config.version = vec![version]; + let mut listener = config.init(Default::default()).unwrap().listen().await.unwrap(); + let addr = listener.tcp_local_addr().unwrap(); + + let mut config = crate::connect::Config::default(); + config.version = vec![version]; + config.backoff.initial = Some(MIN_BACKOFF); + config.backoff.max = Some(MIN_BACKOFF); + let connection = config.init(Default::default()).unwrap().connect(url(&format!("tcp://{addr}/"))); + let auth = connection.auth(); + let added = tokio::spawn({ + let auth = auth.clone(); + async move { auth.add("token").await } + }); + + let mut token = None; + for round in 0..2 { + let request = tokio::time::timeout(WAIT, listener.accept()).await.unwrap().unwrap(); + let mut requests = request.auth().requests().unwrap(); + let (session, _task) = request.ok().await.unwrap(); + let _issued = loop { + let request = tokio::time::timeout(WAIT, requests.next()).await.unwrap().unwrap(); + match request.token().as_ref() { + b"" => std::mem::forget(request.accept(moq_net::auth::Grant::default())), + token => { + assert_eq!(token, b"token", "round {round}"); + break request.accept(moq_net::auth::Grant::all()); + } + } + }; + let token = match token.take() { + Some(token) => token, + None => added.await.unwrap().expect("granted"), + }; + let mut grant = token.grant(); + while grant.peek() != Some(moq_net::auth::Grant::all()) { + tokio::time::timeout(WAIT, grant.changed()).await.unwrap().unwrap(); + } + session.abort(moq_net::Error::Cancel); + token = Some(token); + } + drop(connection); + let token = token.unwrap(); + let closed = tokio::time::timeout(WAIT, token.closed()).await.unwrap(); + assert!(matches!(closed, moq_net::Error::Cancel), "{closed:?}"); + } + /// A session that ends the loop reports through `closed`, not as a retry reason. #[cfg(feature = "tcp")] #[tokio::test] diff --git a/rs/moq-tokio/src/connection/auth.rs b/rs/moq-tokio/src/connection/auth.rs deleted file mode 100644 index f6e5a194b24..00000000000 --- a/rs/moq-tokio/src/connection/auth.rs +++ /dev/null @@ -1,721 +0,0 @@ -//! Tokens and grant watches that follow a reconnecting connection. - -use std::{ - sync::{Arc, Mutex}, - task::{Poll, ready}, -}; - -use bytes::Bytes; -use futures::FutureExt; -use moq_net::{auth, kio}; - -use super::{Pacing, State, terminal}; -use crate::{Error, abort::AbortOnDrop}; - -/// Present session tokens across every session of a connection. -#[derive(Clone)] -pub struct Handle { - pub(super) connection: kio::Consumer, - pub(super) pacing: Pacing, -} - -impl Handle { - /// Watch the current session's union, clearing it while disconnected. - pub fn grant(&self) -> Watch { - Watch { - source: Source::Connection(self.connection.clone()), - last: None, - } - } - - /// Present a token now and on later sessions until its handle is dropped or refused. - pub async fn add(&self, token: impl Into) -> crate::Result { - let producer = kio::Producer::::default(); - let state = producer.consume(); - let connection = self.connection.clone(); - let pacing = self.pacing; - let token = token.into(); - let replay = Arc::new(Replay { - bytes: token, - queued: Mutex::new(Vec::new()), - }); - { - let state = connection.read(); - let mut tokens = state.tokens.lock().unwrap(); - tokens.retain(|token| token.strong_count() > 0); - if let Some(session) = &state.session { - replay.queue(session, state.connects); - } - tokens.push(Arc::downgrade(&replay)); - } - let (answered, answer) = tokio::sync::oneshot::channel(); - let task = AbortOnDrop::new(tokio::spawn(async move { - let mut answered = Some(answered); - if let Err(err) = present(connection, pacing, replay, &producer, &mut answered).await { - if let Some(answered) = answered.take() { - let _ = answered.send(Err(err.clone())); - } - if let Ok(mut state) = producer.write() { - state.grant = None; - state.error = Some(err); - } - } - })); - answer.await.map_err(|_| Error::from(moq_net::Error::Cancel))??; - Ok(Auth { _task: task, state }) - } -} - -type Opening = futures::future::BoxFuture<'static, moq_net::Result>; - -/// Registration shared with session publication, while the task owns its token's life. -pub(super) struct Replay { - bytes: Bytes, - /// Registrations queued as sessions are published, until the task takes them. - queued: Mutex>, -} - -struct Queued { - connects: u64, - session: moq_net::Session, - opening: Opening, -} - -impl Replay { - fn queue(&self, session: &moq_net::Session, connects: u64) { - let handle = session.auth(); - let bytes = self.bytes.clone(); - let mut opening = async move { handle.add(bytes).await }.boxed(); - // The first poll registers the token synchronously, before the session can be - // observed. The replay task replaces this noop waker when it takes the future. - if let Some(result) = opening.as_mut().now_or_never() { - opening = futures::future::ready(result).boxed(); - } - self.queued.lock().unwrap().push(Queued { - connects, - session: session.clone(), - opening, - }); - } - - /// Take the registration queued for session `connects`, moving older ones to `draining`. - fn take(&self, connects: u64, draining: &mut Draining) -> Option { - let mut queued = self.queued.lock().unwrap(); - let mut current = None; - for entry in std::mem::take(&mut *queued) { - match entry.connects.cmp(&connects) { - // Requests on a draining session may still be waiting on this answer. - std::cmp::Ordering::Less => draining.push(entry.session, entry.opening), - std::cmp::Ordering::Equal => current = Some(entry.opening), - // A replacement published since the task's snapshot already holds this token. - // Dropping its registration would withdraw the token from a session consumers - // can see, so leave it for the task's next pass. - std::cmp::Ordering::Greater => queued.push(entry), - } - } - current - } -} - -/// Tokens kept on sessions draining after a migration. Each is held by a task that -/// releases it, and its session, once that session ends; dropping this withdraws them all. -#[derive(Default)] -struct Draining(Vec); - -impl Draining { - /// Keep `held`, a token answered or not, registered on `session` until it ends. - fn push(&mut self, session: moq_net::Session, held: impl Send + 'static) { - self.0.retain(|task| !task.is_finished()); - self.0.push(AbortOnDrop::new(tokio::spawn(async move { - session.closed().await; - drop(held); - }))); - } -} - -pub(super) fn replay(state: &State, session: &moq_net::Session, connects: u64) { - state.tokens.lock().unwrap().retain(|token| { - let Some(token) = token.upgrade() else { - return false; - }; - token.queue(session, connects); - true - }); -} - -/// A presented token; dropping it withdraws it from live and draining sessions. -pub struct Auth { - _task: AbortOnDrop, - state: kio::Consumer, -} - -impl Auth { - /// Watch this token's grant on the current session. - pub fn grant(&self) -> Watch { - Watch { - source: Source::Auth(self.state.clone()), - last: None, - } - } - - /// Wait until the token is refused or its connection ends. - pub async fn closed(&self) -> crate::Result<()> { - kio::wait(|waiter| self.state.poll_closed(waiter)).await; - match &self.state.read().error { - Some(err) => Err(err.clone()), - None => Ok(()), - } - } -} - -#[derive(Default)] -struct Granted { - grant: Option, - error: Option, -} - -#[derive(Clone)] -enum Source { - Connection(kio::Consumer), - Auth(kio::Consumer), -} - -/// Observe a grant as tokens change and the connection replaces its session. -#[derive(Clone)] -pub struct Watch { - source: Source, - last: Option, -} - -impl Watch { - /// The current grant, or none while disconnected or after the token ends. - pub fn peek(&self) -> Option { - match &self.source { - Source::Connection(connection) => { - if connection.poll_closed(&kio::Waiter::noop()).is_ready() { - return None; - } - connection - .read() - .session - .as_ref() - .and_then(|session| session.auth().grant().peek()) - } - Source::Auth(state) => { - if state.poll_closed(&kio::Waiter::noop()).is_ready() { - return None; - } - state.read().grant.clone() - } - } - } - - /// Poll for a different grant or the end of its owner. - pub fn poll_changed(&mut self, waiter: &kio::Waiter) -> Poll>> { - let result = match &self.source { - Source::Connection(connection) => match ready!(connection.poll(waiter, |state| { - let mut watch = state.session.as_ref().map(|session| session.auth().grant()); - let current = watch.as_ref().and_then(auth::Watch::peek); - if current != self.last { - return Poll::Ready(current); - } - if let Some(watch) = &mut watch - && let Poll::Ready(Ok(grant)) = watch.poll_changed(waiter) - { - return Poll::Ready(grant); - } - Poll::Pending - })) { - Ok(grant) => Ok(grant), - Err(state) => Err(terminal(&state)), - }, - Source::Auth(state) => match ready!(state.poll(waiter, |state| { - if state.grant != self.last { - Poll::Ready(state.grant.clone()) - } else { - Poll::Pending - } - })) { - Ok(grant) => Ok(grant), - Err(state) => Err(state.error.clone().unwrap_or_else(|| moq_net::Error::Cancel.into())), - }, - }; - if let Ok(grant) = &result { - self.last = grant.clone(); - } - Poll::Ready(result) - } - - /// Wait for a different grant or the end of its owner. - pub async fn changed(&mut self) -> crate::Result> { - kio::wait(|waiter| self.poll_changed(waiter)).await - } -} - -async fn next( - connection: &kio::Consumer, - previous: Option<(u64, bool)>, -) -> crate::Result<(u64, Option)> { - kio::wait(|waiter| { - match ready!(connection.poll(waiter, |state| { - if Some((state.connects, state.session.is_some())) != previous { - Poll::Ready((state.connects, state.session.clone())) - } else { - Poll::Pending - } - })) { - Ok(session) => Poll::Ready(Ok(session)), - Err(state) => Poll::Ready(Err(terminal(&state))), - } - }) - .await -} - -/// Whether a refusal is the acceptor failing to decide rather than its verdict on -/// the token, such as an unreachable auth server or a full verification queue. -/// moq-transport's AUTH_ERROR carries neither, so there every refusal is final. -fn transient(err: &moq_net::Error) -> bool { - matches!( - err, - moq_net::Error::Session( - moq_net::SessionError::Internal | moq_net::SessionError::TooManyRequests | moq_net::SessionError::Timeout - ) - ) -} - -async fn present( - connection: kio::Consumer, - pacing: Pacing, - replay: Arc, - producer: &kio::Producer, - answered: &mut Option>>, -) -> crate::Result<()> { - let mut snapshot = next(&connection, None).await?; - let mut draining = Draining::default(); - 'session: loop { - let (connects, session) = snapshot; - let Some(session) = session else { - if let Ok(mut state) = producer.write() { - state.grant = None; - } - snapshot = next(&connection, Some((connects, false))).await?; - continue; - }; - if let Ok(mut state) = producer.write() { - state.grant = None; - } - let mut delay = None; - let mut opening = replay.take(connects, &mut draining); - let token = loop { - let attempt = async { - if opening.is_none() { - if let Some(delay) = delay { - tokio::time::sleep(delay).await; - } - let handle = session.auth(); - let bytes = replay.bytes.clone(); - opening = Some(async move { handle.add(bytes).await }.boxed()); - } - opening.as_mut().expect("opening").await - }; - let result = tokio::select! { - biased; - changed = next(&connection, Some((connects, true))) => Err(changed), - result = attempt => Ok(result), - }; - let result = match result { - Ok(result) => result, - Err(changed) => { - // Requests on a draining session may still be waiting on this answer. - if let Some(opening) = opening.take() { - draining.push(session, opening); - } - snapshot = changed?; - continue 'session; - } - }; - opening = None; - match result { - Ok(token) => break token, - // The session ending is no verdict on the token: replay it on the next one. - Err(_) if session.closed().now_or_never().is_some() => { - snapshot = next(&connection, Some((connects, true))).await?; - continue 'session; - } - // Nor is an acceptor that could not decide: present it again, paced like a - // reconnect. Only an accepted grant counts, so a retry never extends one. - Err(err) if transient(&err) => { - let wait = delay.map_or(pacing.initial, |delay| pacing.next(delay)); - tracing::debug!(%err, ?wait, "token refused transiently; presenting it again"); - delay = Some(wait); - } - Err(err) => return Err(err.into()), - } - }; - if let Some(answered) = answered.take() { - let _ = answered.send(Ok(())); - } - let mut watch = token.grant(); - if let Ok(mut state) = producer.write() { - state.grant = watch.peek(); - } - loop { - tokio::select! { - biased; - changed = next(&connection, Some((connects, true))) => { snapshot = changed?; break; } - _ = session.closed() => { - if let Ok(mut state) = producer.write() { state.grant = None; } - snapshot = next(&connection, Some((connects, true))).await?; - break; - } - grant = watch.changed() => match grant { - Ok(grant) => { - if let Ok(mut state) = producer.write() { state.grant = grant; } - } - // The stream ended with its session, not by the peer's verdict. - Err(_) if session.closed().now_or_never().is_some() => { - if let Ok(mut state) = producer.write() { state.grant = None; } - snapshot = next(&connection, Some((connects, true))).await?; - break; - } - Err(err) => return Err(err.into()), - } - } - } - // A migrated session may still be serving its streams during GOAWAY drain. - // Keep its grant until that session ends or the application drops this token. - draining.push(session, token); - } -} - -#[cfg(all(test, feature = "tcp"))] -mod tests { - use super::*; - - struct Pair { - client: moq_net::Session, - requests: tokio::sync::mpsc::UnboundedReceiver, - _tasks: Vec>, - _acceptor: AbortOnDrop, - } - - async fn pair() -> Pair { - let versions = moq_net::Versions::from("moq-lite-07-wip".parse::().unwrap()); - let config = || { - let mut config = qmux::Config::new(qmux::Version::QMux01); - config.protocol = qmux::Protocol::Negotiate(versions.alpns().iter().map(|alpn| alpn.to_string()).collect()); - config - }; - let stream = |io| qmux::transport::Stream::new(io, qmux::Version::QMux01, config().max_record_size); - let (client, server) = tokio::io::duplex(64 * 1024); - let (client, server) = tokio::try_join!( - qmux::Session::connect(stream(client), config()), - qmux::Session::accept(stream(server), config()) - ) - .unwrap(); - let now = moq_net::time::now(); - let client = async { - let (session, driver) = moq_net::Client::new() - .with_versions(versions.clone()) - .connect( - now, - crate::transport::Session::new(client, moq_net::transport::Binding::InBand), - ) - .await - .unwrap(); - (session, AbortOnDrop::new(tokio::spawn(moq_net::time::run(driver)))) - }; - let server = async { - let request = moq_net::Server::new() - .with_versions(versions.clone()) - .accept_request( - now, - crate::transport::Session::new(server, moq_net::transport::Binding::InBand), - ) - .await - .unwrap(); - let mut requests = request.auth().requests().unwrap(); - let (session, driver) = request.ok().await.unwrap(); - let (sender, receiver) = tokio::sync::mpsc::unbounded_channel(); - let acceptor = tokio::spawn(async move { - let _session = session; - let mut initial = Vec::new(); - while let Some(request) = requests.next().await { - if request.token().is_empty() { - initial.push(request.accept(auth::Grant::default())); - } else if sender.send(request).is_err() { - break; - } - } - }); - ( - receiver, - AbortOnDrop::new(tokio::spawn(moq_net::time::run(driver))), - AbortOnDrop::new(acceptor), - ) - }; - let ((client, client_task), (requests, server_task, acceptor)) = tokio::join!(client, server); - Pair { - client, - requests, - _tasks: vec![client_task, server_task], - _acceptor: acceptor, - } - } - - fn attach(connection: &kio::Producer, session: &moq_net::Session) { - let mut state = connection.write().ok().expect("connection observed"); - state.connects += 1; - replay(&state, session, state.connects); - state.session = Some(session.clone()); - } - - #[tokio::test(start_paused = true)] - async fn replay_is_registered_before_the_session_is_published() { - let connection = kio::Producer::::default(); - let auth = retained(&connection); - let mut first = pair().await; - attach(&connection, &first.client); - let (token, _issued) = tokio::join!(auth.add("replay"), async { - first.requests.recv().await.unwrap().accept(auth::Grant::all()) - }); - let token = token.unwrap(); - let mut second = pair().await; - let connects = { - let state = connection.read(); - let connects = state.connects + 1; - replay(&state, &second.client, connects); - connects - }; - // The task still observes the first session. The second token reaches the peer - // before publication only if queueing it already polled add's registration. - let request = tokio::time::timeout(std::time::Duration::from_secs(1), second.requests.recv()) - .await - .expect("token was not queued before publication") - .unwrap(); - assert_eq!(request.token().as_ref(), b"replay"); - { - let mut state = connection.write().ok().expect("connection observed"); - state.connects = connects; - state.session = Some(second.client.clone()); - } - let _issued = request.accept(auth::Grant::all()); - let mut watch = token.grant(); - while watch.peek().is_none() { - watch.changed().await.unwrap(); - } - } - - #[tokio::test(start_paused = true)] - async fn a_stale_snapshot_keeps_the_newer_sessions_registration() { - let mut first = pair().await; - let mut second = pair().await; - let replay = Replay { - bytes: "replay".into(), - queued: Mutex::new(Vec::new()), - }; - let mut draining = Draining::default(); - // The replacement queues and publishes while the task still holds the first snapshot. - replay.queue(&second.client, 2); - let request = second.requests.recv().await.unwrap(); - assert!(replay.take(1, &mut draining).is_none()); - let opening = replay - .take(2, &mut draining) - .expect("the newer session's registration was dropped"); - let _issued = request.accept(auth::Grant::all()); - let _token = opening.await.unwrap(); - // A registration for a session the task has moved past stays on it while it drains. - replay.queue(&first.client, 1); - let _stale = first.requests.recv().await.unwrap(); - assert!(replay.take(2, &mut draining).is_none()); - assert!(replay.queued.lock().unwrap().is_empty()); - assert_eq!(draining.0.len(), 1, "the draining session lost its registration"); - } - - /// A draining session that closes releases its token and its session handle at once, - /// not only when a later migration adds another. - #[tokio::test(start_paused = true)] - async fn a_closed_draining_session_releases_its_token() { - let pair = pair().await; - let mut draining = Draining::default(); - let held = Arc::new(()); - draining.push(pair.client.clone(), held.clone()); - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - assert_eq!(Arc::strong_count(&held), 2, "released while its session lives"); - pair.client.abort(moq_net::Error::Cancel); - pair.client.closed().await; - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - assert_eq!(Arc::strong_count(&held), 1, "a closed draining session kept its token"); - drop(draining); - } - - /// A migration keeps an unanswered replay on the session it leaves, whether or not the - /// task had taken it, since requests there may still be waiting on its answer. - #[tokio::test(start_paused = true)] - async fn a_draining_session_keeps_an_unanswered_replay() { - for taken in [false, true] { - let connection = kio::Producer::::default(); - let auth = retained(&connection); - let mut first = pair().await; - attach(&connection, &first.client); - let (token, _first_grant) = tokio::join!(auth.add("renew"), async { - first.requests.recv().await.unwrap().accept(auth::Grant::all()) - }); - let token = token.unwrap(); - let mut second = pair().await; - let third = pair().await; - attach(&connection, &second.client); - if taken { - // Let the task take the queued registration and wait on its answer. - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - } - attach(&connection, &third.client); - let request = tokio::time::timeout(std::time::Duration::from_secs(5), second.requests.recv()) - .await - .unwrap_or_else(|_| panic!("taken={taken}: the draining session withdrew its token unsent")) - .unwrap(); - assert_eq!(request.token().as_ref(), b"renew"); - let _issued = request.accept(auth::Grant::all()); - let mut grant = second.client.auth().grant(); - tokio::time::timeout(std::time::Duration::from_secs(5), async { - while grant.peek() != Some(auth::Grant::all()) { - grant.changed().await.unwrap(); - } - }) - .await - .unwrap_or_else(|_| panic!("taken={taken}: the draining session never applied its token")); - tokio::time::sleep(std::time::Duration::from_secs(1)).await; - assert_eq!( - second.client.auth().grant().peek(), - Some(auth::Grant::all()), - "taken={taken}: the draining session withdrew its token" - ); - drop(token); - } - } - - #[tokio::test(start_paused = true)] - async fn a_token_survives_replacement_and_withdraws_from_both_sessions() { - let connection = kio::Producer::::default(); - let auth = retained(&connection); - let mut first = pair().await; - attach(&connection, &first.client); - let (token, first_grant) = tokio::join!(auth.add("renew"), async { - let request = first.requests.recv().await.unwrap(); - assert_eq!(request.token().as_ref(), b"renew"); - request.accept(auth::Grant::all()) - }); - let token = token.unwrap(); - let mut grant = token.grant(); - assert!(grant.changed().await.unwrap().is_some()); - let mut second = pair().await; - attach(&connection, &second.client); - let request = second.requests.recv().await.unwrap(); - assert_eq!(request.token().as_ref(), b"renew"); - assert!( - grant.changed().await.unwrap().is_none(), - "replacement reused its predecessor grant" - ); - let second_grant = request.accept(auth::Grant::all()); - assert!(grant.changed().await.unwrap().is_some()); - assert!( - first_grant.closed().now_or_never().is_none(), - "migration withdrew a draining grant" - ); - drop(token); - let _ = tokio::join!(first_grant.closed(), second_grant.closed()); - } - - #[tokio::test(start_paused = true)] - async fn a_session_closing_mid_replay_keeps_the_token() { - let connection = kio::Producer::::default(); - let auth = retained(&connection); - let mut first = pair().await; - attach(&connection, &first.client); - let (token, _first_grant) = tokio::join!(auth.add("renew"), async { - first.requests.recv().await.unwrap().accept(auth::Grant::all()) - }); - let token = token.unwrap(); - let mut second = pair().await; - attach(&connection, &second.client); - // Close the replacement while its replayed token is still unanswered, before - // the connection notices. - let _unanswered = second.requests.recv().await.unwrap(); - second.client.abort(moq_net::Error::Cancel); - second.client.closed().await; - let mut third = pair().await; - attach(&connection, &third.client); - let request = tokio::time::timeout(std::time::Duration::from_secs(5), third.requests.recv()) - .await - .expect("the token was not replayed after its session closed") - .unwrap(); - assert_eq!(request.token().as_ref(), b"renew"); - drop(token); - } - - fn retained(connection: &kio::Producer) -> Handle { - Handle { - connection: connection.consume(), - pacing: Pacing::new(&super::super::Backoff::default()), - } - } - - #[tokio::test(start_paused = true)] - async fn a_transient_refusal_is_presented_again_after_backoff() { - let connection = kio::Producer::::default(); - let auth = retained(&connection); - let mut pair = pair().await; - attach(&connection, &pair.client); - let start = tokio::time::Instant::now(); - let (token, issued) = tokio::join!(auth.add("renew"), async { - for _ in 0..2 { - let outage = pair.requests.recv().await.unwrap(); - outage.reject(moq_net::SessionError::Internal, "auth server unavailable"); - } - let retried = pair.requests.recv().await.unwrap(); - assert_eq!(retried.token().as_ref(), b"renew"); - retried.accept(auth::Grant::all()) - }); - let token = token.unwrap(); - // The default pacing waits 1s, then 2s, before the third presentation. - assert!( - start.elapsed() >= std::time::Duration::from_secs(3), - "retried without backing off: {:?}", - start.elapsed() - ); - assert!(token.grant().changed().await.unwrap().is_some()); - drop(token); - issued.closed().await; - } - - #[tokio::test(start_paused = true)] - async fn an_unauthorized_refusal_is_final() { - let connection = kio::Producer::::default(); - let auth = retained(&connection); - let mut pair = pair().await; - attach(&connection, &pair.client); - let (token, ()) = tokio::join!(auth.add("renew"), async { - let refused = pair.requests.recv().await.unwrap(); - refused.reject(moq_net::SessionError::Unauthorized, "token refused"); - }); - assert!(token.is_err()); - tokio::time::sleep(std::time::Duration::from_secs(60)).await; - assert!(pair.requests.try_recv().is_err(), "a refused token was presented again"); - } - - #[tokio::test(start_paused = true)] - async fn ending_the_connection_withdraws_a_retained_token_handle() { - let connection = kio::Producer::::default(); - let auth = retained(&connection); - let mut pair = pair().await; - attach(&connection, &pair.client); - let (token, issued) = tokio::join!(auth.add("renew"), async { - pair.requests.recv().await.unwrap().accept(auth::Grant::all()) - }); - let token = token.unwrap(); - drop(connection); - assert!(token.closed().await.is_err()); - assert!(token.grant().peek().is_none()); - issued.closed().await; - } -} From 549ee5d9144b2e7e4a365b6c9b71f5aeb680a90e Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 16:55:16 -0700 Subject: [PATCH 05/31] fix(auth): finish the moq-tokio half of the shared auth handle Fix the reconnect test, which shadowed its token and never compiled under `--all-targets` with tcp; its OOM was the build on a loaded machine, not the test (it now runs in 0.15s). Hold issued grants instead of leaking them. Extend `a_handle_backs_one_active_session` to cover a draining session keeping an unanswered token beside its successor, and a token added after the drain riding only the successor. Point the relay auth bench at `moq_net::auth`, update the relay refresh tests for a transient refusal that now retries instead of failing `add()`, add MIGRATE entries for moq-net and moq-tokio, and apply rustfmt. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/MIGRATE.md | 10 +++++ rs/moq-net/src/auth.rs | 22 +++++++--- rs/moq-net/src/ietf/session.rs | 5 +-- rs/moq-net/src/lite/session.rs | 13 ++++-- rs/moq-net/tests/integration/auth.rs | 66 +++++++++++++++++++++++----- rs/moq-relay/benches/auth.rs | 6 +-- rs/moq-relay/src/auth/refresh.rs | 50 ++++++++++++--------- rs/moq-tokio/MIGRATE.md | 7 +++ rs/moq-tokio/src/connection.rs | 35 ++++++++------- 9 files changed, 153 insertions(+), 61 deletions(-) diff --git a/rs/moq-net/MIGRATE.md b/rs/moq-net/MIGRATE.md index f86f29361e9..ed06f5b5f31 100644 --- a/rs/moq-net/MIGRATE.md +++ b/rs/moq-net/MIGRATE.md @@ -4,6 +4,16 @@ How to update your code for each breaking change, newest first. ## Unreleased +### An auth handle can outlive its session (#6109) + +`auth::Handle::new()` creates a handle that backs no session yet; pass it to `Client::with_auth` and every session that client connects presents the tokens it holds. +A `Route` naming one of its `Auth`s keeps that token across a reconnect, so drop the code that added the token again and re-announced after one. +The handle backs one active session at a time, plus the ones draining after a GOAWAY: connecting another while one is still active fails with `Error::Duplicate`. Give independent concurrent sessions separate handles. +`Handle::grant()` and `Auth::grant()` follow the newest open session, so they read `None` while disconnected instead of keeping the last session's grant. +`Auth::closed()` resolves only on the peer's verdict (a refusal, `Unsupported`, or a clean close after a grant) or the handle's end, which `Handle::abort` triggers; a session ending is not one. +A handle a session creates for itself still ends with that session. +On moq-lite, a token refused with a code that may pass (`Internal`, `TooManyRequests`, `Timeout`) is presented again after a backoff of 1s doubling to 30s, instead of `add` failing with that code. + ### Each group has one writer (#6020) `group::Producer` no longer implements `Clone`: keep one writer and move it by value between owners, or put it behind a lock when several callers write it. diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index 0448cbba544..1a5376b5b3c 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -816,7 +816,10 @@ impl Link { /// Read this session's side of the handle, and the handle's state. fn read(&self, f: impl FnOnce(&Attached, &State) -> R) -> R { let state = self.state().read(); - let link = state.links.get(&self.id()).expect("a link outlives its session's state"); + let link = state + .links + .get(&self.id()) + .expect("a link outlives its session's state"); f(link, &state) } @@ -2676,7 +2679,10 @@ mod tests { assert!(matches!(auth.poll_answered(&kio::Waiter::noop()), Poll::Ready(Ok(())))); assert_eq!(auth.grant().peek(), None); assert_eq!(ietf.token(&auth).as_deref(), Some(b"jwt".as_slice())); - assert!(ietf.read(|link, _| link.opening.is_empty()), "nothing to open a stream for"); + assert!( + ietf.read(|link, _| link.opening.is_empty()), + "nothing to open a stream for" + ); let lite = Link::new(false); let auth = lite.present(Bytes::from_static(b"jwt"), false).unwrap(); @@ -2769,7 +2775,10 @@ mod tests { second.close(Error::Cancel); let third = Link::attach(&handle, true, false).unwrap(); - assert!(third.poll_opening(&kio::Waiter::noop()).is_pending(), "a refused token stays ended"); + assert!( + third.poll_opening(&kio::Waiter::noop()).is_pending(), + "a refused token stays ended" + ); } /// A refusal that may pass is presented again after a doubling backoff, and settles nothing. @@ -2792,7 +2801,6 @@ mod tests { link.granted(id, Grant::all()); assert!(matches!(auth.poll_answered(&kio::Waiter::noop()), Poll::Ready(Ok(())))); } - } #[cfg(all(test, feature = "async"))] @@ -2960,7 +2968,11 @@ mod request_token_tests { verdict.grant().await.expect("granted"); // The union only reflects tokens presented on this side (none here), never a // request token the acceptor answered. - assert_eq!(handle.handle().grant().peek(), None, "a request token must not widen the union"); + assert_eq!( + handle.handle().grant().peek(), + None, + "a request token must not widen the union" + ); } fn subscribe_only(prefix: &str) -> Grant { diff --git a/rs/moq-net/src/ietf/session.rs b/rs/moq-net/src/ietf/session.rs index 17b732f07f7..006cab78408 100644 --- a/rs/moq-net/src/ietf/session.rs +++ b/rs/moq-net/src/ietf/session.rs @@ -2232,10 +2232,7 @@ mod tests { #[moq_clock::test] async fn a_server_offers_auth_only_to_a_client_that_did() { let all = crate::setup::Extensions::default(); - assert_eq!( - our_setup(false, crate::auth::Link::new(true), all).await, - (true, true) - ); + assert_eq!(our_setup(false, crate::auth::Link::new(true), all).await, (true, true)); assert_eq!( our_setup(false, crate::auth::Link::new(false), all).await, (false, true) diff --git a/rs/moq-net/src/lite/session.rs b/rs/moq-net/src/lite/session.rs index c0af2829995..5dd49ff67f9 100644 --- a/rs/moq-net/src/lite/session.rs +++ b/rs/moq-net/src/lite/session.rs @@ -527,10 +527,17 @@ struct PresentToken { } enum PresentState { - Open { token: bytes::Bytes }, - Run { stream: Stream, answered: bool }, + Open { + token: bytes::Bytes, + }, + Run { + stream: Stream, + answered: bool, + }, /// Refused for a reason that may pass: present it again once the deadline fires. - Retry { deadline: crate::time::Deadline }, + Retry { + deadline: crate::time::Deadline, + }, Done, } diff --git a/rs/moq-net/tests/integration/auth.rs b/rs/moq-net/tests/integration/auth.rs index 630b710bc1f..749e31e6c92 100644 --- a/rs/moq-net/tests/integration/auth.rs +++ b/rs/moq-net/tests/integration/auth.rs @@ -1875,7 +1875,10 @@ async fn a_shared_handle_presents_its_token_on_every_session(version: &'static s first.server.abort(Error::Cancel); first.client.closed().await; assert_eq!(token.grant().peek(), None, "no session is live"); - assert!(futures::FutureExt::now_or_never(token.closed()).is_none(), "a session ending is no verdict"); + assert!( + futures::FutureExt::now_or_never(token.closed()).is_none(), + "a session ending is no verdict" + ); let mut second = connect(options()).await; let mut tokens = serve(second.requests.take().unwrap(), |token| match token { @@ -1904,16 +1907,38 @@ async fn presented( both } -/// A handle backs one active session: another fails loudly until the first drains. +/// A handle backs one active session: another fails loudly until the first drains. The +/// draining session keeps presenting the handle's tokens beside its successor, even one +/// its peer has not answered yet, since its requests may still rely on them. A token added +/// after the drain rides only the successor: a draining session opens no new streams. async fn a_handle_backs_one_active_session(version: &'static str) { within(async { + use futures::FutureExt; + let handle = auth::Handle::new(); - let first = connect(Options { + let options = || Options { version: Some(version), + client_publish: Some(produce_origin(2)), + server_requests: true, client_auth: Some(handle.clone()), ..Default::default() - }) - .await; + }; + let mut first = connect(options()).await; + let mut first_requests = first.requests.take().unwrap(); + let added = moq_clock::sim::spawn({ + let handle = handle.clone(); + async move { handle.add("token").await } + }); + let mut first_issued = Vec::new(); + let unanswered = loop { + let request = first_requests.next().await.expect("a token"); + if request.token().is_empty() { + first_issued.push(request.accept(Grant::default())); + continue; + } + assert_eq!(request.token().as_ref(), b"token"); + break request; + }; let parsed: Version = version.parse().unwrap(); let mut mock = moq_net::transport::memory::Config::default(); @@ -1928,12 +1953,31 @@ async fn a_handle_backs_one_active_session(version: &'static str) { first.server.drain().send(moq_net::goaway::Goaway::new()).unwrap(); first.client.draining().recv().await.expect("the GOAWAY"); - let _second = connect(Options { - version: Some(version), - client_auth: Some(handle), - ..Default::default() - }) - .await; + let mut second = connect(options()).await; + let mut second_tokens = serve(second.requests.take().unwrap(), |_| Some(Grant::all())); + let _second_issued = presented(&mut second_tokens).await; + let _token = added.await.expect("joined").expect("granted on the second session"); + + moq_clock::sleep(Duration::from_secs(1)).await; + assert!( + unanswered.closed().now_or_never().is_none(), + "the draining session withdrew its unanswered token" + ); + let first_grant = unanswered.accept(Grant::all()); + moq_clock::sleep(Duration::from_secs(1)).await; + assert!( + first_grant.closed().now_or_never().is_none(), + "the draining session dropped its late grant" + ); + + let later = handle.add("later").await.expect("granted on the second session"); + let (token, _later) = second_tokens.next().await.expect("the active session presents it"); + assert_eq!(token, b"later"); + assert!( + first_requests.next().now_or_never().is_none(), + "the draining session opened a new stream" + ); + drop(later); }) .await .expect("timed out"); diff --git a/rs/moq-relay/benches/auth.rs b/rs/moq-relay/benches/auth.rs index 0dd360d2458..9160876dd00 100644 --- a/rs/moq-relay/benches/auth.rs +++ b/rs/moq-relay/benches/auth.rs @@ -16,10 +16,10 @@ fn grant(prefix: &str) -> Grant { struct Fixture { _connections: Vec, - _tokens: Vec, + _tokens: Vec, producers: Vec, selected: usize, - watch: moq_tokio::connection::auth::Watch, + watch: moq_net::auth::Watch, tasks: Vec>, alternate: bool, } @@ -149,7 +149,7 @@ fn refresh(c: &mut Criterion) { /// oldest waiting token, and presents one more so the queue keeps its depth. struct Queue { _connection: moq_tokio::Connection, - auth: moq_tokio::connection::auth::Handle, + auth: moq_net::auth::Handle, admissions: auth::Admissions, held: std::collections::VecDeque, presented: usize, diff --git a/rs/moq-relay/src/auth/refresh.rs b/rs/moq-relay/src/auth/refresh.rs index 1e03db153f4..ed7e0028956 100644 --- a/rs/moq-relay/src/auth/refresh.rs +++ b/rs/moq-relay/src/auth/refresh.rs @@ -807,16 +807,21 @@ mod tests { let clock = manual(); let (lease, mut admissions) = admission(grant("a/**", 30)).await; let (client, mut supervising) = sessions(lease).await; - for _ in 0..3 { - let auth = client.auth(); - let (result, ()) = tokio::join!(auth.add("replacement"), async { - admissions - .next() - .await - .unwrap() - .refuse(super::super::Error::Unavailable("auth server down".into())); - }); - assert!(matches!(result, Err(moq_net::Error::Session(SessionError::Internal)))); + let mut adding = present(&client, "replacement"); + for attempt in 0..3 { + let admission = admissions.next().await.unwrap(); + assert_eq!( + admission.request.query.as_deref(), + Some("jwt=replacement"), + "attempt {attempt}" + ); + admission.refuse(super::super::Error::Unavailable("auth server down".into())); + // The refusal may pass, so the client presents the token again after a backoff + // shorter than each step below, rather than ending it. + assert!( + settles_pending(&mut adding).await, + "a transient refusal ended the token" + ); clock.advance(Duration::from_secs(5)); } clock.advance(Duration::from_secs(15) - Duration::from_nanos(1)); @@ -830,6 +835,7 @@ mod tests { // instant, and the client's transport can break before the close code lands. client.closed().await; client.abort(moq_net::Error::Cancel); + adding.abort(); } /// Present `token` in the background, as a peer whose credit allows it. @@ -873,7 +879,8 @@ mod tests { } /// A waiting token whose admission times out is refused without reaching the - /// decider, while the check it waited behind keeps its slot past the timeout. + /// decider, while the check it waited behind keeps its slot past the timeout. The + /// refusal may pass, so the client keeps presenting both tokens. #[tokio::test(start_paused = true)] async fn a_waiting_token_times_out_without_reaching_the_decider() { let (lease, mut admissions) = admission_with(grant("a/**", 3000), 1).await; @@ -881,20 +888,23 @@ mod tests { let slow = present(&client, "slow"); let held = admissions.next().await.unwrap(); let waiting = present(&client, "waiting"); - assert!(matches!( - waiting.await.unwrap(), - Err(moq_net::Error::Session(SessionError::Internal)) - )); - assert!(matches!( - slow.await.unwrap(), - Err(moq_net::Error::Session(SessionError::Internal)) - )); + // Past the ten-second admission timeout, and the retries that follow it. + tokio::time::sleep(Duration::from_secs(30)).await; + assert!( + !waiting.is_finished() && !slow.is_finished(), + "a transient refusal ended a token" + ); assert!( admissions.next().now_or_never().is_none(), "a timed-out check released its slot" ); - // The decider giving up frees the slot for the next token, not the expired one. + // Withdrawn, the retried tokens leave the queue. The decider giving up frees the + // slot for the next token, not the expired one. + slow.abort(); + waiting.abort(); + let _ = slow.await; + let _ = waiting.await; drop(held); let _next = present(&client, "next"); let admission = admissions.next().await.unwrap(); diff --git a/rs/moq-tokio/MIGRATE.md b/rs/moq-tokio/MIGRATE.md index e76df241415..b2681f33cdc 100644 --- a/rs/moq-tokio/MIGRATE.md +++ b/rs/moq-tokio/MIGRATE.md @@ -4,6 +4,13 @@ How to update your code for each breaking change, newest first. ## Unreleased +### `Connection::auth()` returns the `moq_net` handle (#6109) + +`Connection::auth()` returns a `moq_net::auth::Handle` that every session of the connection is built on, and the `connection::auth` module (`Handle`, `Auth`, `Watch`) is removed. +Replace `moq_tokio::connection::auth::{Handle, Auth, Watch}` with `moq_net::auth::{Handle, Auth, Watch}`. +A token it returns can now be named by a `Route`, and keeps riding that route's announcements on every reconnect. +Its grant reads `None` while disconnected. A transiently refused token retries on a fixed backoff of 1s doubling to 30s rather than the reconnect `backoff`. + ### `jemalloc::tikv_jemallocator` is 0.7 (#6053) The re-exported allocator crate moved from tikv-jemallocator 0.6 to 0.7. diff --git a/rs/moq-tokio/src/connection.rs b/rs/moq-tokio/src/connection.rs index 3be71111588..104f7a889d2 100644 --- a/rs/moq-tokio/src/connection.rs +++ b/rs/moq-tokio/src/connection.rs @@ -2420,38 +2420,43 @@ mod tests { config.version = vec![version]; config.backoff.initial = Some(MIN_BACKOFF); config.backoff.max = Some(MIN_BACKOFF); - let connection = config.init(Default::default()).unwrap().connect(url(&format!("tcp://{addr}/"))); + let connection = config + .init(Default::default()) + .unwrap() + .connect(url(&format!("tcp://{addr}/"))); let auth = connection.auth(); let added = tokio::spawn({ let auth = auth.clone(); async move { auth.add("token").await } }); + // Held past each session's abort: dropping a grant closes its stream cleanly, which + // is the peer's verdict ending the token. + let mut issued = Vec::new(); + let mut added = Some(added); let mut token = None; for round in 0..2 { let request = tokio::time::timeout(WAIT, listener.accept()).await.unwrap().unwrap(); let mut requests = request.auth().requests().unwrap(); let (session, _task) = request.ok().await.unwrap(); - let _issued = loop { + loop { let request = tokio::time::timeout(WAIT, requests.next()).await.unwrap().unwrap(); - match request.token().as_ref() { - b"" => std::mem::forget(request.accept(moq_net::auth::Grant::default())), - token => { - assert_eq!(token, b"token", "round {round}"); - break request.accept(moq_net::auth::Grant::all()); - } + if request.token().is_empty() { + issued.push(request.accept(moq_net::auth::Grant::default())); + continue; } - }; - let token = match token.take() { - Some(token) => token, - None => added.await.unwrap().expect("granted"), - }; - let mut grant = token.grant(); + assert_eq!(request.token().as_ref(), b"token", "round {round}"); + issued.push(request.accept(moq_net::auth::Grant::all())); + break; + } + if let Some(added) = added.take() { + token = Some(added.await.unwrap().expect("granted")); + } + let mut grant = token.as_ref().unwrap().grant(); while grant.peek() != Some(moq_net::auth::Grant::all()) { tokio::time::timeout(WAIT, grant.changed()).await.unwrap().unwrap(); } session.abort(moq_net::Error::Cancel); - token = Some(token); } drop(connection); let token = token.unwrap(); From 1089bbcd19e3ac0b4235b227ead4cefd39f176e6 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 17:07:01 -0700 Subject: [PATCH 06/31] feat(js/net): an auth handle that outlives its session Mirror the moq-net change in js/net. `Auth.Handle` is now a class: a session-independent handle passed to `connect({ auth })` presents every token it holds on each session built on it, so a route naming one of its `Auth`s keeps it across a reconnect. A handle backs one active session at a time, plus the ones draining after a GOAWAY; attaching another rejects with `Auth.Duplicate`. Only the peer's verdict, or the handle's end, ends a token. The reconnect loop owns one handle (`Reload.auth`), surfaced as `Connection.auth`, and aborts it when the loop closes. A transiently refused token on moq-lite is presented again after a 1s backoff doubling to 30s, as in Rust. `Auth.None` is removed. Update the docs that said to add the token again and re-announce after a reconnect, and add a MIGRATE entry. Co-Authored-By: Claude Opus 5.5 --- doc/bin/relay/auth.md | 8 +- doc/concept/moq-lite.md | 6 +- doc/concept/standard.md | 2 +- doc/lib/js/net.md | 2 +- doc/lib/rs/moq-net.md | 2 +- js/net/MIGRATE.md | 10 + js/net/bench/forward.ts | 7 +- js/net/src/auth.test.ts | 158 ++++++- js/net/src/auth.ts | 88 +--- js/net/src/auth_session.ts | 630 +++++++++++++++++++------- js/net/src/connection/connect.ts | 17 + js/net/src/connection/established.ts | 3 + js/net/src/connection/forward.test.ts | 7 +- js/net/src/connection/migrate.test.ts | 61 +++ js/net/src/connection/pool.ts | 14 + js/net/src/connection/reload.ts | 12 + js/net/src/ietf/connection.ts | 17 +- js/net/src/lite/connection.ts | 18 +- js/net/src/wire.ts | 4 + 19 files changed, 817 insertions(+), 249 deletions(-) diff --git a/doc/bin/relay/auth.md b/doc/bin/relay/auth.md index 25d59388048..03dd8ab3627 100644 --- a/doc/bin/relay/auth.md +++ b/doc/bin/relay/auth.md @@ -133,8 +133,8 @@ key can itself be **scoped** at generation (`--root`, `--publish`, On moq-lite-07-wip or moq-transport draft-17 and later with MoQ Auth negotiated, a client can present another JWT without reconnecting. Present its replacement before the current grant expires and keep the returned token handle alive. -The native `Connection::auth()` handle re-presents retained tokens after a -reconnect; the JavaScript session's `auth.add()` presents one for that session. +The auth handle a reconnecting connection owns (native `Connection::auth()`, +JavaScript `connection.auth`) presents its tokens again on every reconnect. Older versions refuse in-band tokens. Each accepted token adds its publish and subscribe patterns to the session's @@ -166,8 +166,8 @@ one that times out while waiting is refused without being sent. A sent check always runs to completion, so the connection's `end` stays its last request and `--limit-token` counts stay exact. On moq-lite, an auth server that cannot answer, by timeout or outage, refuses a token as a -transient error, and `Connection::auth()` presents it again on its reconnect -backoff. On moq-transport the refusal is final and the token ends. Retrying +transient error, and the client presents it again after a backoff of 1s +doubling to 30s. On moq-transport the refusal is final and the token ends. Retrying never extends an accepted grant, so a session whose last grant expires during an outage still closes. diff --git a/doc/concept/moq-lite.md b/doc/concept/moq-lite.md index edda6e031b0..5c1212d4e58 100644 --- a/doc/concept/moq-lite.md +++ b/doc/concept/moq-lite.md @@ -209,8 +209,10 @@ everywhere. On moq-transport an added token is kept even without the extension, so an announcement can name it: it rides that `PUBLISH_NAMESPACE` as its authorization token. Naming a fresh token renews the announcement in place with `REQUEST_UPDATE` from draft 16, and re-announces it on drafts 14 and 15, so it -can outlive the token it started with. The token belongs to the session that added it, so after a -reconnect the application adds it again and re-announces. moq-lite has no +can outlive the token it started with. A token added to a reconnecting +connection's auth handle is presented on every session it makes, so the +announcement keeps it across a reconnect with no re-add or re-announce; one +added to a single session's handle ends with that session. moq-lite has no per-request token, so there an announcement rides the session's scope. ## Subscriptions diff --git a/doc/concept/standard.md b/doc/concept/standard.md index 59605edbf21..8808362e43c 100644 --- a/doc/concept/standard.md +++ b/doc/concept/standard.md @@ -29,7 +29,7 @@ subset drops. What a peer actually observes against this implementation: - **History is one group.** In Rust, Object ID gaps must be marked by Prior Object ID Gap properties for a group to be fetchable. A `FETCH` returns one group from the cache, or on drafts 14 through 19 the saved prefix of the group a new subscription just joined. A range of groups is refused, as is a draft 20+ `LOCATION_FILTER` that ends at Largest Object rather than an absolute End Location. On draft 20 and later, `FETCH_OK`'s End Location is the requested end, capped at Largest Object: a range holding no objects gets an empty fetch stream, and only a start past Largest Object is refused, with `INVALID_RANGE`. A relay's copy with no upstream subscription can't know Largest Object short of the track's end, so it echoes the requested end instead. JavaScript publishing refuses every `FETCH`. Datagrams are never fetchable. A Rust reader that only fetches learns the track from `TRACK_STATUS` instead of subscribing, so a finished track stays fetchable; draft 17 still subscribes, since its `TRACK_STATUS` answer cannot say whether the track is timed, and so does a reader whose publisher refuses `TRACK_STATUS` as `NOT_SUPPORTED`. - **Timing.** A track whose `SUBSCRIBE_OK` declares no `TIMESCALE` is untimed, as is every track on drafts 14 through 16. A Rust reader that never subscribed takes the units from `TRACK_STATUS` on draft 18 and later. - **Strict SETUP and parameters.** Repeated unknown `SETUP` options, GREASE included, are accepted if well-formed; a repeated known option is rejected. On draft 17 and later, a `GROUP_ORDER` other than Ascending (1) or Descending (2) closes the session. Message parameters follow the negotiated draft's lists: drafts 14 and 15 ignore an unknown or misplaced parameter, draft 16 ignores one defined only for another message but closes the session on an unknown one, and draft 17 and later close it on both. Other malformed control input closes the session with the draft's code too, such as a server `SETUP` carrying `PATH` or `AUTHORITY`, a request cut short by FIN, or a second `GOAWAY` on one stream; a first `GOAWAY` on a request stream is legal. -- **One `SETUP` credential**, forwarded to the [auth server](/bin/relay/auth#the-contract) unverified. A second `SETUP` token closes the session. We advertise no token cache, so an alias reference in `SETUP` closes it with `PROTOCOL_VIOLATION`, one on a request with `UNKNOWN_AUTH_TOKEN_ALIAS`, and a registration on a request with `AUTH_TOKEN_CACHE_OVERFLOW`. Where the Rust side authorizes the peer or verifies tokens, a token on a `SUBSCRIBE` or `PUBLISH_NAMESPACE` is verified like an in-band session token and authorizes that one request alone, even where that authorization already covers it; it never widens the session. It is refused with `NOT_SUPPORTED` when nothing verifies it, when it is empty, or when its Token Type is anything but 0 (out of band). A side that does neither, JavaScript included, ignores the token. Rust refuses a request carrying more than one distinct token with `NOT_SUPPORTED`, since it verifies one; JavaScript, which verifies none, ignores them all. A token-authorized `SUBSCRIBE` still stays within the grant the peer gave us. A token-authorized `PUBLISH_NAMESPACE` needs a grant covering everything beneath the namespace, and the tracks we subscribe under it rely on that announce instead of our limit, ending with it. An update renews the token, except on a drafts 14 and 15 `PUBLISH_NAMESPACE`, which has no update, and a refused renewal ends only that request. Rust and JavaScript send the token a route names (`Route::auth`) on its `PUBLISH_NAMESPACE`, including the one drafts 14 and 15 answer a solicited `SUBSCRIBE_NAMESPACE` with, and swapping it renews the request with `REQUEST_UPDATE` from draft 16, or ends and re-sends it before; the inline `NAMESPACE` that answers a solicited `SUBSCRIBE_NAMESPACE` from draft 16 carries no token. The token belongs to the session that added it, so a reconnect means adding it again and re-announcing. A token on any other request is decoded and grants nothing, and a request repeating one is refused with `MALFORMED_AUTH_TOKEN`. `SUBSCRIBE_TRACKS` reads its namespace before refusal; its token is not decoded. On draft 17 and later, the negotiated [MoQ Auth extension](/concept/moq-lite#authorization) accepts additional session tokens in band. +- **One `SETUP` credential**, forwarded to the [auth server](/bin/relay/auth#the-contract) unverified. A second `SETUP` token closes the session. We advertise no token cache, so an alias reference in `SETUP` closes it with `PROTOCOL_VIOLATION`, one on a request with `UNKNOWN_AUTH_TOKEN_ALIAS`, and a registration on a request with `AUTH_TOKEN_CACHE_OVERFLOW`. Where the Rust side authorizes the peer or verifies tokens, a token on a `SUBSCRIBE` or `PUBLISH_NAMESPACE` is verified like an in-band session token and authorizes that one request alone, even where that authorization already covers it; it never widens the session. It is refused with `NOT_SUPPORTED` when nothing verifies it, when it is empty, or when its Token Type is anything but 0 (out of band). A side that does neither, JavaScript included, ignores the token. Rust refuses a request carrying more than one distinct token with `NOT_SUPPORTED`, since it verifies one; JavaScript, which verifies none, ignores them all. A token-authorized `SUBSCRIBE` still stays within the grant the peer gave us. A token-authorized `PUBLISH_NAMESPACE` needs a grant covering everything beneath the namespace, and the tracks we subscribe under it rely on that announce instead of our limit, ending with it. An update renews the token, except on a drafts 14 and 15 `PUBLISH_NAMESPACE`, which has no update, and a refused renewal ends only that request. Rust and JavaScript send the token a route names (`Route::auth`) on its `PUBLISH_NAMESPACE`, including the one drafts 14 and 15 answer a solicited `SUBSCRIBE_NAMESPACE` with, and swapping it renews the request with `REQUEST_UPDATE` from draft 16, or ends and re-sends it before; the inline `NAMESPACE` that answers a solicited `SUBSCRIBE_NAMESPACE` from draft 16 carries no token. A token added to a reconnecting connection's auth handle rides every session it makes, so the announcement keeps it across a reconnect; one added to a single session's handle ends with that session. A token on any other request is decoded and grants nothing, and a request repeating one is refused with `MALFORMED_AUTH_TOKEN`. `SUBSCRIBE_TRACKS` reads its namespace before refusal; its token is not decoded. On draft 17 and later, the negotiated [MoQ Auth extension](/concept/moq-lite#authorization) accepts additional session tokens in band. - **Refused, not fatal.** A legal request this stack does not serve is rejected on its own and the session stays up: `FORWARD=0`, `SUBSCRIBE_TRACKS`, and the fetch forms above get `NOT_SUPPORTED`. Range Filters get `INVALID_FILTER` on draft 19 and later because the advertised filter limit is zero. On draft 21 and later, namespaces whose first tuple field is exactly `.` or `.session` get `DOES_NOT_EXIST` before application lookup; other dot prefixes still reach the application. Subscription updates accept priority, negotiated subscriber max age, and delivery timeouts; Rust also renews request tokens and judges `NEW_GROUP_REQUEST` as a join (see dynamic groups below). Other supported-but-unimplemented preferences end that subscription; a `TRACK_PROPERTY_FILTER` there closes the session with `PROTOCOL_VIOLATION`, since it belongs only to `SUBSCRIBE_TRACKS`. On draft 18 and later a namespace update may change its prefix; suffixes after the acknowledgement are relative to that new prefix. - **Objects and datagrams.** Only subgroup 0 is accepted: a stream for any other subgroup, whether its header gives the ID or takes it from the first Object ID, is stopped and the session stays up. Rust relays preserve Object IDs, gaps, object properties, FIRST\_OBJECT, and END\_OF\_GROUP status objects between moq-transport peers for subgroup 0, including across a moq-lite-07 hop between two relays that negotiate the [ietf](/draft/moq-ietf) extension. Datagrams retain their IDs, properties, priority, and status. Plain Rust readers and moq-lite peers see consecutive frames and normal datagram payloads; JavaScript supports only normal datagrams at Object ID 0. A malformed datagram closes the session; a relayed one the downstream draft cannot carry is dropped. A relayed Timestamp leaves in the units the downstream response declares, and is dropped where it declares none (drafts 14 through 16), as for a plain frame; an immutable one that would change or vanish is refused. On a timed track the frame holds each object's time, so a mutable Timestamp that only repeats the immutable one is not restated; a status object has no frame, so it keeps its Timestamp among its properties. Only property 0x10 is read as a Timestamp. Property 0x06 stays opaque and keeps its value regardless of timestamp units; it is dropped across the draft-19 boundary, or refused when immutable, because draft 19 assigns it to SUBGROUP\_DELIVERY\_TIMEOUT. From draft 15, a status object carrying properties is refused, and one relayed from draft 14 leaves without them. Draft 14 allows them, but an END\_OF\_TRACK carrying properties ends the subscription as unsupported, since a track end has nowhere to keep them. A `FETCH` carries the END\_OF\_GROUP object on drafts 14 and 15; later drafts have no FETCH status, so their response ends at the last frame. A group cannot resume across route changes by frame ordinal once Object IDs skip ahead of the ordinals on either route; recovery refuses it instead of splicing different objects. - **Dynamic groups.** A track advertises `DYNAMIC_GROUPS` when its publisher sets a refresh ratio, and a track received with `DYNAMIC_GROUPS` gets a ratio of 0. A `NEW_GROUP_REQUEST` (draft 15 and later) counts as one more subscriber joining, which a `SUBSCRIBE` already is: it asks for a new group only when the newest group's bytes after its first frame exceed the ratio times the first frame. Joins coalesce until the asked-for group arrives. A Rust relay sends `NEW_GROUP_REQUEST` upstream, on the `SUBSCRIBE` or in a `REQUEST_UPDATE`, once a join wants a group past the newest it holds; a relay holding nothing yet asks for none. On a track without a ratio, an update carrying one ends the subscription with `UPDATE_FAILED`. On drafts 15 and 16, whose `TRACK_STATUS` answer has no room for it, a relay copy first learned from `TRACK_STATUS` (a fetch-only reader) does not advertise it to later subscribers. A moq-lite-07 hop (`moq-lite-07-wip`, opt-in) carries the same demand natively: the ratio in `TRACK_INFO` and the asked-for group in `SUBSCRIBE_UPDATE`. Earlier moq-lite versions carry neither, so a track received over one never refreshes. diff --git a/doc/lib/js/net.md b/doc/lib/js/net.md index b0c833901ec..133266f5362 100644 --- a/doc/lib/js/net.md +++ b/doc/lib/js/net.md @@ -45,7 +45,7 @@ broadcast.announce(); - **Timing is per track, with no default.** A track that declares a `timescale` carries a `timestamp` on every frame; one without is [untimed](/concept/moq-lite#subscriptions) and its frames carry none. Omitting it means untimed, as in Rust. Nothing stamps a frame for you: the publisher passes the timestamp, and the `Timed` type (`{ value, at }`) carries one into the `@moq/json` and `@moq/flate` producers. - **Subscriber staleness is `maxDelay`.** It counts from a group's successor, by media time and by wall clock, whichever reaches it first. Passing the old `maxAge` key throws a `TypeError` naming `maxDelay`. Publisher retention is the separate `Track.Info.maxAge`. - **Hidden paths** stay out of discovery unless the announce request opts in. See [hidden broadcasts](/concept/moq-lite#hidden-broadcasts). -- **Authorization is in band.** On moq-lite 07 (opt in with `versions: ["moq-lite-07-wip", "moq-lite-06"]`, listing every fallback you still want) and on moq-transport draft-17+ with the [MoQ Auth extension](/draft/moq-auth), `auth.grant` watches what the relay lets this side publish and subscribe to, and `auth.add(token)` presents another token without reconnecting. Publishing outside the grant closes the connection with `SessionCode.Unauthorized`, naming the path. On moq-lite, a subscription the grant stops covering resets with `StreamCode.Unauthorized` and the session stays up. On moq-transport, `auth.add(token)` keeps the token even without the extension, and a broadcast announced with the `Auth.Auth` it returns (`broadcast.announce({ auth })`) carries it on that announcement's `PUBLISH_NAMESPACE`; announcing again with another renews the request. An `Auth.Auth` belongs to the session that added it, so after a reconnect, add the token again and re-announce. +- **Authorization is in band.** On moq-lite 07 (opt in with `versions: ["moq-lite-07-wip", "moq-lite-06"]`, listing every fallback you still want) and on moq-transport draft-17+ with the [MoQ Auth extension](/draft/moq-auth), `auth.grant` watches what the relay lets this side publish and subscribe to, and `auth.add(token)` presents another token without reconnecting. Publishing outside the grant closes the connection with `SessionCode.Unauthorized`, naming the path. On moq-lite, a subscription the grant stops covering resets with `StreamCode.Unauthorized` and the session stays up. On moq-transport, `auth.add(token)` keeps the token even without the extension, and a broadcast announced with the `Auth.Auth` it returns (`broadcast.announce({ auth })`) carries it on that announcement's `PUBLISH_NAMESPACE`; announcing again with another renews the request. A `Connection`'s `auth` handle outlives its sessions: a token added there is presented on every reconnect and keeps riding the announcements that name it, with no re-add or re-announce. A one-shot session's `auth` ends with it, unless `Connection.connect` was given a `new Auth.Handle()` as `auth`. - **A graceful close waits.** `await connection.close()` withdraws announcements and drains for up to one second. Application delivery requires explicit `moq-lite-07-wip`; older Lite versions wait only for transport ACKs. IETF media streams are not drained. `abort()` ends immediately. Examples: diff --git a/doc/lib/rs/moq-net.md b/doc/lib/rs/moq-net.md index 1763f6251d2..b0358c3b135 100644 --- a/doc/lib/rs/moq-net.md +++ b/doc/lib/rs/moq-net.md @@ -25,7 +25,7 @@ What you use it for, beyond what the concept page already describes: - **Use the model without a runtime.** Disable default features on `moq-net` to use its `poll_*` APIs and origin driver with caller-supplied time. The default `async` feature adds async helpers, `time::run`, and both wire sessions. - **You drive the session, or `moq-tokio` does.** `connect` and `accept` return a session plus a driver that never reads the clock itself. `moq_net::time::run` polls it on tokio or in the browser. `moq-tokio` and `moq-wasm` hide that. A custom transport implements `moq_net::transport::poll`, and `moq_net::transport::memory::pair` links two sessions in one process with no sockets, for tests or in-process links. - **A session caps what its peer can hold.** `session::Limits` (via `Client::with_limits` and `Server::with_limits`) bounds announces, subscriptions, and FETCH requests independently per session; the defaults suit a relay mesh, so lower them for untrusted peers. Past a cap, the session closes with `TOO_MANY_REQUESTS`. On moq-transport drafts 14 to 16 they also size the request-ID window, which every request counts against, so very low limits can starve it. Peer-declared lengths are capped before they are buffered. -- **Authorization is in band.** On moq-lite 07 (`moq-lite-07-wip`, opt-in) and on moq-transport draft-17+ with the [MoQ Auth extension](/draft/moq-auth), `session.auth()` presents tokens without reconnecting and watches the grant the peer sent back. A server verifies tokens itself by taking `handshake.auth().requests()` before `ok()`. `authorize(&grant)` narrows or widens a live session on any version: what falls outside resets with `Unauthorized` and the session stays up. A client that publishes outside its grant closes the session, naming the path. +- **Authorization is in band.** On moq-lite 07 (`moq-lite-07-wip`, opt-in) and on moq-transport draft-17+ with the [MoQ Auth extension](/draft/moq-auth), `session.auth()` presents tokens without reconnecting and watches the grant the peer sent back. A handle passed to `Client::with_auth` outlives each session, so its tokens, and the routes naming them, carry across your own reconnects. A server verifies tokens itself by taking `handshake.auth().requests()` before `ok()`. `authorize(&grant)` narrows or widens a live session on any version: what falls outside resets with `Unauthorized` and the session stays up. A client that publishes outside its grant closes the session, naming the path. - **A graceful close waits.** `session.close().await` withdraws announcements and drains for up to one second. Application delivery requires explicit `moq-lite-07-wip`; older Lite versions (including default lite-06) and IETF wait only for transport ACKs. `abort` ends immediately. IETF drafts 14 through 16 send withdrawals without waiting. ```bash diff --git a/js/net/MIGRATE.md b/js/net/MIGRATE.md index 4d7076305f6..f2908310a1b 100644 --- a/js/net/MIGRATE.md +++ b/js/net/MIGRATE.md @@ -4,6 +4,16 @@ How to update your code for each breaking change, newest first. ## Unreleased +### An auth handle can outlive its session (#6109) + +`Auth.Handle` is now a class: `new Auth.Handle()` backs no session yet, and `connect({ auth })` builds the session on it, so every session built on one presents the tokens it holds. +A reconnecting `Connection` owns one as `connection.auth`; a route naming one of its `Auth.Auth`s keeps that token across a reconnect, so drop the code that added the token again and re-announced after one. +A handle backs one active session at a time, plus the ones draining after a GOAWAY: connecting another while one is still active rejects with `Auth.Duplicate`. +`Established.auth` returns the session's handle, so its `grant` and each `Auth.Auth`'s `grant` read undefined once the session closes, instead of an empty grant. +`Auth.Auth.closed` settles only on the peer's verdict or the handle's end (`handle.abort()`); a session ending is not one, except for a handle the session created for itself. +`Auth.None` is removed; nothing returned it. +On moq-lite, a token refused with a code that may pass (`Internal`, `TooManyRequests`, `Timeout`) is presented again after a backoff of 1s doubling to 30s, instead of `add` rejecting with that code. + ### `largest()` reports within a reader's `setGroups` bounds `Track.Subscriber.largest()` and `Track.Ordered.largest()` report the track's largest only while it is within the bounds set with `setGroups` or `withGroups`, where they used to ignore the bounds. diff --git a/js/net/bench/forward.ts b/js/net/bench/forward.ts index ba8e2a6fb4a..f174a35548a 100644 --- a/js/net/bench/forward.ts +++ b/js/net/bench/forward.ts @@ -7,6 +7,7 @@ import { Manual } from "@moq/clock"; import { Once } from "@moq/signals"; import { announcedView } from "../src/announced.test-util"; import * as Announce from "../src/announced.ts"; +import { AuthSession } from "../src/auth_session.ts"; import { Producer as BroadcastProducer } from "../src/broadcast.ts"; import type { Established } from "../src/connection/established.ts"; import { forwardAnnounced } from "../src/connection/forward.ts"; @@ -32,7 +33,11 @@ class FakeSession { die!: () => void; constructor() { - registerWire(this, { consume: () => new BroadcastProducer().consume(), goaway: new Once() }); + registerWire(this, { + consume: () => new BroadcastProducer().consume(), + goaway: new Once(), + auth: new AuthSession({ peerGrant: { publish: new Path.Patterns(), subscribe: new Path.Patterns() } }), + }); this.closed = new Promise((resolve) => { this.die = () => resolve(null); }); diff --git a/js/net/src/auth.test.ts b/js/net/src/auth.test.ts index 27a50d510b2..ae5295e9e05 100644 --- a/js/net/src/auth.test.ts +++ b/js/net/src/auth.test.ts @@ -1,8 +1,7 @@ import { describe, expect, spyOn, test } from "bun:test"; import { Manual, Milli, Nano } from "@moq/clock"; import type { Getter } from "@moq/signals"; -import { type Grant, type Issued, Unsupported } from "./auth.ts"; -import type { AuthSession } from "./auth_session.ts"; +import { Duplicate, type Grant, Handle, type Issued, type Request, type Requests, Unsupported } from "./auth.ts"; import { accept as acceptSession, connect as connectSession, type Established } from "./connection/index.ts"; import { SessionCode, SessionError, StreamCode, StreamError, toStreamCode } from "./error.ts"; import { AuthMessage as IetfAuthMessage, IetfAuthWire } from "./ietf/auth.ts"; @@ -67,17 +66,93 @@ interface Pair { transport: MockTransport; } -async function connect(opts: { publish?: OriginProducer; serverPublish?: OriginProducer; protocol: string }) { +async function connect(opts: { + publish?: OriginProducer; + serverPublish?: OriginProducer; + protocol: string; + auth?: Handle; +}) { const pair = createMockTransportPair(opts.protocol); const [client, server] = await Promise.all([ - connectSession({ url, transport: pair.client, publish: opts.publish?.consume() }), + connectSession({ url, transport: pair.client, publish: opts.publish?.consume(), auth: opts.auth }), acceptSession({ transport: pair.server, url, publish: opts.serverPublish?.consume() }), ]); return { client, server, transport: pair.client } satisfies Pair; } +/** Answer every token the peer presents with `answer`'s grant, holding each until the session ends. */ +function answer(requests: Requests, answer: (token: string) => Grant): Issued[] { + const issued: Issued[] = []; + void (async () => { + for (;;) { + const request = await requests.next(); + if (!request) break; + issued.push(request.accept(answer(new TextDecoder().decode(request.token)))); + } + })(); + return issued; +} + // Every case runs on moq-lite-07-wip and on moq-transport with the MoQ Auth extension. describe.each([Lite.ALPN_07_WIP, Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_22])("%s", (protocol) => { + test("a shared handle presents its token on every session", async () => { + const handle = new Handle(); + // Added before any session: it waits for the first. + const adding = handle.add("token"); + + const first = await connect({ publish: new OriginProducer(), protocol, auth: handle }); + answer(first.server.auth.requests(), (token) => (token ? grant(["first"], []) : grant([], []))); + const token = await adding; + await waitFor(token.grant, (g) => g?.publish.equals(patterns("first")) === true); + expect(first.client.auth).toBe(handle); + + first.client.abort(); + first.server.abort(); + expect(token.grant.peek()).toBeUndefined(); + expect(handle.grant.peek()).toBeUndefined(); + let ended = false; + void token.closed.then(() => { + ended = true; + }); + await nextTask(); + expect(ended).toBe(false); + + const second = await connect({ publish: new OriginProducer(), protocol, auth: handle }); + answer(second.server.auth.requests(), (token) => (token ? grant(["second"], []) : grant([], []))); + await waitFor(token.grant, (g) => g?.publish.equals(patterns("second")) === true); + await waitFor(handle.grant, (g) => g?.publish.equals(patterns("second")) === true); + expect(ended).toBe(false); + // A route naming the token carries it on the new session's requests, on moq-transport. + const carried = wireOf(second.client).auth.carried(token); + if (protocol === Lite.ALPN_07_WIP) expect(carried).toBeUndefined(); + else expect(carried).toEqual(new TextEncoder().encode("token")); + + token.close(); + expect(await token.closed).toBeNull(); + await waitFor(handle.grant, (g) => g?.publish.equals(patterns("")) === false); + second.client.abort(); + second.server.abort(); + }); + + test("a handle backs one active session", async () => { + const handle = new Handle(); + const first = await connect({ publish: new OriginProducer(), protocol, auth: handle }); + const pair = createMockTransportPair(protocol); + await expect(connectSession({ url, transport: pair.client, auth: handle })).rejects.toBeInstanceOf(Duplicate); + pair.client.close(); + + first.client.abort(); + first.server.abort(); + const second = await connect({ publish: new OriginProducer(), protocol, auth: handle }); + expect(second.client.auth).toBe(handle); + + // Ending the handle ends the tokens it holds, and no session can be built on it. + handle.abort(new Error("done")); + await expect(handle.add("late")).rejects.toThrow("done"); + second.client.abort(); + second.server.abort(); + }); + test("both sides learn their default grant", async () => { const { client, server } = await connect({ publish: new OriginProducer(), protocol }); @@ -279,11 +354,13 @@ describe.each([Lite.ALPN_07_WIP, Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_22])("%s", // Checked before any stream notices the transport closing. client.abort(); - const closed = client.auth.grant.peek(); + // No session is live, and every token ended with it. + expect(client.auth.grant.peek()).toBeUndefined(); + const closed = wireOf(client).auth.grant.peek(); expect(closed?.publish.size).toBe(0); expect(closed?.subscribe.size).toBe(0); // What the session enforced is not revoked by its end. - expect((client.auth as AuthSession).scope.peek()?.publish.size).toBeGreaterThan(0); + expect(wireOf(client).auth.scope.peek()?.publish.size).toBeGreaterThan(0); server.abort(); }); @@ -298,7 +375,7 @@ describe.each([Lite.ALPN_07_WIP, Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_22])("%s", published.appendGroup().writeFrame({ payload: new Uint8Array([1]) }); const { client, server } = await connect({ serverPublish: origin, protocol }); const requests = server.auth.requests(); - await (client.auth as AuthSession).ready(Path.from("room/x"), "subscribe"); + await wireOf(client).auth.ready(Path.from("room/x"), "subscribe"); const setup = await requests.next(); if (!setup) throw new Error("missing setup token"); const initial = setup.accept(grant([], [])); @@ -361,7 +438,7 @@ describe.each([Lite.ALPN_07_WIP, Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_22])("%s", if (!unanswered) throw new Error("missing token"); const track = new OriginProducer().createBroadcast(Path.from("room/x")).createTrack("video"); const reader = track.subscribe(); - const ready = (client.auth as AuthSession).ready(Path.from("room/x"), "subscribe", { + const ready = wireOf(client).auth.ready(Path.from("room/x"), "subscribe", { until: () => abandoned(track.demand()), }); let settled = false; @@ -472,8 +549,8 @@ describe.each([Lite.ALPN_07_WIP, Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_22])("%s", // The grant drops as the session closes, which can land before the transport's close // reaches the subscription; force that order. - (client.auth as AuthSession).close(); - expect(client.auth.grant.peek()?.subscribe.size).toBe(0); + wireOf(client).auth.close(); + expect(wireOf(client).auth.grant.peek()?.subscribe.size).toBe(0); await nextTask(); expect(track.closed.peek()).toBeUndefined(); @@ -579,6 +656,65 @@ describe.each([Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_22])("%s", (protocol) => { }); }); +// moq-transport's AUTH_ERROR carries no code that may pass, so only lite retries. +test("lite-07 presents a transiently refused token again after a backoff", async () => { + const clock = new Manual(); + const restore = clock.install(); + const warned = spyOn(console, "warn").mockImplementation(() => {}); + const refusals = () => warned.mock.calls.filter(([line]) => line === "auth token refused").length; + const { client, server } = await connect({ publish: new OriginProducer(), protocol: Lite.ALPN_07_WIP }); + const requests = server.auth.requests(); + const issued: Issued[] = []; + let added = false; + + /** Refuse `request` as busy, once the client has started its backoff. */ + const refuse = async (request: Request | undefined) => { + expect(request && new TextDecoder().decode(request.token)).toBe("token"); + const before = refusals(); + request?.reject(SessionCode.TooManyRequests, "busy"); + // The client starts its backoff as it logs the refusal. + await settles(() => refusals() > before); + }; + + /** The token presented again, exactly once `backoff` passes. */ + const after = async (backoff: Milli) => { + let presented = false; + const next = requests.next().then((request) => { + presented = true; + return request; + }); + clock.advance(Nano.fromMilli(Milli(backoff - 1))); + for (let i = 0; i < 10; i++) await nextTask(); + expect(presented).toBe(false); + expect(added).toBe(false); + clock.advance(Nano.fromMilli(Milli(1))); + return await next; + }; + + try { + const setup = await requests.next(); + if (!setup) throw new Error("missing setup token"); + issued.push(setup.accept(grant([], []))); + + const adding = client.auth.add("token"); + void adding.then(() => { + added = true; + }); + await refuse(await requests.next()); + await refuse(await after(Milli(1000))); + const accepted = await after(Milli(2000)); + if (!accepted) throw new Error("the token was not presented again"); + issued.push(accepted.accept(grant(["a"], []))); + const token = await adding; + expect(token.grant.peek()?.publish.equals(patterns("a"))).toBe(true); + } finally { + client.abort(); + server.abort(); + warned.mockRestore(); + restore(); + } +}); + // moq-lite carries patterns, so every grant arrives exactly as issued. test("a fetch abandoned while its token waits never reaches the wire", async () => { const logged = spyOn(console, "debug"); @@ -717,7 +853,7 @@ test.each([ const bytes = new TextEncoder().encode("bytes"); const copied = await client.auth.add(bytes); bytes.fill(0); - expect((client.auth as AuthSession).carried(copied)).toEqual(new TextEncoder().encode("bytes")); + expect(wireOf(client).auth.carried(copied)).toEqual(new TextEncoder().encode("bytes")); // The token ends with its session, like any other. client.abort(); expect(await token.closed).toBeInstanceOf(Error); diff --git a/js/net/src/auth.ts b/js/net/src/auth.ts index 6a191369d5d..5d2189cc92d 100644 --- a/js/net/src/auth.ts +++ b/js/net/src/auth.ts @@ -8,16 +8,22 @@ * undefined. There {@link Handle.add} rejects with {@link Unsupported} on moq-lite, and on * moq-transport keeps the token, with no grant, for the announcements whose route names it. * {@link Handle.grant} is the union of every token this side presented, and {@link Handle.add} - * presents another without reconnecting, returning its {@link Auth}. Mirrors the Rust - * `moq_net::auth`. + * presents another without reconnecting, returning its {@link Auth}. + * + * A {@link Handle} passed to `connect` as `auth`, or the one a reconnecting `Connection` owns, + * outlives its sessions: each presents every token it holds, so a route naming one of its + * {@link Auth}s keeps it across a reconnect. Only the peer's verdict ends such a token; a + * session ending leaves it for the next. Mirrors the Rust `moq_net::auth`. * * @module */ -import { type Getter, Signal } from "@moq/signals"; -import { SessionCode } from "./error.ts"; +import type { Getter } from "@moq/signals"; +import type { SessionCode } from "./error.ts"; import type * as Path from "./path.ts"; +export { Duplicate, grantsEqual, Handle, Unsupported } from "./auth_session.ts"; + /** * What a peer lets this side do, in this side's own paths (relative to the session). * @@ -32,27 +38,21 @@ export interface Grant { expires?: number; } -/** Whether two grants allow the same paths and lapse together. */ -export function grantsEqual(a: Grant | undefined, b: Grant | undefined): boolean { - if (a === undefined || b === undefined) return a === b; - return a.publish.equals(b.publish) && a.subscribe.equals(b.subscribe) && a.expires === b.expires; -} - -/** 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"; - } -} - -/** A token this side presented, which a route can name. {@link Auth.close} withdraws it. */ +/** + * A token this side presented, which a route can name, on every session of its handle. + * {@link Auth.close} withdraws it. + */ export interface Auth { - /** This token's own grant: undefined until the peer answers, and again once it ends. */ + /** + * This token's own grant on the live session: undefined until its peer answers, while no + * session is live, and again once it ends. + */ readonly grant: Getter; - /** Settles when the token ends: the peer's revocation, or its withdrawal. */ + /** + * Settles when the token ends for good: the peer's refusal or revocation, `null` when the + * peer closed it without one or it was withdrawn, or the handle's end. A session ending is + * not one: the next session presents it again. + */ readonly closed: PromiseLike; /** Withdraw the token, removing its grant from the union and remaking any request that carries it without it. */ close(): void; @@ -94,45 +94,3 @@ export interface Requests { /** Refuse every later token. */ close(): void; } - -/** A session's tokens and grants. See the module docs. */ -export interface Handle { - /** The union of every grant this side holds: undefined until the peer first answers. */ - readonly grant: Getter; - - /** - * Present another token, resolving once the peer answers it. Rejects with - * {@link Unsupported} when the session or the peer takes no tokens in band, or with the - * peer's refusal as an `Error.Session`. On moq-transport without AUTH it resolves at - * once with no grant: only a request whose route names it carries it. - */ - add(token: string | Uint8Array): Promise; - - /** - * Answer every token the peer presents, instead of the default: granting what this side - * publishes and consumes for the connection's own credential and refusing any other. - * - * Call it as soon as connect or accept resolves, in the same task: whoever answers is - * decided once, right after. Throws once that has happened. - * - * The peer's stream credit bounds how many tokens wait unanswered, since each holds its - * stream open until answered or withdrawn. A withdrawn token leaves the queue. - */ - requests(): Requests; -} - -/** The {@link Handle} of a session without AUTH: no grant, and no tokens. */ -export class None implements Handle { - /** Always undefined: no token is ever granted. */ - readonly grant: Getter = new Signal(undefined); - - /** Always rejects with {@link Unsupported}. */ - add(_token: string | Uint8Array): Promise { - return Promise.reject(new Unsupported()); - } - - /** A stream that never yields a request. */ - requests(): Requests { - return { next: async () => undefined, close: () => {} }; - } -} diff --git a/js/net/src/auth_session.ts b/js/net/src/auth_session.ts index dc326b35b9c..af77848b076 100644 --- a/js/net/src/auth_session.ts +++ b/js/net/src/auth_session.ts @@ -1,20 +1,51 @@ -import { Instant, Nano, now, wall } from "@moq/clock"; +import { Instant, Milli, Nano, now, sleep, wall } from "@moq/clock"; import { type Dispose, type Getter, Once, Signal } from "@moq/signals"; -import { - type Auth, - type Grant, - grantsEqual, - type Handle, - type Issued, - type Request, - type Requests, - Unsupported, -} from "./auth.ts"; +import type { Auth, Grant, Issued, Request, Requests } from "./auth.ts"; import { closeReason, error, ProtocolViolation, SessionCode, SessionError, StreamCode, StreamError } from "./error.ts"; import * as Path from "./path.ts"; import type { Stream } from "./stream.ts"; import { nextTask } from "./util/yield.ts"; +/** Whether two grants allow the same paths and lapse together. */ +export function grantsEqual(a: Grant | undefined, b: Grant | undefined): boolean { + if (a === undefined || b === undefined) return a === b; + return a.publish.equals(b.publish) && a.subscribe.equals(b.subscribe) && a.expires === b.expires; +} + +/** 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"; + } +} + +/** A handle already backs a session that is open and not draining. */ +export class Duplicate extends Error { + constructor(message = "auth handle already backs an active session") { + super(message); + this.name = "Duplicate"; + } +} + +// How long a token refused for a reason that may pass waits before its session presents it +// again, doubling on each refusal up to RETRY_MAX. +const RETRY_INITIAL = Milli(1000); +const RETRY_MAX = Milli(30_000); + +// Whether a refusal is the acceptor failing to decide rather than its verdict on the token, +// such as an unreachable auth server or a full verification queue. +function transient(err: Error | null): boolean { + return ( + err instanceof SessionError && + (err.code === SessionCode.Internal || + err.code === SessionCode.TooManyRequests || + err.code === SessionCode.Timeout) + ); +} + /** A grant as the wire carries it: the expiry is milliseconds from now. @internal */ export interface WireGrant { publish: Path.Patterns; @@ -69,27 +100,48 @@ function transportClosed(err: unknown): boolean { function cancel(): StreamError { return new StreamError(StreamCode.Cancel, { message: "cancel" }); } +/** One token as one session presents it. */ +class Presented { + readonly token: Uint8Array; + /** The session's own credential, presented at setup on this session alone. */ + readonly setup: boolean; + /** The handle's token this presents, or undefined for the session's own credential. */ + readonly owner?: Token; + + /** The latest grant: undefined before the first, during a retry, and once it ends here. */ + grant?: Grant; + stream?: Stream; + /** Wakes a retry backoff early once the presentation is no longer wanted. */ + retry?: AbortController; + withdrawn = false; + isAnswered = false; + ended = false; + + constructor(token: Uint8Array, setup: boolean, owner?: Token) { + this.token = token; + this.setup = setup; + this.owner = owner; + } +} -/** One token this side presented. */ -class Presented implements Auth { +/** A token the application added to a {@link Handle}, presented on every session of it. */ +class Token implements Auth { readonly grant = new Signal(undefined); readonly closed: Promise; readonly answered: Promise; - readonly setup: boolean; - readonly token: Uint8Array; + readonly bytes: Uint8Array; - stream?: Stream; - withdrawn = false; isAnswered = false; ended = false; + #withdraw: (token: Token) => void; #close!: (err: Error | null) => void; #answer!: () => void; #refuse!: (err: Error) => void; - constructor(token: Uint8Array, setup: boolean) { - this.token = token; - this.setup = setup; + constructor(bytes: Uint8Array, withdraw: (token: Token) => void) { + this.bytes = bytes; + this.#withdraw = withdraw; this.closed = new Promise((resolve) => { this.#close = resolve; }); @@ -119,13 +171,188 @@ class Presented implements Auth { } close() { - if (this.withdrawn) return; - this.withdrawn = true; - // The stream's loop notices and ends the token; one still opening checks on arrival. - // One without a stream to come (carried only by requests) ends here. - if (this.stream) this.stream.abort(cancel()); - else if (this.isAnswered) this.end(null); + this.#withdraw(this); + } +} + +/** The state behind one {@link Handle}: the tokens it presents, and every session it backs. */ +class HandleState { + /** The live session's union. */ + readonly grant = new Signal(undefined); + /** Created by a session for itself, so it ends with that session. */ + own = false; + /** Set once the handle ends. */ + closed?: Error; + /** Every token the application added that has not ended. */ + readonly tokens = new Set(); + /** Every open session built on this handle, oldest first. */ + readonly sessions: AuthSession[] = []; + acceptor: "undecided" | "default" | RequestQueue = "undecided"; + + /** The session the handle backs now: the newest one still open. */ + get live(): AuthSession | undefined { + return this.sessions.at(-1); + } + + /** Fail unless a new session can be built on the handle. */ + vacant() { + if (this.closed) throw this.closed; + if (this.sessions.some((session) => session.active)) throw new Duplicate(); + } + + attach(session: AuthSession) { + this.vacant(); + this.sessions.push(session); + } + + /** A session closed: it presents nothing anymore. A handle of its own ends with it. */ + detach(session: AuthSession, err: Error) { + const index = this.sessions.indexOf(session); + if (index >= 0) this.sessions.splice(index, 1); + if (this.own) this.abort(err); + this.refresh(); + } + + /** Point the handle's grant, and each token's, at the live session. */ + refresh() { + const live = this.live; + const union = live?.grant.peek(); + if (!grantsEqual(union, this.grant.peek())) this.grant.set(union); + for (const token of this.tokens) { + const grant = live?.grantOf(token); + if (!grantsEqual(grant, token.grant.peek())) token.grant.set(grant); + } + } + + add(bytes: Uint8Array): Token { + if (this.closed) throw this.closed; + const token = new Token(bytes, (token) => this.withdraw(token)); + this.tokens.add(token); + for (const session of [...this.sessions]) session.offer(token); + return token; + } + + /** The application dropped the token: every session stops presenting it. */ + withdraw(token: Token) { + if (!this.tokens.delete(token)) return; + for (const session of this.sessions) session.withdraw(token); + token.end(null); + } + + /** The peer's verdict, or the handle's end: the token ends on every session. */ + verdict(token: Token, err: Error | null) { + if (!this.tokens.delete(token)) return; + token.end(err); + for (const session of this.sessions) session.withdraw(token); } + + /** Decide who answers the peer's tokens, once: the application if it took them. */ + decide(): RequestQueue | undefined { + if (this.acceptor === "undecided") this.acceptor = "default"; + return this.acceptor instanceof RequestQueue ? this.acceptor : undefined; + } + + abort(err: Error) { + if (this.closed) return; + this.closed = err; + for (const token of [...this.tokens]) this.verdict(token, err); + if (this.acceptor instanceof RequestQueue) this.acceptor.close(); + } +} + +const states = new WeakMap(); + +function stateOf(handle: Handle): HandleState { + const state = states.get(handle); + if (!state) throw new TypeError("not an auth handle"); + return state; +} + +/** + * Fail unless a session can be built on `handle`: it ended, or it backs an active session. + * + * @internal + */ +export function vacant(handle: Handle) { + stateOf(handle).vacant(); +} + +/** + * The tokens this side presents and the grant they earn, across every session built on it. + * + * A session made without one creates its own, returned by `Established.auth`, which ends with + * that session. Pass one to `connect` as `auth` instead and it outlives its sessions: each + * presents every token the handle holds, so an {@link Auth} it returned keeps riding a route's + * announcements across a reconnect. It backs one active session at a time, plus the ones + * draining after a GOAWAY. Mirrors the Rust `moq_net::auth::Handle`. + */ +export class Handle { + /** A handle backing no session yet, for `connect`'s `auth` option. */ + constructor() { + states.set(this, new HandleState()); + } + + /** + * The union of every grant this side holds on the live session: undefined until its peer + * first answers a token, while no session is live, and forever on a version without AUTH. + */ + get grant(): Getter { + return stateOf(this).grant; + } + + /** + * Present another token on every session of this handle, resolving once a peer answers it. + * + * With no session live it waits for the next one. A refusal that may pass, such as an + * unreachable auth server, is presented again after a backoff on moq-lite. Rejects with + * {@link Unsupported} when a session neither carries AUTH nor lets a request carry a token + * (moq-lite without AUTH), with the peer's refusal as an `Error.Session`, or with the + * handle's end. On moq-transport without AUTH it resolves at once with no grant: only a + * request whose route names it carries it. + */ + async add(token: string | Uint8Array): Promise { + // A copy, so the caller reusing its buffer never changes what a request carries. + const bytes = typeof token === "string" ? new TextEncoder().encode(token) : token.slice(); + const added = stateOf(this).add(bytes); + await added.answered; + return added; + } + + /** + * Answer every token the peer presents, instead of the default: granting what this side + * publishes and consumes for the connection's own credential and refusing any other. + * + * Call it as soon as connect or accept resolves, in the same task: whoever answers is + * decided once, right after. Throws once that has happened. + * + * The peer's stream credit bounds how many tokens wait unanswered, since each holds its + * stream open until answered or withdrawn. A withdrawn token leaves the queue. + */ + requests(): Requests { + const state = stateOf(this); + if (state.acceptor !== "undecided") throw new Error("auth requests already taken or answered by default"); + const queue = new RequestQueue(); + // A session of its own that carries no AUTH never receives a token. + if (state.closed || (state.own && !state.live?.negotiated)) queue.close(); + state.acceptor = queue; + return queue; + } + + /** + * End the handle with `err`: every token ends, its sessions stop presenting them, and the + * peer's queued tokens are refused. A handle a session created for itself ends with that + * session. + */ + abort(err: Error = new Error("auth handle closed")): void { + stateOf(this).abort(err); + } +} + +/** A handle of a session's own, which ends with it. */ +function ownHandle(): Handle { + const handle = new Handle(); + stateOf(handle).own = true; + return handle; } /** The peer's token, answered by the application. */ @@ -265,28 +492,35 @@ export interface AuthSessionProps { /** How this session's wire carries the exchange, or undefined when it carries none. */ wire?: AuthWire; /** - * Whether a request can carry a token (moq-transport), so {@link AuthSession.add} keeps one - * the wire cannot present, for the announcements that name it. + * Whether a request can carry a token (moq-transport), so a token the wire cannot present + * is kept for the announcements that name it. */ requests?: boolean; /** What the default acceptor grants the peer's connection credential. */ peerGrant: Grant; + /** The handle the session is built on, or undefined for one of its own that ends with it. */ + handle?: Handle; + /** Whether the session is draining after a GOAWAY, which frees its handle for a successor. */ + draining?: () => boolean; } /** - * A session's tokens and grants: presents ours, one stream each, and answers the peer's. - * The wire decides only the encoding; see {@link AuthWire}. + * One session's side of a {@link Handle}: presents the handle's tokens and its own + * credential, one stream each, and answers the peer's. The wire decides only the encoding; + * see {@link AuthWire}. * * @internal */ -export class AuthSession implements Handle { +export class AuthSession { /** The peer sent a malformed AUTH message; the owning connection closes the session. */ readonly violated = new Once(); + /** The handle this session is built on. */ + readonly handle: Handle; + #state: HandleState; #wire?: AuthWire; #requests: boolean; #peerGrant: Grant; - // Every token this session issued, so a request carries only its own. - #issued = new WeakSet(); + #draining: () => boolean; #ended = new Signal(0); #union = new Signal(undefined); @@ -295,10 +529,11 @@ export class AuthSession implements Handle { #scope = new Signal(undefined); // The peer replied to some token, so the union is known even when empty. #replied = false; - #tokens = new Set(); + #presented = new Set(); + // The handle's tokens as this session presents them, so a request carries only those. + #byToken = new Map(); #setupPending = new Signal(0); #pending = new Signal(0); - #acceptor: "undecided" | "default" | RequestQueue = "undecided"; #closed = false; // Whoever answers the peer's tokens is decided once, after the task that established @@ -306,16 +541,28 @@ export class AuthSession implements Handle { // wins, however quickly the peer's first token arrives. #decided = nextTask(); - constructor({ wire, requests, peerGrant }: AuthSessionProps) { + /** Throws {@link Duplicate} while `handle` backs another active session, or the handle's end. */ + constructor({ wire, requests, peerGrant, handle, draining }: AuthSessionProps) { + this.handle = handle ?? ownHandle(); + this.#state = stateOf(this.handle); this.#wire = wire; this.#requests = requests ?? false; this.#peerGrant = peerGrant; + this.#draining = draining ?? (() => false); + this.#state.attach(this); // Present the connection's own credential right away, so both sides learn their // grant without waiting on the app. - if (wire) this.#present(wire, new Uint8Array(), true); + if (wire) this.#present(wire, new Presented(new Uint8Array(), true)); + for (const token of [...this.#state.tokens]) this.offer(token); } + /** Whether the session still takes new work: open, with no GOAWAY received. */ + get active(): boolean { + return !this.#closed && !this.#draining(); + } + + /** The union of this session's grants, undefined until its peer first answers. */ get grant(): Getter { return this.#union; } @@ -330,30 +577,59 @@ export class AuthSession implements Handle { return this.#wire !== undefined; } - async add(token: string | Uint8Array): Promise { - if (this.#closed || (!this.#wire && !this.#requests)) throw new Unsupported(); - // A copy, so the caller reusing its buffer never changes what a request carries. - const bytes = typeof token === "string" ? new TextEncoder().encode(token) : token.slice(); - if (!this.#wire) { - // Only a request carries it, and the peer judges it there: no grant to wait for. - const presented = new Presented(bytes, false); - presented.answer(); - this.#track(presented); - return presented; + /** The handle's `token` grant on this session. */ + grantOf(token: Token): Grant | undefined { + return this.#byToken.get(token)?.grant; + } + + /** Present one of the handle's tokens on this session. */ + offer(token: Token) { + if (this.#closed || this.#byToken.has(token)) return; + if (!this.#wire && !this.#requests) { + this.#state.verdict(token, new Unsupported()); + return; } - const presented = this.#present(this.#wire, bytes, false); - await presented.answered; - return presented; + const presented = new Presented(token.bytes, false, token); + this.#byToken.set(token, presented); + if (this.#wire) { + this.#present(this.#wire, presented); + return; + } + // Only a request carries it, and the peer judges it there: no grant to wait for. + presented.isAnswered = true; + this.#presented.add(presented); + token.answer(); + } + + /** Stop presenting one of the handle's tokens: withdrawn, or ended for good elsewhere. */ + withdraw(token: Token) { + const presented = this.#byToken.get(token); + if (!presented || presented.withdrawn) return; + presented.withdrawn = true; + presented.grant = undefined; + // The stream's loop notices and ends the presentation; one still opening checks on + // arrival, and one waiting to retry wakes. One without a stream to come (carried only by + // requests) ends here. + if (presented.stream) presented.stream.abort(cancel()); + else if (presented.retry) presented.retry.abort(); + else if (!this.#wire) { + presented.ended = true; + this.#presented.delete(presented); + this.#byToken.delete(token); + this.#ended.update((n) => n + 1); + } + this.#recompute(); } /** - * The bytes a request carries for `token`: undefined for none, for one another session - * issued, or for one that ended. + * The bytes a request carries for `token`: undefined for none, for one another handle + * issued, or for one that ended here. */ carried(token: Auth | undefined): Uint8Array | undefined { - if (!this.#requests || !(token instanceof Presented) || !this.#issued.has(token) || token.ended) - return undefined; - return token.token; + if (!this.#requests || !(token instanceof Token)) return undefined; + const presented = this.#byToken.get(token); + if (!presented || presented.ended || presented.withdrawn) return undefined; + return presented.token; } /** Bumps whenever one of this session's tokens ends, so a request carrying it drops it. */ @@ -361,14 +637,6 @@ export class AuthSession implements Handle { return this.#ended; } - requests(): Requests { - if (this.#acceptor !== "undecided") throw new Error("auth requests already taken or answered by default"); - const queue = new RequestQueue(); - if (!this.#wire) queue.close(); - this.#acceptor = queue; - return queue; - } - /** Resolves once every token the session presented at setup has its first reply. */ async setupAnswered(): Promise { while (this.#setupPending.peek() > 0) await this.#setupPending.changed(); @@ -450,12 +718,12 @@ export class AuthSession implements Handle { throw new ProtocolViolation("malformed AUTH", { cause: err }); } await this.#decided; - if (this.#acceptor === "undecided") this.#acceptor = "default"; + const acceptor = this.#state.decide(); const issued = new IssuedGrant(stream, wire); - if (this.#acceptor instanceof RequestQueue) { + if (acceptor) { const request = new PeerRequest(token, issued); - this.#acceptor.push(request); + acceptor.push(request); } else if (token.byteLength > 0) { // Only the connection's own credential has a default answer; a token needs // someone to verify it. @@ -470,130 +738,178 @@ export class AuthSession implements Handle { if (ended instanceof ProtocolViolation) throw ended; } - /** End the session: fail every pending token and close the requests. */ - close() { + /** + * End the session: fail every pending token here, leaving the handle's tokens for the next + * session. A handle of the session's own ends with it, closing the requests. + */ + close(err: Error = new Error("session closed")) { if (this.#closed) return; this.#closed = true; this.#pending.set(0); - for (const token of this.#tokens) token.end(new Error("session closed")); - this.#tokens.clear(); + for (const presented of this.#presented) { + presented.ended = true; + presented.isAnswered = true; + presented.grant = undefined; + presented.retry?.abort(); + } + this.#presented.clear(); + this.#byToken.clear(); this.#recompute(); - if (this.#acceptor instanceof RequestQueue) this.#acceptor.close(); + this.#state.detach(this, err); } - #present(wire: AuthWire, token: Uint8Array, setup: boolean): Presented { - const presented = new Presented(token, setup); - this.#track(presented); - if (setup) this.#setupPending.update((n) => n + 1); - if (token.byteLength > 0) this.#pending.update((n) => n + 1); + #present(wire: AuthWire, presented: Presented) { + this.#presented.add(presented); + if (presented.setup) this.#setupPending.update((n) => n + 1); + if (presented.token.byteLength > 0) this.#pending.update((n) => n + 1); void this.#run(wire, presented); - return presented; } - /** Hold `token` until it ends, so the session's close ends it too. */ - #track(token: Presented) { - this.#tokens.add(token); - this.#issued.add(token); - void token.closed.then(() => { - this.#tokens.delete(token); - this.#ended.update((n) => n + 1); - }); - } - - async #run(wire: AuthWire, token: Presented) { + async #run(wire: AuthWire, presented: Presented) { let result: Error | null = null; let closed = false; - try { - const stream = await wire.present(token.token); - token.stream = stream; - if (token.withdrawn) throw cancel(); - - for (;;) { - let reply: WireReply | undefined; - try { - reply = await wire.read(stream); - } catch (err) { - if ( - err instanceof StreamError || - err instanceof SessionError || - err instanceof Unsupported || - transportClosed(err) - ) - throw err; - throw new ProtocolViolation("malformed AUTH reply", { cause: err }); - } - if (!reply) { - // The peer ended the grant without revoking it, or closed without ever - // answering. Close our side too, which is what settles its Issued.closed. - result = token.isAnswered ? null : new Unsupported(); + // The peer refused the last attempt with a reply, rather than losing its stream. + let refused = false; + // The peer granted the token at some point, so a clean close ends the grant. + let granted = false; + let delay: Milli | undefined; + for (;;) { + refused = false; + // Whether this attempt's stream got a grant, so a close is not the peer predating AUTH. + let answered = false; + try { + const stream = await wire.present(presented.token); + presented.stream = stream; + if (presented.withdrawn || presented.ended) throw cancel(); + + for (;;) { + let reply: WireReply | undefined; + try { + reply = await wire.read(stream); + } catch (err) { + if ( + err instanceof StreamError || + err instanceof SessionError || + err instanceof Unsupported || + transportClosed(err) + ) + throw err; + throw new ProtocolViolation("malformed AUTH reply", { cause: err }); + } + if (!reply) { + // The peer ended the grant without revoking it, or closed without ever + // answering. Close our side too, which is what settles its Issued.closed. + result = answered ? null : new Unsupported(); + stream.close(); + break; + } + if ("grant" in reply) { + const expires = reply.grant.expires === undefined ? undefined : wall() + reply.grant.expires; + presented.grant = { publish: reply.grant.publish, subscribe: reply.grant.subscribe, expires }; + answered = granted = true; + delay = undefined; + this.#replied = true; + this.#answered(presented); + this.#recompute(); + continue; + } + console.warn("auth token refused", reply.refused); + // A refused setup token leaves an empty union, not an unknown (unrestricted) + // one. A grant the acceptor could not tell, or never judged, is unknown, not empty. + const unjudged = + reply.refused instanceof Unsupported || + (reply.refused instanceof StreamError && reply.refused.code === StreamCode.GoingAway); + if (!unjudged) this.#replied = true; + result = reply.refused; + refused = true; stream.close(); break; } - if ("grant" in reply) { - const expires = reply.grant.expires === undefined ? undefined : wall() + reply.grant.expires; - token.grant.set({ publish: reply.grant.publish, subscribe: reply.grant.subscribe, expires }); - this.#replied = true; - this.#answered(token); - this.#recompute(); - continue; + } catch (err: unknown) { + closed = transportClosed(err); + if (presented.withdrawn || presented.ended) { + result = null; + } else if (!answered && err instanceof StreamError) { + // A peer that predates AUTH resets a stream type it does not know. + result = new Unsupported(); + } else { + result = error(err); + if (result instanceof ProtocolViolation && !this.#closed && this.violated.peek() === undefined) { + this.violated.set(result); + } + if (!this.#closed) console.warn("auth token ended", result); } - console.warn("auth token refused", reply.refused); - // A refused setup token leaves an empty union, not an unknown (unrestricted) - // one. A grant the acceptor could not tell, or never judged, is unknown, not empty. - const unjudged = - reply.refused instanceof Unsupported || - (reply.refused instanceof StreamError && reply.refused.code === StreamCode.GoingAway); - if (!unjudged) this.#replied = true; - result = reply.refused; - stream.close(); - break; } - } catch (err: unknown) { - closed = transportClosed(err); - if (token.withdrawn) { + presented.stream = undefined; + + // A refusal that may pass, such as an unreachable auth server: present the token + // again after a backoff while it is still wanted here. Only moq-lite's AUTH_ERROR + // carries such a code. + if (!refused || !transient(result) || this.#closed || presented.withdrawn) break; + presented.grant = undefined; + // Answered, so nothing waits on the token through the backoff. + this.#settle(presented); + this.#recompute(); + delay = delay === undefined ? RETRY_INITIAL : Milli(Math.min(delay * 2, RETRY_MAX)); + presented.retry = new AbortController(); + await sleep(Nano.fromMilli(delay), { signal: presented.retry.signal }).catch(() => void 0); + presented.retry = undefined; + if (this.#closed || presented.withdrawn || presented.ended) { + refused = false; result = null; - } else if (!token.isAnswered && err instanceof StreamError) { - // A peer that predates AUTH resets a stream type it does not know. - result = new Unsupported(); - } else { - result = error(err); - if (result instanceof ProtocolViolation && !this.#closed && this.violated.peek() === undefined) { - this.violated.set(result); - } - if (!this.#closed) console.warn("auth token ended", result); + break; } } + // Already ended by the session's close. + if (presented.ended) return; + presented.ended = true; + presented.grant = undefined; // A token that ends unanswered counts as answered for enforcement: its refusal is // the reply. - const unanswered = !token.isAnswered; - token.end(result); - if (unanswered && token.setup) this.#setupPending.update((n) => n - 1); - if (unanswered && token.token.byteLength > 0 && !this.#closed) this.#pending.update((n) => n - 1); - this.#tokens.delete(token); + this.#settle(presented); + this.#presented.delete(presented); + const owner = presented.owner; + if (owner && this.#byToken.get(owner) === presented) this.#byToken.delete(owner); + this.#ended.update((n) => n + 1); + // Only the peer's verdict ends one of the handle's tokens on every session: a refusal, + // Unsupported, or a clean close after a grant. Anything else leaves it for the next. + const verdict = + !this.#closed && + !presented.withdrawn && + (refused || result instanceof Unsupported || (result === null && granted)); + if (owner && verdict) this.#state.verdict(owner, result); // Closed before the union shrinks, so the scope never reads it as a revocation. if (closed) this.close(); this.#recompute(); } - #answered(token: Presented) { - if (token.isAnswered) return; - token.answer(); - if (token.setup) this.#setupPending.update((n) => n - 1); - if (token.token.byteLength > 0 && !this.#closed) this.#pending.update((n) => n - 1); + /** The peer granted `presented`: it counts as answered, and so does the handle's token. */ + #answered(presented: Presented) { + this.#settle(presented); + presented.owner?.answer(); + } + + /** Stop counting `presented` as unanswered, for enforcement and the setup wait. */ + #settle(presented: Presented) { + if (presented.isAnswered) return; + presented.isAnswered = true; + if (presented.setup) this.#setupPending.update((n) => n - 1); + if (presented.token.byteLength > 0 && !this.#closed) this.#pending.update((n) => n - 1); } #recompute() { const granted: Grant[] = []; - for (const token of this.#tokens) { - const grant = token.grant.peek(); - if (grant) granted.push(grant); + for (const presented of this.#presented) { + if (presented.grant) granted.push(presented.grant); } // Undefined until the first reply; an empty union afterwards grants nothing. - if (granted.length === 0 && !this.#replied) return; - const next = union(granted); - if (!grantsEqual(next, this.#union.peek())) this.#union.set(next); - if (!this.#closed && !grantsEqual(next, this.#scope.peek())) this.#scope.set(next); + if (granted.length > 0 || this.#replied) { + const next = union(granted); + if (!grantsEqual(next, this.#union.peek())) this.#union.set(next); + if (!this.#closed && !grantsEqual(next, this.#scope.peek())) this.#scope.set(next); + } + this.#state.refresh(); } } diff --git a/js/net/src/connection/connect.ts b/js/net/src/connection/connect.ts index e4b56b00f87..543041d8c48 100644 --- a/js/net/src/connection/connect.ts +++ b/js/net/src/connection/connect.ts @@ -1,5 +1,7 @@ import { Milli, Nano, sleep } from "@moq/clock"; import Session, { type Version as QmuxVersion } from "@moq/qmux"; +import type * as Auth from "../auth.ts"; +import { vacant } from "../auth_session.ts"; import { error, fromClose, ProtocolViolation, sessionCause } from "../error.ts"; import * as Ietf from "../ietf/index.ts"; import * as Lite from "../lite/index.ts"; @@ -202,6 +204,16 @@ export interface ConnectProps { /** The moq-transport extensions to offer, each on unless set to `false`. */ extensions?: Extensions.Extensions; + /** + * Build the session on `auth`, so it presents every token the handle holds and a route + * naming one of its `Auth`s keeps it across a reconnect. Omit to give the session a handle + * of its own, which ends with it. + * + * The handle backs one active session at a time, plus the ones draining after a GOAWAY: + * connecting another while one is still active rejects with {@link Auth.Duplicate}. + */ + auth?: Auth.Handle; + /** * Aborts the connection attempt with the signal's reason. An already-aborted * signal rejects before anything opens, and aborting after the connection is @@ -213,6 +225,8 @@ export interface ConnectProps { /** The per-session wiring shared by every negotiated protocol path. */ type SessionProps = { publish?: OriginConsumer; + /** The auth handle the session is built on, or undefined for one of its own. */ + handle?: Auth.Handle; /** Whether this side dialed; only the dialing side aborts on a publication its grant does not cover. */ client: boolean; /** The moq-transport extensions this side offers. */ @@ -238,6 +252,8 @@ export async function connect({ url, ...props }: ConnectProps): Promise(); @@ -264,6 +280,7 @@ export async function connect({ url, ...props }: ConnectProps): Promise, abort: Promise): Promise { const wiring: SessionProps = { publish: props.publish, + handle: props.auth, client: true, extensions: Extensions.offered(props.extensions), }; diff --git a/js/net/src/connection/established.ts b/js/net/src/connection/established.ts index eeddf689af8..f28fbedf941 100644 --- a/js/net/src/connection/established.ts +++ b/js/net/src/connection/established.ts @@ -33,6 +33,9 @@ export interface Established { * presents. On moq-lite-07-wip, and on moq-transport draft-17+ when both sides negotiate * MoQ Auth, each side presents its connection's credential right after setup. * Otherwise the grant stays undefined. + * + * The handle passed to `connect` as `auth`, or else one the session created for itself, + * which ends with it. */ readonly auth: Auth.Handle; diff --git a/js/net/src/connection/forward.test.ts b/js/net/src/connection/forward.test.ts index ed3350c345a..9a737265a5e 100644 --- a/js/net/src/connection/forward.test.ts +++ b/js/net/src/connection/forward.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, expect, jest, spyOn, test } from "bun:test"; import { Once } from "@moq/signals"; import * as Announce from "../announced.ts"; +import { AuthSession } from "../auth_session.ts"; import { type Consumer as BroadcastConsumer, Producer as BroadcastProducer } from "../broadcast.ts"; import * as Epoch from "../epoch.ts"; import { Route } from "../hop.ts"; @@ -49,7 +50,11 @@ class FakeSession { #die!: (reason: Error | null) => void; constructor() { - registerWire(this, { consume: (path) => this.consume(path), goaway: new Once() }); + registerWire(this, { + consume: (path) => this.consume(path), + goaway: new Once(), + auth: new AuthSession({ peerGrant: { publish: new Path.Patterns(), subscribe: new Path.Patterns() } }), + }); this.closed = new Promise((resolve) => { this.#die = resolve; }); diff --git a/js/net/src/connection/migrate.test.ts b/js/net/src/connection/migrate.test.ts index 46674c8f356..0b587ee8bb8 100644 --- a/js/net/src/connection/migrate.test.ts +++ b/js/net/src/connection/migrate.test.ts @@ -271,3 +271,64 @@ test("a redirect onto an already pooled key leaves both entries to their handles resident.close(); } }); + +test("the connection's tokens ride every session across a GOAWAY", async () => { + // Every dial gets a lite-07 session that grants each token it is presented. + const relays: { server: WebTransport; tokens: string[]; withdrawn: string[] }[] = []; + const stub = function StubWebTransport(dialed: string | URL) { + const pair = createMockTransportPair(Lite.ALPN_07_WIP); + const relay = { server: pair.server, tokens: [] as string[], withdrawn: [] as string[] }; + relays.push(relay); + void accept({ transport: pair.server, url: new URL(dialed) }).then((session) => { + const requests = session.auth.requests(); + void (async () => { + for (;;) { + const request = await requests.next(); + if (!request) break; + const token = new TextDecoder().decode(request.token); + relay.tokens.push(token); + const issued = request.accept({ + publish: new Path.Patterns([Path.Pattern.all()]), + subscribe: new Path.Patterns(), + }); + void issued.closed.then(() => relay.withdrawn.push(token)); + } + })(); + }); + return pair.client; + }; + globalThis.WebTransport = stub as unknown as typeof WebTransport; + + const reload = new Reload({ + url, + websocket: { enabled: false }, + versions: [Lite.ALPN_07_WIP], + goaway: { handover }, + delay: { initial: Milli(1) }, + }); + const token = await reload.auth.add("token"); + const first = relays[0]; + if (!first) throw new Error("no first dial"); + expect(first.tokens).toContain("token"); + + const stream = await Stream.open(first.server, { version: Lite.Version.DRAFT_07 }); + await stream.writer.u53(Lite.StreamId.Goaway); + await new Lite.Goaway("").encode(stream.writer, Lite.Version.DRAFT_07); + + // The replacement presents it with no help from the application, while the draining + // session keeps presenting it beside its successor. + await waitUntil(() => relays[1]?.tokens.includes("token") === true); + await waitUntil(() => token.grant.peek() !== undefined && reload.established.peek() !== undefined); + await settle(); + expect(first.withdrawn).not.toContain("token"); + let ended = false; + void token.closed.then(() => { + ended = true; + }); + await settle(); + expect(ended).toBe(false); + + // The tokens end with the loop. + reload.close(); + expect(await token.closed).toBeInstanceOf(Error); +}); diff --git a/js/net/src/connection/pool.ts b/js/net/src/connection/pool.ts index 66b7dc6ec17..fb4cca8a423 100644 --- a/js/net/src/connection/pool.ts +++ b/js/net/src/connection/pool.ts @@ -7,6 +7,7 @@ import { Milli, Nano } from "@moq/clock"; import { Effect, type GetPromise, type Getter, Once, Signal } from "@moq/signals"; import * as Announce from "../announced.ts"; +import type * as Auth from "../auth.ts"; import type { Handle } from "../bandwidth.ts"; import * as Origin from "../origin.ts"; import * as Path from "../path.ts"; @@ -179,6 +180,15 @@ export class Connection { */ readonly origin: Getter; + /** + * The auth handle for the current URL, or undefined while disabled or URL-less. + * + * Every session the connection makes presents the tokens it holds, so a route naming one + * keeps it across a reconnect, and its grant follows the live session. A shared handle's + * is the same one every other handle on this URL uses, since they share the session. + */ + readonly auth: Getter; + /** * Settles once this handle is released via {@link close}: `null` on a clean close, or * the abort {@link Error}. Peek it synchronously (`undefined` while open), observe it @@ -194,6 +204,7 @@ export class Connection { readonly #probe = new Signal | undefined>(undefined); readonly #origin = new Signal(undefined); readonly #bandwidth = new Signal(undefined); + readonly #auth = new Signal(undefined); readonly #error = new Signal(undefined); #signals: Effect; @@ -217,6 +228,7 @@ export class Connection { this.probe = new Follow(this.#probe); this.origin = this.#origin; this.bandwidth = this.#bandwidth; + this.auth = this.#auth; this.transport = this.#signals.computed((effect) => effect.get(this.#established)?.transport); const linger = props?.linger; @@ -240,6 +252,7 @@ export class Connection { effect.set(this.#origin, lease.origin, undefined); effect.set(this.#bandwidth, lease.connection.bandwidth, undefined); + effect.set(this.#auth, lease.connection.auth, undefined); // Proxy the shared loop's outputs, so this handle reads like its own connection. effect.run((nested) => nested.set(this.#status, nested.get(lease.connection.status), "disconnected")); @@ -271,6 +284,7 @@ export class Connection { if (!effect.get(this.enabled) || !effect.get(href)) return; effect.set(this.#origin, origin, undefined); effect.set(this.#bandwidth, loop.bandwidth, undefined); + effect.set(this.#auth, loop.auth, undefined); }); this.#signals.run((effect) => effect.set(this.#status, effect.get(loop.status), "disconnected")); this.#signals.run((effect) => effect.set(this.#established, effect.get(loop.established), undefined)); diff --git a/js/net/src/connection/reload.ts b/js/net/src/connection/reload.ts index 8d5bab18ca0..1797fd38ce3 100644 --- a/js/net/src/connection/reload.ts +++ b/js/net/src/connection/reload.ts @@ -1,6 +1,7 @@ import { Instant, Milli, Nano, now, sleep } from "@moq/clock"; import { Effect, type GetPromise, type Getter, Once, Signal } from "@moq/signals"; import * as Announce from "../announced.ts"; +import { Handle as AuthHandle } from "../auth.ts"; import { Allocator } from "../bandwidth.ts"; import { error, SessionCode, SessionError } from "../error.ts"; import type { Consumer as OriginConsumer, Producer as OriginProducer } from "../origin.ts"; @@ -142,6 +143,14 @@ export class Reload { */ readonly bandwidth: Allocator; + /** + * The auth handle every session of the loop is built on, spanning reconnects. + * + * Each session presents every token it holds, so a route naming one keeps it across a + * reconnect, and its grant follows the live session. It ends when the loop closes. + */ + readonly auth = new AuthHandle(); + /** Protocol versions to offer, most preferred first, kept across retries and redirects. */ readonly versions?: readonly string[]; @@ -403,6 +412,7 @@ export class Reload { webtransport: this.webtransport, publish: this.publish, consume: this.consume, + auth: this.auth, signal, }); } finally { @@ -639,6 +649,8 @@ export class Reload { close(abort?: Error) { this.#signals.close(); this.#draining?.retire(); + // No session will present the tokens again, so they end with the loop. + this.auth.abort(abort ?? new Error("connection closed")); if (this.#closed.peek() === undefined) this.#closed.set(abort ?? null); } } diff --git a/js/net/src/ietf/connection.ts b/js/net/src/ietf/connection.ts index 9d50a2bbe0c..00782ee6ff3 100644 --- a/js/net/src/ietf/connection.ts +++ b/js/net/src/ietf/connection.ts @@ -62,7 +62,7 @@ export class Connection implements Established { /** Our tokens and grants, when the peer negotiated MoQ Auth; see {@link Established.auth}. */ get auth(): Auth.Handle { - return this.#auth; + return this.#auth.handle; } // Our tokens and grants, and the answers to the peer's (MoQ Auth). @@ -128,6 +128,7 @@ export class Connection implements Established { activeCount = false, cluster, auth = false, + handle, epoch = false, early = [], requestWindow, @@ -160,6 +161,8 @@ export class Connection implements Established { cluster?: Cluster.Hops; /** Whether MoQ Auth is negotiated (draft-17+): offered by both SETUPs. */ auth?: boolean; + /** The auth handle to build the session on, or undefined for one of its own. */ + handle?: Auth.Handle; /** Whether MoQ Epoch is negotiated (draft-17+): offered by both SETUPs. */ epoch?: boolean; /** Uni streams that arrived before the peer's SETUP, type unread (v17+). */ @@ -214,7 +217,13 @@ export class Connection implements Established { publish: new Path.Patterns([Path.Pattern.all()]), subscribe: new Path.Patterns(publish ? [Path.Pattern.all()] : []), }, + handle, + draining: () => this.#goaway.peek() !== undefined, }); + // Left before anything observes the close, so a successor on the same handle never + // finds this session still active. + const leave = () => this.#auth.close(); + void quic.closed.then(leave, leave); this.#client = client; this.#auth.violated.subscribe((err) => { @@ -251,7 +260,11 @@ export class Connection implements Established { grant: this.#auth.scope, auth: this.#auth, }); - registerWire(this, { consume: (path) => this.#subscriber.consume(path), goaway: this.#goaway }); + registerWire(this, { + consume: (path) => this.#subscriber.consume(path), + goaway: this.#goaway, + auth: this.#auth, + }); void this.#run(early); } diff --git a/js/net/src/lite/connection.ts b/js/net/src/lite/connection.ts index 86dc40c6bc3..2765ee19d95 100644 --- a/js/net/src/lite/connection.ts +++ b/js/net/src/lite/connection.ts @@ -55,6 +55,8 @@ export interface ConnectionProps { session?: Stream; /** The origin whose broadcasts are served to the peer. Omit to publish nothing. */ publish?: OriginConsumer; + /** The auth handle to build the session on, or undefined for one of its own. */ + handle?: Auth.Handle; /** * Whether this side dialed. Only the dialing side aborts on a publication its grant does * not cover: a server publishes whatever the peer may read, not what it intends to push. @@ -140,7 +142,7 @@ export class Connection implements Established { * * @internal */ - constructor({ url, quic, version, session, publish, client = true }: ConnectionProps) { + constructor({ url, quic, version, session, publish, handle, client = true }: ConnectionProps) { this.url = url; this.#quic = quic; this.#session = session; @@ -164,7 +166,13 @@ export class Connection implements Established { publish: new Path.Patterns([Path.Pattern.all()]), subscribe: new Path.Patterns(publish ? [Path.Pattern.all()] : []), }, + handle, + draining: () => this.#goaway.peek() !== undefined, }); + // Left before anything observes the close, so a successor on the same handle never + // finds this session still active. + const leave = () => this.#auth.close(); + void quic.closed.then(leave, leave); this.#auth.violated.subscribe((err) => { if (!err) return; closeViolation(this.#quic, err); @@ -187,14 +195,18 @@ export class Connection implements Established { this.#auth, ); this.#client = client; - registerWire(this, { consume: (path) => this.#subscriber.consume(path), goaway: this.#goaway }); + registerWire(this, { + consume: (path) => this.#subscriber.consume(path), + goaway: this.#goaway, + auth: this.#auth, + }); void this.#run(); } /** Our tokens and grants; see {@link Established.auth}. */ get auth(): Auth.Handle { - return this.#auth; + return this.#auth.handle; } /** diff --git a/js/net/src/wire.ts b/js/net/src/wire.ts index 1ae5b386c53..4a9eb4143c4 100644 --- a/js/net/src/wire.ts +++ b/js/net/src/wire.ts @@ -8,6 +8,7 @@ * @module */ import type { Dispose, GetPromise, Getter } from "@moq/signals"; +import type { AuthSession } from "./auth_session.ts"; import type * as broadcast from "./broadcast.ts"; import type { Drain } from "./connection/goaway.ts"; import type * as Epoch from "./epoch.ts"; @@ -139,6 +140,9 @@ export interface Established { // private: plumbing /** Settles with the peer's GOAWAY; the session keeps serving until it closes. */ readonly goaway: GetPromise; + // private: plumbing + /** This session's side of its auth handle. */ + readonly auth: AuthSession; } type View = Broadcast | OriginProducer | OriginConsumer | Established; From 03d536c2c0079a21f1b57a88ed8f7c6670916fc1 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 17:30:19 -0700 Subject: [PATCH 07/31] fix(auth): keep the session's auth side out of the public d.ts Mark the wire view's auth field internal so the declaration build no longer imports an internal type, and satisfy clippy's field_reassign_with_default in the moq-tokio reconnect test. Co-Authored-By: Claude Opus 5.5 --- js/net/src/wire.ts | 9 ++++++--- rs/moq-tokio/src/connection.rs | 6 ++++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/js/net/src/wire.ts b/js/net/src/wire.ts index 4a9eb4143c4..0047c11ca0f 100644 --- a/js/net/src/wire.ts +++ b/js/net/src/wire.ts @@ -8,7 +8,6 @@ * @module */ import type { Dispose, GetPromise, Getter } from "@moq/signals"; -import type { AuthSession } from "./auth_session.ts"; import type * as broadcast from "./broadcast.ts"; import type { Drain } from "./connection/goaway.ts"; import type * as Epoch from "./epoch.ts"; @@ -141,8 +140,12 @@ export interface Established { /** Settles with the peer's GOAWAY; the session keeps serving until it closes. */ readonly goaway: GetPromise; // private: plumbing - /** This session's side of its auth handle. */ - readonly auth: AuthSession; + /** + * This session's side of its auth handle. + * + * @internal + */ + readonly auth: import("./auth_session.ts").AuthSession; } type View = Broadcast | OriginProducer | OriginConsumer | Established; diff --git a/rs/moq-tokio/src/connection.rs b/rs/moq-tokio/src/connection.rs index 104f7a889d2..5108ab5ad80 100644 --- a/rs/moq-tokio/src/connection.rs +++ b/rs/moq-tokio/src/connection.rs @@ -2416,8 +2416,10 @@ mod tests { let mut listener = config.init(Default::default()).unwrap().listen().await.unwrap(); let addr = listener.tcp_local_addr().unwrap(); - let mut config = crate::connect::Config::default(); - config.version = vec![version]; + let mut config = crate::connect::Config { + version: vec![version], + ..Default::default() + }; config.backoff.initial = Some(MIN_BACKOFF); config.backoff.max = Some(MIN_BACKOFF); let connection = config From 52729c2aa9d9cfd5ab82282393398842d4280a05 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 17:30:22 -0700 Subject: [PATCH 08/31] quest: finish m1/auth/issuer Co-Authored-By: Claude Opus 5.5 --- quest/m0/audit-quests.md | 4 --- quest/m1/auth/README.md | 1 - quest/m1/auth/issuer.md | 55 ---------------------------------------- 3 files changed, 60 deletions(-) delete mode 100644 quest/m1/auth/issuer.md diff --git a/quest/m0/audit-quests.md b/quest/m0/audit-quests.md index 556e8afcf50..2a2ea913f41 100644 --- a/quest/m0/audit-quests.md +++ b/quest/m0/audit-quests.md @@ -64,10 +64,6 @@ Checkpoint, 2026-10-19, against `main` at `2e1ec2ebf`: This run remains open, with no successor, until the unresolved conflicts are settled. The unattended instruction was to skip uncertain choices: -- [Auth issuer](/quest/m1/auth/issuer.md) specifies a shared handle and one - live session's grant, but not how independent concurrent sessions select - grants and refusals. Existing reconnect code only designates one active - generation alongside draining predecessors. - [Binding linger](/quest/m1/linger-ffi.md) extends the hand-written C ABI, while [generated C](/quest/m1/c/README.md) freezes it except claim safety. - [Catalog HLS](/quest/m1/archive/catalog-hls.md) names `.hls.z` tracks, diff --git a/quest/m1/auth/README.md b/quest/m1/auth/README.md index 17f6a6024d9..56adfa5c3a1 100644 --- a/quest/m1/auth/README.md +++ b/quest/m1/auth/README.md @@ -144,7 +144,6 @@ because `main` assigned 0x3A to NOT_FETCHABLE first. ## Required -- [Auth outlives its session](/quest/m1/auth/issuer.md) - one `auth::Handle` spans a connection's sessions, so a route's auth survives a reconnect - [Bindings](/quest/m1/auth/bindings.md) - grants and tokens reach every binding through moq-ffi - [Token in band](/quest/m1/auth/token-in-band.md) - extra credentials ride diff --git a/quest/m1/auth/issuer.md b/quest/m1/auth/issuer.md deleted file mode 100644 index 98b69e5bcb7..00000000000 --- a/quest/m1/auth/issuer.md +++ /dev/null @@ -1,55 +0,0 @@ -# [L] Auth outlives its session - -## Goal - -A route on a reconnecting connection names its token once: the same -`moq_net::auth::Auth` keeps riding its announcements on every session the -connection makes, with no re-add or re-announce from the application. - -## Plan - -- Today a `moq_net::auth::Auth` belongs to the session whose `auth::Handle` - added it, and `Route::auth` carries it only on that session, so after a - reconnect the application adds the token again on the new session and - re-announces. `moq_tokio::Connection::auth().add()` keeps a token across - sessions, but it returns moq-tokio's own `connection::auth::Auth`, which a - `Route` cannot name. -- ✅ Decided 2026-10-16 (maintainer, #5695): the long-term shape is a - session-independent issuer in moq-net. An `auth::Handle` outlives sessions - and is passed in with something like `Client::with_auth(handle)` (and the - `Server` equivalent). Each session built on it presents every token the - handle holds, and a `moq_net::auth::Auth` it issued is carried by every - session of that handle. This revisits the "bound to the handle that issued - it" decision of [#5695](https://github.com/moq-dev/moq/pull/5695): the - binding stays, but the handle spans sessions. -- moq-tokio's replay loop moves into moq-net. Today - `rs/moq-tokio/src/connection/auth.rs` spawns a task per added token that - presents it on each new session (`present`, with backoff on a transient - refusal and a final stop on `Unauthorized`). With the handle in moq-net, - `Connection::auth()` returns that `moq_net::auth::Handle`, and - `connection::auth::{Handle, Auth}` go away. -- Decided 2026-10-20 (maintainer): a handle backs one active session at a - time, plus the predecessors it is draining, matching the reconnect code's - single active generation. Attaching a handle to a second concurrent session - fails loudly; independent concurrent sessions use separate handles, so an - `Auth` never has to pick between two live grants. -- An `Auth`'s `grant()` and `closed()` follow the session that is live now: the - grant clears while disconnected and returns once the new session's peer - answers, and `closed()` settles only on a refusal or when the handle ends. -- The ietf publisher's check "presented on this session" (the route's `Auth` - was issued by this session's handle) keeps working unchanged once the handle - is shared, so a route survives a reconnect with no re-announce from the app. -- Mirror in `js/net`: the JS `Handle` lives on the reconnecting connection - rather than one session, and `Route.auth` follows it. -- Update `doc/concept/moq-lite.md`, `doc/concept/standard.md` and - `doc/lib/js/net.md`, which today say a reconnect means adding the token - again and re-announcing. - -Public API: breaking in moq-net (`Handle` construction and the `Client`/`Server` -builders), moq-tokio (the `connection::auth` types go away), and js/net. Wire: -none. - -## Related - -- [#5695](https://github.com/moq-dev/moq/pull/5695) - adds `Route::auth`, bound to one session's handle -- [Bindings](/quest/m1/auth/bindings.md) - exposes `Connection::auth()` through moq-ffi, so it should follow this shape From 88e31cd9fde590032b7ad04e1fa4ec1392f78a93 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 17:34:01 -0700 Subject: [PATCH 09/31] fix(moq-net): build the auth link without the async feature Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/auth.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index 1a5376b5b3c..868e4c61203 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -791,6 +791,7 @@ impl Link { } /// The handle this session is built on. + #[cfg_attr(not(feature = "async"), allow(dead_code))] pub(crate) fn handle(&self) -> &Handle { &self.handle } @@ -825,6 +826,7 @@ impl Link { /// Retire the session to draining once `goaway` is sent or received, freeing the /// handle for a replacement. + #[cfg_attr(not(feature = "async"), allow(dead_code))] pub(crate) fn drain_with(&self, goaway: std::sync::Arc) { self.with(|link| link.goaway = Some(goaway)); } From bd0f1c5329764d1179f80b86a6c3ce176fa3c7fe Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 17:55:42 -0700 Subject: [PATCH 10/31] test(js/net): drive the GOAWAY token test with a manual clock Co-Authored-By: Claude Opus 5.5 --- js/net/src/connection/migrate.test.ts | 68 +++++++++++++++++---------- 1 file changed, 43 insertions(+), 25 deletions(-) diff --git a/js/net/src/connection/migrate.test.ts b/js/net/src/connection/migrate.test.ts index 0b587ee8bb8..e6072dca4be 100644 --- a/js/net/src/connection/migrate.test.ts +++ b/js/net/src/connection/migrate.test.ts @@ -1,11 +1,12 @@ import { afterEach, expect, test } from "bun:test"; -import { Milli } from "@moq/clock"; +import { Manual, Milli, Nano } from "@moq/clock"; import { RefusedRedirect } from "../error.ts"; import * as Lite from "../lite/index.ts"; import { createMockTransportPair } from "../mock.ts"; import { Producer as OriginProducer } from "../origin.ts"; import * as Path from "../path.ts"; import { Stream } from "../stream.ts"; +import { nextTask } from "../util/yield.ts"; import { accept } from "./accept.ts"; import { Connection, resetShared } from "./pool.ts"; import { Reload } from "./reload.ts"; @@ -273,6 +274,18 @@ test("a redirect onto an already pooled key leaves both entries to their handles }); test("the connection's tokens ride every session across a GOAWAY", async () => { + const clock = new Manual(); + const restore = clock.install(); + // Bounded by task turns rather than wall time, advancing the clock a millisecond per turn + // so the reconnect's delay passes long before the draining session's handover. + const until = async (done: () => boolean) => { + for (let i = 0; i < 100 && !done(); i++) { + await nextTask(); + clock.advance(Nano.fromMilli(Milli(1))); + } + expect(done()).toBe(true); + }; + // Every dial gets a lite-07 session that grants each token it is presented. const relays: { server: WebTransport; tokens: string[]; withdrawn: string[] }[] = []; const stub = function StubWebTransport(dialed: string | URL) { @@ -306,29 +319,34 @@ test("the connection's tokens ride every session across a GOAWAY", async () => { goaway: { handover }, delay: { initial: Milli(1) }, }); - const token = await reload.auth.add("token"); - const first = relays[0]; - if (!first) throw new Error("no first dial"); - expect(first.tokens).toContain("token"); - - const stream = await Stream.open(first.server, { version: Lite.Version.DRAFT_07 }); - await stream.writer.u53(Lite.StreamId.Goaway); - await new Lite.Goaway("").encode(stream.writer, Lite.Version.DRAFT_07); - - // The replacement presents it with no help from the application, while the draining - // session keeps presenting it beside its successor. - await waitUntil(() => relays[1]?.tokens.includes("token") === true); - await waitUntil(() => token.grant.peek() !== undefined && reload.established.peek() !== undefined); - await settle(); - expect(first.withdrawn).not.toContain("token"); - let ended = false; - void token.closed.then(() => { - ended = true; - }); - await settle(); - expect(ended).toBe(false); + try { + const token = await reload.auth.add("token"); + const first = relays[0]; + if (!first) throw new Error("no first dial"); + expect(first.tokens).toContain("token"); + + const stream = await Stream.open(first.server, { version: Lite.Version.DRAFT_07 }); + await stream.writer.u53(Lite.StreamId.Goaway); + await new Lite.Goaway("").encode(stream.writer, Lite.Version.DRAFT_07); + + // The replacement presents it with no help from the application, while the draining + // session keeps presenting it beside its successor. + await until(() => relays[1]?.tokens.includes("token") === true); + await until(() => token.grant.peek() !== undefined && reload.established.peek() !== undefined); + await nextTask(); + expect(first.withdrawn).not.toContain("token"); + let ended = false; + void token.closed.then(() => { + ended = true; + }); + await nextTask(); + expect(ended).toBe(false); - // The tokens end with the loop. - reload.close(); - expect(await token.closed).toBeInstanceOf(Error); + // The tokens end with the loop. + reload.close(); + expect(await token.closed).toBeInstanceOf(Error); + } finally { + reload.close(); + restore(); + } }); From 3ed59324f68b9cd2f6c35ee01a778fdb52bffd86 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 12:50:53 -0700 Subject: [PATCH 11/31] fix(auth): a draining session takes no new tokens A token added after a GOAWAY was offered to the draining session too: a draining moq-lite session without AUTH refused it as Unsupported, which ended it on the successor, and in JS a draining session opened a fresh AUTH stream for it. Offer new tokens only to active sessions, and in JS stop a transient retry once the session drains. JS also points the handle's grants at the successor as soon as it attaches. Co-Authored-By: Claude Opus 5.5 --- js/net/src/auth.test.ts | 41 +++++++++++++++++++++++++++++++++++++- js/net/src/auth_session.ts | 15 ++++++++++++-- rs/moq-net/src/auth.rs | 23 ++++++++++++++++++++- 3 files changed, 75 insertions(+), 4 deletions(-) diff --git a/js/net/src/auth.test.ts b/js/net/src/auth.test.ts index ae5295e9e05..d5e3549b965 100644 --- a/js/net/src/auth.test.ts +++ b/js/net/src/auth.test.ts @@ -15,7 +15,7 @@ import * as LiteMessage from "./lite/message.ts"; import { createMockTransportPair, type MockTransport } from "./mock.ts"; import { Producer as OriginProducer } from "./origin.ts"; import * as Path from "./path.ts"; -import { type Stream, Writer } from "./stream.ts"; +import { Stream, Writer } from "./stream.ts"; import { abandoned } from "./util/demand.ts"; import { withTimeout } from "./util/timeout.ts"; import { nextTask } from "./util/yield.ts"; @@ -715,6 +715,45 @@ test("lite-07 presents a transiently refused token again after a backoff", async } }); +test("lite-07 presents a token added after a GOAWAY only on the successor", async () => { + const protocol = Lite.ALPN_07_WIP; + const handle = new Handle(); + const pair = createMockTransportPair(protocol); + const [client, server] = await Promise.all([ + connectSession({ url, transport: pair.client, publish: new OriginProducer().consume(), auth: handle }), + acceptSession({ transport: pair.server, url }), + ]); + const seen: string[] = []; + answer(server.auth.requests(), (token) => { + seen.push(token); + return token ? grant(["first"], []) : grant([], []); + }); + const token = await handle.add("token"); + await waitFor(handle.grant, (g) => g?.publish.equals(patterns("first")) === true); + + const stream = await Stream.open(pair.server, { version: Lite.Version.DRAFT_07 }); + await stream.writer.u53(Lite.StreamId.Goaway); + await new Lite.Goaway("").encode(stream.writer, Lite.Version.DRAFT_07); + await settles(() => !wireOf(client).auth.active); + + const late = handle.add("late"); + const successor = await connect({ publish: new OriginProducer(), protocol, auth: handle }); + // The successor has not answered yet, so its predecessor's grant no longer applies. + expect(handle.grant.peek()).toBeUndefined(); + expect(token.grant.peek()).toBeUndefined(); + + answer(successor.server.auth.requests(), (token) => (token ? grant(["second"], []) : grant([], []))); + await late; + await waitFor(handle.grant, (g) => g?.publish.equals(patterns("second")) === true); + for (let i = 0; i < 10; i++) await nextTask(); + expect(seen).not.toContain("late"); + + client.abort(); + server.abort(); + successor.client.abort(); + successor.server.abort(); +}); + // moq-lite carries patterns, so every grant arrives exactly as issued. test("a fetch abandoned while its token waits never reaches the wire", async () => { const logged = spyOn(console, "debug"); diff --git a/js/net/src/auth_session.ts b/js/net/src/auth_session.ts index af77848b076..b35fe434e3b 100644 --- a/js/net/src/auth_session.ts +++ b/js/net/src/auth_session.ts @@ -203,6 +203,8 @@ class HandleState { attach(session: AuthSession) { this.vacant(); this.sessions.push(session); + // The successor is live now, so its predecessor's grants no longer apply. + this.refresh(); } /** A session closed: it presents nothing anymore. A handle of its own ends with it. */ @@ -582,9 +584,12 @@ export class AuthSession { return this.#byToken.get(token)?.grant; } - /** Present one of the handle's tokens on this session. */ + /** + * Present one of the handle's tokens on this session. A draining session takes no new + * tokens, so its verdict never ends one its successor carries. + */ offer(token: Token) { - if (this.#closed || this.#byToken.has(token)) return; + if (!this.active || this.#byToken.has(token)) return; if (!this.#wire && !this.#requests) { this.#state.verdict(token, new Unsupported()); return; @@ -859,6 +864,12 @@ export class AuthSession { result = null; break; } + // After a GOAWAY the peer must not see new streams: the successor presents it. + if (this.#draining()) { + refused = false; + result = new StreamError(StreamCode.GoingAway); + break; + } } // Already ended by the session's close. diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index 868e4c61203..4b16258a5d5 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -290,8 +290,11 @@ impl Attached { /// Present the handle's token `id` on this session, returning the answer when the /// session settles it at once: `Ok` when only a request can carry it, so nothing will /// answer, and [`Error::Unsupported`] when nothing can. + /// + /// A draining session takes no new tokens, so its verdict never ends one its successor + /// carries. fn offer(&mut self, id: u64, token: &Bytes) -> Option> { - if self.closed.is_some() { + if !self.active() { return None; } let answered = match (self.supported, self.requests) { @@ -2783,6 +2786,24 @@ mod tests { ); } + /// A token added while a session without AUTH drains rides only its successor, so the + /// draining session cannot end it as unsupported. + #[test] + fn a_draining_session_takes_no_new_token() { + let handle = Handle::new(); + let draining = Link::attach(&handle, false, false).unwrap(); + let (goaway, _protocol) = crate::goaway::Handle::new(false); + goaway.producer().send(crate::goaway::Goaway::new()).unwrap(); + draining.drain_with(std::sync::Arc::new(goaway)); + let successor = Link::attach(&handle, true, false).unwrap(); + + let auth = handle.present(Bytes::from_static(b"jwt")).unwrap(); + assert!(Selector::Token(auth.id()).ended(&handle.state.read()).is_none()); + let id = opened(&successor); + successor.granted(id, Grant::all()); + assert!(matches!(auth.poll_answered(&kio::Waiter::noop()), Poll::Ready(Ok(())))); + } + /// A refusal that may pass is presented again after a doubling backoff, and settles nothing. #[test] fn a_transient_refusal_backs_off() { From fad76b14ce24af0cecae7453d552393fbb10e594 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 13:20:26 -0700 Subject: [PATCH 12/31] fix(auth): a session that sent a GOAWAY presents no new AUTH stream The lite driver checked only a received GOAWAY before opening an AUTH stream, so a transient retry that woke after this side's own GOAWAY opened a fresh one on the draining session. Gate opens and retries on the link being active, which covers both directions. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/auth.rs | 29 +++++++++++++++++++++++++++-- rs/moq-net/src/lite/session.rs | 8 ++------ 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index 4b16258a5d5..bdcfe74f6e6 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -834,6 +834,12 @@ impl Link { self.with(|link| link.goaway = Some(goaway)); } + /// Whether the session still takes new work: open, with no GOAWAY sent or received. + #[cfg_attr(not(feature = "async"), allow(dead_code))] + pub(crate) fn active(&self) -> bool { + self.read(|link, _| link.active()) + } + /// Whether this side speaks AUTH: false on a version without it, on a side that does /// not offer it, and once the peer turns out not to. #[cfg_attr(not(feature = "async"), allow(dead_code))] @@ -1017,11 +1023,12 @@ impl Link { } /// The token to present again once a [`retry`](Self::retry) delay passes, or `None` - /// when it is no longer wanted here. + /// when it is no longer wanted here, or the session is draining and its successor + /// presents it instead. #[cfg_attr(not(feature = "async"), allow(dead_code))] pub(crate) fn reopen(&self, id: u64) -> Option { self.with(|link| { - if link.closed.is_some() { + if !link.active() { return None; } let slot = link.slots.get_mut(&id)?; @@ -2804,6 +2811,24 @@ mod tests { assert!(matches!(auth.poll_answered(&kio::Waiter::noop()), Poll::Ready(Ok(())))); } + /// A retry that wakes after this side sent a GOAWAY opens nothing: the successor presents it. + #[test] + fn a_draining_session_presents_no_retry() { + let link = Link::new(true); + let (goaway, _protocol) = crate::goaway::Handle::new(false); + let goaway = std::sync::Arc::new(goaway); + link.drain_with(goaway.clone()); + let _auth = link.present(Bytes::from_static(b"jwt"), false).unwrap(); + let id = opened(&link); + link.refused(id); + let busy = Error::Session(SessionError::TooManyRequests); + assert_eq!(link.retry(id, &busy), Some(RETRY_INITIAL)); + + goaway.producer().send(crate::goaway::Goaway::new()).unwrap(); + assert!(!link.active()); + assert_eq!(link.reopen(id), None); + } + /// A refusal that may pass is presented again after a doubling backoff, and settles nothing. #[test] fn a_transient_refusal_backs_off() { diff --git a/rs/moq-net/src/lite/session.rs b/rs/moq-net/src/lite/session.rs index 5dd49ff67f9..d5bc4571016 100644 --- a/rs/moq-net/src/lite/session.rs +++ b/rs/moq-net/src/lite/session.rs @@ -332,7 +332,6 @@ where session: session.clone(), version, handle: auth.clone(), - going_away: goaway.going_away.clone(), tokens: kio::Tasks::new(), violation: Default::default(), started: false, @@ -473,7 +472,6 @@ struct Present { session: S, version: Version, handle: crate::auth::Link, - going_away: crate::goaway::GoingAway, tokens: kio::Tasks>, /// Set by a token whose peer broke the protocol, which ends the session. violation: kio::Shared>, @@ -498,7 +496,6 @@ impl Present { session: self.session.clone(), version: self.version, handle: self.handle.clone(), - going_away: self.going_away.clone(), violation: self.violation.clone(), id, state: PresentState::Open { token }, @@ -520,7 +517,6 @@ struct PresentToken { session: S, version: Version, handle: crate::auth::Link, - going_away: crate::goaway::GoingAway, violation: kio::Shared>, id: u64, state: PresentState, @@ -580,8 +576,8 @@ impl PresentToken { loop { match &mut self.state { PresentState::Open { token } => { - // After a GOAWAY the peer must not see new streams. - if self.going_away.is_set() { + // After a GOAWAY, sent or received, the peer must not see new streams. + if !self.handle.active() { return Poll::Ready(Error::GoingAway); } let token = token.clone(); From c6f331e2d47ca31d4519f1a2c3b3c282b45e6410 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 15:01:15 -0700 Subject: [PATCH 13/31] fix(auth): a draining session's late refusal keeps its successor's grant During a GOAWAY overlap a token can sit unanswered on the draining session while the successor grants it. A late refusal there ended the token on every session, withdrawing the live grant and every route naming it. A draining session's verdict now ends a token only on that session once another session granted it, in Rust and JS. Co-Authored-By: Claude Opus 5.5 --- js/net/src/auth.test.ts | 47 ++++++++++++++++++++++++++++++++++++++ js/net/src/auth_session.ts | 6 +++++ rs/moq-net/src/auth.rs | 31 +++++++++++++++++++++++-- 3 files changed, 82 insertions(+), 2 deletions(-) diff --git a/js/net/src/auth.test.ts b/js/net/src/auth.test.ts index d5e3549b965..6b91a2484d1 100644 --- a/js/net/src/auth.test.ts +++ b/js/net/src/auth.test.ts @@ -754,6 +754,53 @@ test("lite-07 presents a token added after a GOAWAY only on the successor", asyn successor.server.abort(); }); +test("lite-07 ignores a draining session's late refusal once its successor granted", async () => { + const protocol = Lite.ALPN_07_WIP; + const handle = new Handle(); + const pair = createMockTransportPair(protocol); + const [client, server] = await Promise.all([ + connectSession({ url, transport: pair.client, publish: new OriginProducer().consume(), auth: handle }), + acceptSession({ transport: pair.server, url }), + ]); + const requests = server.auth.requests(); + const setup = await requests.next(); + if (!setup) throw new Error("missing setup token"); + const issued = [setup.accept(grant([], []))]; + const adding = handle.add("token"); + const unanswered = await requests.next(); + expect(unanswered && new TextDecoder().decode(unanswered.token)).toBe("token"); + + const stream = await Stream.open(pair.server, { version: Lite.Version.DRAFT_07 }); + await stream.writer.u53(Lite.StreamId.Goaway); + await new Lite.Goaway("").encode(stream.writer, Lite.Version.DRAFT_07); + await settles(() => !wireOf(client).auth.active); + + const successor = await connect({ publish: new OriginProducer(), protocol, auth: handle }); + answer(successor.server.auth.requests(), (token) => (token ? grant(["second"], []) : grant([], []))); + const token = await adding; + await waitFor(token.grant, (g) => g?.publish.equals(patterns("second")) === true); + + const warned = spyOn(console, "warn").mockImplementation(() => {}); + let ended = false; + void token.closed.then(() => { + ended = true; + }); + unanswered?.reject(SessionCode.Unauthorized, "stale"); + try { + await settles(() => warned.mock.calls.some(([line]) => line === "auth token refused")); + for (let i = 0; i < 10; i++) await nextTask(); + expect(ended).toBe(false); + expect(token.grant.peek()?.publish.equals(patterns("second"))).toBe(true); + } finally { + warned.mockRestore(); + for (const grant of issued) grant.close(); + client.abort(); + server.abort(); + successor.client.abort(); + successor.server.abort(); + } +}); + // moq-lite carries patterns, so every grant arrives exactly as issued. test("a fetch abandoned while its token waits never reaches the wire", async () => { const logged = spyOn(console, "debug"); diff --git a/js/net/src/auth_session.ts b/js/net/src/auth_session.ts index b35fe434e3b..30d72d46ddf 100644 --- a/js/net/src/auth_session.ts +++ b/js/net/src/auth_session.ts @@ -885,9 +885,15 @@ export class AuthSession { this.#ended.update((n) => n + 1); // Only the peer's verdict ends one of the handle's tokens on every session: a refusal, // Unsupported, or a clean close after a grant. Anything else leaves it for the next. + // A draining session's verdict is stale once another session granted the token. + const superseded = + !this.active && + owner !== undefined && + this.#state.sessions.some((session) => session !== this && session.grantOf(owner) !== undefined); const verdict = !this.#closed && !presented.withdrawn && + !superseded && (refused || result instanceof Unsupported || (result === null && granted)); if (owner && verdict) this.#state.verdict(owner, result); // Closed before the union shrinks, so the scope never reads it as a revocation. diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index bdcfe74f6e6..fe65a1b6a01 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -1046,12 +1046,18 @@ impl Link { /// leaves it for the next session to present. #[cfg_attr(not(feature = "async"), allow(dead_code))] pub(crate) fn ended(&self, id: u64, err: Error) { - self.with_state(|state, link| { - let link = attached(&mut state.links, link); + self.with_state(|state, link_id| { + // A draining session's verdict is stale once another session granted the token. + let superseded = !attached(&mut state.links, link_id).active() + && state.links.iter().any(|(other, link)| { + *other != link_id && link.slots.get(&id).is_some_and(|slot| slot.grant.is_some()) + }); + let link = attached(&mut state.links, link_id); let Some(slot) = link.slots.get_mut(&id) else { return; }; let verdict = !slot.setup + && !superseded && link.closed.is_none() && !slot.withdrawn && slot.ended.is_none() @@ -2811,6 +2817,27 @@ mod tests { assert!(matches!(auth.poll_answered(&kio::Waiter::noop()), Poll::Ready(Ok(())))); } + /// A draining session's late refusal ends the token there alone once its successor + /// granted it, so a stale verdict never withdraws a live grant. + #[test] + fn a_draining_refusal_leaves_the_successors_grant() { + let handle = Handle::new(); + let draining = Link::attach(&handle, true, false).unwrap(); + let auth = handle.present(Bytes::from_static(b"jwt")).unwrap(); + let id = opened(&draining); + let (goaway, _protocol) = crate::goaway::Handle::new(false); + goaway.producer().send(crate::goaway::Goaway::new()).unwrap(); + draining.drain_with(std::sync::Arc::new(goaway)); + + let successor = Link::attach(&handle, true, false).unwrap(); + assert_eq!(opened(&successor), id); + successor.granted(id, Grant::all()); + draining.refused(id); + draining.ended(id, Error::Session(SessionError::Unauthorized)); + assert!(Selector::Token(id).ended(&handle.state.read()).is_none()); + assert_eq!(auth.grant().peek(), Some(Grant::all())); + } + /// A retry that wakes after this side sent a GOAWAY opens nothing: the successor presents it. #[test] fn a_draining_session_presents_no_retry() { From 0c7acddebf573b197ceb22747909b684bc2e1283 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 17:14:54 -0700 Subject: [PATCH 14/31] fix(auth): a draining verdict defers to the active session, and a retry stays unanswered - A draining session's refusal is stale while an active session still presents the token, even mid-retry with no grant, so it no longer ends the successor's token. - A token waiting out a transient refusal counts as unanswered, so a publication it might cover waits instead of closing the session as unauthorized. - An ended auth handle stops the reconnect loop (moq-tokio and js/net) instead of redialing sessions the handle refuses. - A session's own handle has no successor: a token added after its GOAWAY fails with GoingAway, and any end of its stream is final. - moq-transport's presenter drops its retry loop: AUTH_ERROR has no transient code. - JS requests() throws Auth.Duplicate, like Rust; docs follow the handle's lifetime. Co-Authored-By: Claude Opus 5.5 --- doc/lib/js/net.md | 2 +- js/net/MIGRATE.md | 2 +- js/net/src/auth.test.ts | 106 ++++++++++++++++++++++++++- js/net/src/auth.ts | 2 +- js/net/src/auth_session.ts | 46 ++++++++++-- js/net/src/connection/reload.test.ts | 35 +++++++++ js/net/src/connection/reload.ts | 11 ++- rs/moq-net/src/auth.rs | 106 ++++++++++++++++++++++++--- rs/moq-net/src/ietf/auth.rs | 26 ++----- rs/moq-tokio/src/connection.rs | 34 +++++++++ 10 files changed, 326 insertions(+), 44 deletions(-) diff --git a/doc/lib/js/net.md b/doc/lib/js/net.md index 7c5e666677b..0d3c0f35b96 100644 --- a/doc/lib/js/net.md +++ b/doc/lib/js/net.md @@ -45,7 +45,7 @@ broadcast.announce(); - **Timing is per track, with no default.** A track that declares a `timescale` carries a `timestamp` on every frame; one without is [untimed](/concept/moq-lite#subscriptions) and its frames carry none. Omitting it means untimed, as in Rust. Nothing stamps a frame for you: the publisher passes the timestamp, and the `Timed` type (`{ value, at }`) carries one into the `@moq/json` and `@moq/flate` producers. - **Subscriber staleness is `maxDelay`.** It counts from a group's successor, by media time and by wall clock, whichever reaches it first. Passing the old `maxAge` key throws a `TypeError` naming `maxDelay`. Publisher retention is the separate `Track.Info.maxAge`. - **Hidden paths** stay out of discovery unless the announce request opts in. See [hidden broadcasts](/concept/moq-lite#hidden-broadcasts). -- **Authorization is in band.** On moq-lite 07 (opt in with `versions: ["moq-lite-07-wip", "moq-lite-06"]`, listing every fallback you still want) and on moq-transport draft-17+ with the [MoQ Auth extension](/draft/moq-auth), `auth.grant` watches what the relay lets this side publish and subscribe to, and `auth.add(token)` presents another token without reconnecting. Publishing outside the grant closes the connection with `SessionCode.Unauthorized`, naming the path. On moq-lite, a subscription the grant stops covering resets with `StreamCode.Unauthorized` and the session stays up. On moq-transport, `auth.add(token)` keeps the token even without the extension, and a broadcast announced with the `Auth.Auth` it returns (`broadcast.announce({ auth })`) carries it on that announcement's `PUBLISH_NAMESPACE`; announcing again with another renews the request. A `Connection`'s `auth` handle outlives its sessions: a token added there is presented on every reconnect and keeps riding the announcements that name it, with no re-add or re-announce. A one-shot session's `auth` ends with it, unless `Connection.connect` was given a `new Auth.Handle()` as `auth`. +- **Authorization is in band.** On moq-lite 07 (opt in with `versions: ["moq-lite-07-wip", "moq-lite-06"]`, listing every fallback you still want) and on moq-transport draft-17+ with the [MoQ Auth extension](/draft/moq-auth), `auth.grant` watches what the relay lets this side publish and subscribe to, and `auth.add(token)` presents another token without reconnecting. Publishing outside the grant closes the connection with `SessionCode.Unauthorized`, naming the path. On moq-lite, a subscription the grant stops covering resets with `StreamCode.Unauthorized` and the session stays up. On moq-transport, `auth.add(token)` keeps the token even without the extension, and a broadcast announced with the `Auth.Auth` it returns (`broadcast.announce({ auth })`) carries it on that announcement's `PUBLISH_NAMESPACE`; announcing again with another renews the request. A `Connection`'s `auth` handle (a getter on a pooled connection, following its URL) outlives its sessions: a token added there is presented on every reconnect and keeps riding the announcements that name it, with no re-add or re-announce. A one-shot session's `auth` ends with it, unless `Connection.connect` was given a `new Auth.Handle()` as `auth`. - **A graceful close waits.** `await connection.close()` withdraws announcements and drains for up to one second. Application delivery requires explicit `moq-lite-07-wip`; older Lite versions wait only for transport ACKs. IETF media streams are not drained. `abort()` ends immediately. Examples: diff --git a/js/net/MIGRATE.md b/js/net/MIGRATE.md index ea578fe763f..0ddf4a92cf7 100644 --- a/js/net/MIGRATE.md +++ b/js/net/MIGRATE.md @@ -8,7 +8,7 @@ How to update your code for each breaking change, newest first. `Auth.Handle` is now a class: `new Auth.Handle()` backs no session yet, and `connect({ auth })` builds the session on it, so every session built on one presents the tokens it holds. A reconnecting `Connection` owns one as `connection.auth`; a route naming one of its `Auth.Auth`s keeps that token across a reconnect, so drop the code that added the token again and re-announced after one. -A handle backs one active session at a time, plus the ones draining after a GOAWAY: connecting another while one is still active rejects with `Auth.Duplicate`. +A handle backs one active session at a time, plus the ones draining after a GOAWAY: connecting another while one is still active rejects with `Auth.Duplicate`, and so does a second `requests()` call, which threw a plain `Error` before. `Established.auth` returns the session's handle, so its `grant` and each `Auth.Auth`'s `grant` read undefined once the session closes, instead of an empty grant. `Auth.Auth.closed` settles only on the peer's verdict or the handle's end (`handle.abort()`); a session ending is not one, except for a handle the session created for itself. `Auth.None` is removed; nothing returned it. diff --git a/js/net/src/auth.test.ts b/js/net/src/auth.test.ts index 6b91a2484d1..74898fa7b4a 100644 --- a/js/net/src/auth.test.ts +++ b/js/net/src/auth.test.ts @@ -129,7 +129,7 @@ describe.each([Lite.ALPN_07_WIP, Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_22])("%s", token.close(); expect(await token.closed).toBeNull(); - await waitFor(handle.grant, (g) => g?.publish.equals(patterns("")) === false); + await waitFor(handle.grant, (g) => g?.publish.size === 0); second.client.abort(); second.server.abort(); }); @@ -701,12 +701,22 @@ test("lite-07 presents a transiently refused token again after a backoff", async added = true; }); await refuse(await requests.next()); + // Unanswered through the backoff, so a publication it might cover waits for the retry. + let ready: boolean | undefined; + void wireOf(client) + .auth.ready(Path.from("a"), "publish") + .then((value) => { + ready = value; + }); await refuse(await after(Milli(1000))); + expect(ready).toBeUndefined(); const accepted = await after(Milli(2000)); if (!accepted) throw new Error("the token was not presented again"); issued.push(accepted.accept(grant(["a"], []))); const token = await adding; expect(token.grant.peek()?.publish.equals(patterns("a"))).toBe(true); + await settles(() => ready !== undefined); + expect(ready).toBe(true); } finally { client.abort(); server.abort(); @@ -754,6 +764,27 @@ test("lite-07 presents a token added after a GOAWAY only on the successor", asyn successor.server.abort(); }); +test("lite-07 refuses a token added to a session's own handle after a GOAWAY", async () => { + const protocol = Lite.ALPN_07_WIP; + const pair = createMockTransportPair(protocol); + const [client, server] = await Promise.all([ + connectSession({ url, transport: pair.client, publish: new OriginProducer().consume() }), + acceptSession({ transport: pair.server, url }), + ]); + const stream = await Stream.open(pair.server, { version: Lite.Version.DRAFT_07 }); + await stream.writer.u53(Lite.StreamId.Goaway); + await new Lite.Goaway("").encode(stream.writer, Lite.Version.DRAFT_07); + await settles(() => !wireOf(client).auth.active); + + try { + const late = withTimeout(client.auth.add("late"), Nano.fromMilli(Milli(1000)), "add after GOAWAY hung"); + await expect(late).rejects.toBeInstanceOf(StreamError); + } finally { + client.abort(); + server.abort(); + } +}); + test("lite-07 ignores a draining session's late refusal once its successor granted", async () => { const protocol = Lite.ALPN_07_WIP; const handle = new Handle(); @@ -801,6 +832,79 @@ test("lite-07 ignores a draining session's late refusal once its successor grant } }); +test("lite-07 ignores a draining session's late refusal while its successor retries", async () => { + const clock = new Manual(); + const restore = clock.install(); + const warned = spyOn(console, "warn").mockImplementation(() => {}); + const refusals = () => warned.mock.calls.filter(([line]) => line === "auth token refused").length; + const protocol = Lite.ALPN_07_WIP; + const handle = new Handle(); + const pair = createMockTransportPair(protocol); + const [client, server] = await Promise.all([ + connectSession({ url, transport: pair.client, publish: new OriginProducer().consume(), auth: handle }), + acceptSession({ transport: pair.server, url }), + ]); + const requests = server.auth.requests(); + const issued: Issued[] = []; + const setup = await requests.next(); + if (!setup) throw new Error("missing setup token"); + issued.push(setup.accept(grant([], []))); + const adding = handle.add("token"); + const unanswered = await requests.next(); + expect(unanswered && new TextDecoder().decode(unanswered.token)).toBe("token"); + + const stream = await Stream.open(pair.server, { version: Lite.Version.DRAFT_07 }); + await stream.writer.u53(Lite.StreamId.Goaway); + await new Lite.Goaway("").encode(stream.writer, Lite.Version.DRAFT_07); + await settles(() => !wireOf(client).auth.active); + + const next = await connect({ publish: new OriginProducer(), protocol, auth: handle }); + const successorRequests = next.server.auth.requests(); + try { + const successorSetup = await successorRequests.next(); + if (!successorSetup) throw new Error("missing successor setup token"); + issued.push(successorSetup.accept(grant([], []))); + const first = await successorRequests.next(); + if (!first) throw new Error("the successor did not present the token"); + const firstGrant = first.accept(grant(["second"], [])); + const token = await adding; + await waitFor(token.grant, (g) => g?.publish.equals(patterns("second")) === true); + let ended = false; + void token.closed.then(() => { + ended = true; + }); + + // The successor's grant ends with a refusal that may pass, so it backs off with no grant. + const before = refusals(); + firstGrant.revoke(SessionCode.TooManyRequests, "busy"); + await settles(() => refusals() > before); + await waitFor(token.grant, (g) => g === undefined); + + // The draining session's stale refusal lands during that backoff. + const stale = refusals(); + unanswered?.reject(SessionCode.Unauthorized, "stale"); + await settles(() => refusals() > stale); + for (let i = 0; i < 10; i++) await nextTask(); + expect(ended).toBe(false); + + const retried = successorRequests.next(); + clock.advance(Nano.fromMilli(Milli(1000))); + const again = await retried; + if (!again) throw new Error("the successor did not present the token again"); + issued.push(again.accept(grant(["third"], []))); + await waitFor(token.grant, (g) => g?.publish.equals(patterns("third")) === true); + expect(ended).toBe(false); + } finally { + for (const grant of issued) grant.close(); + client.abort(); + server.abort(); + next.client.abort(); + next.server.abort(); + warned.mockRestore(); + restore(); + } +}); + // moq-lite carries patterns, so every grant arrives exactly as issued. test("a fetch abandoned while its token waits never reaches the wire", async () => { const logged = spyOn(console, "debug"); diff --git a/js/net/src/auth.ts b/js/net/src/auth.ts index 5d2189cc92d..cdfe452eee0 100644 --- a/js/net/src/auth.ts +++ b/js/net/src/auth.ts @@ -89,7 +89,7 @@ export interface Issued { /** The tokens the peer presents, for an application that answers them itself. */ export interface Requests { - /** The next token the peer presents, or undefined once the session ends. */ + /** The next token the peer presents, or undefined once the handle ends. */ next(): Promise; /** Refuse every later token. */ close(): void; diff --git a/js/net/src/auth_session.ts b/js/net/src/auth_session.ts index 30d72d46ddf..2e60c544e3e 100644 --- a/js/net/src/auth_session.ts +++ b/js/net/src/auth_session.ts @@ -22,7 +22,7 @@ export class Unsupported extends Error { } } -/** A handle already backs a session that is open and not draining. */ +/** A handle already backs a session that is open and not draining, or its requests were already taken. */ export class Duplicate extends Error { constructor(message = "auth handle already backs an active session") { super(message); @@ -228,6 +228,10 @@ class HandleState { add(bytes: Uint8Array): Token { if (this.closed) throw this.closed; + // A draining session takes no new tokens, and a handle of its own has no successor. + if (this.own && !this.sessions.some((session) => session.active)) { + throw new StreamError(StreamCode.GoingAway); + } const token = new Token(bytes, (token) => this.withdraw(token)); this.tokens.add(token); for (const session of [...this.sessions]) session.offer(token); @@ -279,6 +283,15 @@ export function vacant(handle: Handle) { stateOf(handle).vacant(); } +/** + * Why `handle` ended, or undefined while it can still back a session. + * + * @internal + */ +export function ended(handle: Handle): Error | undefined { + return stateOf(handle).closed; +} + /** * The tokens this side presents and the grant they earn, across every session built on it. * @@ -325,14 +338,14 @@ export class Handle { * publishes and consumes for the connection's own credential and refusing any other. * * Call it as soon as connect or accept resolves, in the same task: whoever answers is - * decided once, right after. Throws once that has happened. + * decided once, right after. Throws {@link Duplicate} once that has happened. * * The peer's stream credit bounds how many tokens wait unanswered, since each holds its * stream open until answered or withdrawn. A withdrawn token leaves the queue. */ requests(): Requests { const state = stateOf(this); - if (state.acceptor !== "undecided") throw new Error("auth requests already taken or answered by default"); + if (state.acceptor !== "undecided") throw new Duplicate("auth requests already taken or answered by default"); const queue = new RequestQueue(); // A session of its own that carries no AUTH never receives a token. if (state.closed || (state.own && !state.live?.negotiated)) queue.close(); @@ -584,6 +597,12 @@ export class AuthSession { return this.#byToken.get(token)?.grant; } + /** Whether this session still presents the handle's `token`. */ + presents(token: Token): boolean { + const presented = this.#byToken.get(token); + return presented !== undefined && !presented.ended && !presented.withdrawn; + } + /** * Present one of the handle's tokens on this session. A draining session takes no new * tokens, so its verdict never ends one its successor carries. @@ -852,8 +871,9 @@ export class AuthSession { // carries such a code. if (!refused || !transient(result) || this.#closed || presented.withdrawn) break; presented.grant = undefined; - // Answered, so nothing waits on the token through the backoff. - this.#settle(presented); + // Unanswered again, as on its first presentation: a request or publication the token + // might cover waits for the retry's verdict rather than being judged without it. + this.#unsettle(presented); this.#recompute(); delay = delay === undefined ? RETRY_INITIAL : Milli(Math.min(delay * 2, RETRY_MAX)); presented.retry = new AbortController(); @@ -885,16 +905,18 @@ export class AuthSession { this.#ended.update((n) => n + 1); // Only the peer's verdict ends one of the handle's tokens on every session: a refusal, // Unsupported, or a clean close after a grant. Anything else leaves it for the next. - // A draining session's verdict is stale once another session granted the token. + // A draining session's verdict is stale while an active session still presents the + // token: that session's own answer decides, even mid-retry with no grant right now. const superseded = !this.active && owner !== undefined && - this.#state.sessions.some((session) => session !== this && session.grantOf(owner) !== undefined); + this.#state.sessions.some((session) => session !== this && session.active && session.presents(owner)); + // A handle of the session's own has no next session to present it, so any end is final. const verdict = !this.#closed && !presented.withdrawn && !superseded && - (refused || result instanceof Unsupported || (result === null && granted)); + (this.#state.own || refused || result instanceof Unsupported || (result === null && granted)); if (owner && verdict) this.#state.verdict(owner, result); // Closed before the union shrinks, so the scope never reads it as a revocation. if (closed) this.close(); @@ -915,6 +937,14 @@ export class AuthSession { if (presented.token.byteLength > 0 && !this.#closed) this.#pending.update((n) => n - 1); } + /** Count `presented` as unanswered again, as a retry presents it anew. */ + #unsettle(presented: Presented) { + if (!presented.isAnswered) return; + presented.isAnswered = false; + if (presented.setup) this.#setupPending.update((n) => n + 1); + if (presented.token.byteLength > 0) this.#pending.update((n) => n + 1); + } + #recompute() { const granted: Grant[] = []; for (const presented of this.#presented) { diff --git a/js/net/src/connection/reload.test.ts b/js/net/src/connection/reload.test.ts index 65f6a3bf4a9..182425e7b14 100644 --- a/js/net/src/connection/reload.test.ts +++ b/js/net/src/connection/reload.test.ts @@ -262,6 +262,41 @@ test("an announced request follows the reconnect on the same handle", async () = } }); +test("an ended auth handle gives up instead of retrying", async () => { + const original = globalThis.WebTransport; + const url = new URL("https://example.com/"); + const servers: Promise<{ abort(): void }>[] = []; + const stub = function StubWebTransport() { + const pair = createMockTransportPair(Lite.ALPN_06); + servers.push(accept(pair.server, url)); + return pair.client; + }; + globalThis.WebTransport = stub as unknown as typeof WebTransport; + + // An unbounded retry window, so only the ended handle can stop the loop. + const reload = new Reload({ + enabled: true, + url, + websocket: { enabled: false }, + delay: { initial: Milli(1), multiplier: 2, max: Milli(1), timeout: Milli(0) }, + }); + const warned = spyOn(console, "warn").mockImplementation(() => {}); + try { + await waitUntil(() => reload.established.peek() !== undefined); + const aborted = new Error("auth handle aborted"); + reload.auth.abort(aborted); + (await servers[0])?.abort(); + + await waitUntil(() => reload.error.peek() !== undefined); + expect(reload.error.peek()).toBe(aborted); + expect(servers.length).toBe(1); + } finally { + warned.mockRestore(); + reload.close(); + globalThis.WebTransport = original; + } +}); + test("a reload that gives up keeps requests pending until it is disposed", async () => { const original = globalThis.WebTransport; const url = new URL("https://example.com/"); diff --git a/js/net/src/connection/reload.ts b/js/net/src/connection/reload.ts index 24d4744eb54..b7bd1e32625 100644 --- a/js/net/src/connection/reload.ts +++ b/js/net/src/connection/reload.ts @@ -2,6 +2,7 @@ import { Instant, Milli, Nano, now, sleep } from "@moq/clock"; import { Effect, type GetPromise, type Getter, Once, Signal } from "@moq/signals"; import * as Announce from "../announced.ts"; import { Handle as AuthHandle } from "../auth.ts"; +import { ended } from "../auth_session.ts"; import { Allocator } from "../bandwidth.ts"; import { error, SessionCode, SessionError } from "../error.ts"; import type { Consumer as OriginConsumer, Producer as OriginProducer } from "../origin.ts"; @@ -152,7 +153,8 @@ export class Reload { * The auth handle every session of the loop is built on, spanning reconnects. * * Each session presents every token it holds, so a route naming one keeps it across a - * reconnect, and its grant follows the live session. It ends when the loop closes. + * reconnect, and its grant follows the live session. It ends when the loop closes, and + * aborting it stops the loop at its next reconnect, with the abort's error. */ readonly auth = new AuthHandle(); @@ -496,6 +498,13 @@ export class Reload { // Treat teardown as cancellation, not a connection failure. if (signal.aborted) return; + // An ended auth handle refuses every later session, so no retry can recover. + const closed = ended(this.auth); + if (closed) { + this.#giveUp(closed); + return; + } + console.warn("connection error:", err); this.#retry(effect, connected, err); } diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index fe65a1b6a01..893093efff6 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -553,6 +553,10 @@ impl Handle { if let Some(err) = &state.closed { return Err(err.clone()); } + // A draining session takes no new tokens, and a handle of its own has no successor. + if state.bound && !state.links.values().any(Attached::active) { + return Err(Error::GoingAway); + } let id = state.next_id; state.next_id += 1; state.tokens.insert( @@ -1012,8 +1016,9 @@ impl Link { slot.refused = false; slot.streamed = false; slot.grant = None; - // Answered, so nothing waits on the token through the backoff. - slot.answered = Some(Err(err.clone())); + // Unanswered again, as on its first presentation: a request or publication the + // token might cover waits for the retry's verdict rather than being judged without it. + slot.answered = None; let delay = slot.retry.map_or(RETRY_INITIAL, |delay| (delay * 2).min(RETRY_MAX)); slot.retry = Some(delay); link.epoch += 1; @@ -1043,15 +1048,22 @@ impl Link { /// End the token on this session: refused or revoked by the peer, withdrawn by us, or /// its stream lost. A token that was never answered records `err` as its answer. The /// peer's verdict ends one of the handle's tokens on every session; anything else - /// leaves it for the next session to present. + /// leaves it for the next session to present, except on a handle of the session's own. #[cfg_attr(not(feature = "async"), allow(dead_code))] pub(crate) fn ended(&self, id: u64, err: Error) { self.with_state(|state, link_id| { - // A draining session's verdict is stale once another session granted the token. + // A draining session's verdict is stale while an active session still presents the + // token: that session's own answer decides, even mid-retry with no grant right now. let superseded = !attached(&mut state.links, link_id).active() && state.links.iter().any(|(other, link)| { - *other != link_id && link.slots.get(&id).is_some_and(|slot| slot.grant.is_some()) + *other != link_id + && link.active() && link + .slots + .get(&id) + .is_some_and(|slot| slot.ended.is_none() && !slot.withdrawn) }); + // A handle of the session's own has no next session to present it, so any end is final. + let bound = state.bound; let link = attached(&mut state.links, link_id); let Some(slot) = link.slots.get_mut(&id) else { return; @@ -1061,7 +1073,8 @@ impl Link { && link.closed.is_none() && !slot.withdrawn && slot.ended.is_none() - && (slot.refused + && (bound + || slot.refused || matches!(err, Error::Unsupported) // The peer ended the grant without revoking it. || (matches!(err, Error::Cancel) && slot.grant.is_some())); @@ -1511,12 +1524,12 @@ pub struct Requests { } impl Requests { - /// Poll for the next token, or `None` once the session ends. + /// Poll for the next token, or `None` once the handle ends. pub fn poll_next(&mut self, waiter: &kio::Waiter) -> Poll> { self.queue.poll_next(waiter) } - /// Wait for the next token the peer presents, or `None` once the session ends. + /// Wait for the next token the peer presents, or `None` once the handle ends. #[cfg(feature = "async")] pub async fn next(&mut self) -> Option { kio::wait(|waiter| self.poll_next(waiter)).await @@ -2508,6 +2521,29 @@ mod tests { assert!(gate.poll_denied(&waiter).is_pending()); } + /// A token waiting out a transient refusal is unanswered, so a publication it might + /// cover waits for the retry instead of being judged unauthorized. + #[test] + fn a_retrying_token_holds_a_publication() { + let handle = Link::new(true); + let _token = handle.present(Bytes::from_static(b"jwt"), false).unwrap(); + let id = opened(&handle); + handle.refused(id); + let busy = Error::Session(SessionError::TooManyRequests); + assert_eq!(handle.retry(id, &busy), Some(RETRY_INITIAL)); + + let mut gate = Gate::new(handle.clone(), crate::Path::new("room").to_owned(), Direction::Publish); + let waiter = kio::Waiter::noop(); + assert!( + gate.poll_ready(&waiter).is_pending(), + "judged before the retry answered" + ); + handle.reopen(id); + handle.granted(id, Grant::all()); + assert!(gate.poll_ready(&waiter).is_ready()); + assert!(handle.within_grant(Direction::Publish, "room")); + } + /// A route withdrawn while its request waits on a token ends the wait, and the request /// is refused against the union as it stands. #[test] @@ -2799,6 +2835,28 @@ mod tests { ); } + /// A session's own handle has no successor: a token added after its GOAWAY fails at once, + /// and one whose stream ends without a verdict ends for good. + #[test] + fn a_sessions_own_handle_ends_its_tokens() { + let link = Link::new(true); + let auth = link.present(Bytes::from_static(b"jwt"), false).unwrap(); + let id = opened(&link); + link.ended(id, Error::Stream(crate::StreamError::Internal)); + assert!(matches!( + auth.poll_answered(&kio::Waiter::noop()), + Poll::Ready(Err(Error::Stream(_))) + )); + + let (goaway, _protocol) = crate::goaway::Handle::new(false); + goaway.producer().send(crate::goaway::Goaway::new()).unwrap(); + link.drain_with(std::sync::Arc::new(goaway)); + assert!(matches!( + link.present(Bytes::from_static(b"late"), false), + Err(Error::GoingAway) + )); + } + /// A token added while a session without AUTH drains rides only its successor, so the /// draining session cannot end it as unsupported. #[test] @@ -2817,8 +2875,8 @@ mod tests { assert!(matches!(auth.poll_answered(&kio::Waiter::noop()), Poll::Ready(Ok(())))); } - /// A draining session's late refusal ends the token there alone once its successor - /// granted it, so a stale verdict never withdraws a live grant. + /// A draining session's late refusal ends the token there alone while its successor + /// presents it, so a stale verdict never withdraws a live grant. #[test] fn a_draining_refusal_leaves_the_successors_grant() { let handle = Handle::new(); @@ -2838,6 +2896,34 @@ mod tests { assert_eq!(auth.grant().peek(), Some(Grant::all())); } + /// A draining session's late refusal leaves a token its successor is still deciding, + /// including one the successor granted and then transiently refused. + #[test] + fn a_draining_refusal_defers_to_the_successors_retry() { + let handle = Handle::new(); + let draining = Link::attach(&handle, true, false).unwrap(); + let auth = handle.present(Bytes::from_static(b"jwt")).unwrap(); + let id = opened(&draining); + let (goaway, _protocol) = crate::goaway::Handle::new(false); + goaway.producer().send(crate::goaway::Goaway::new()).unwrap(); + draining.drain_with(std::sync::Arc::new(goaway)); + + let successor = Link::attach(&handle, true, false).unwrap(); + assert_eq!(opened(&successor), id); + successor.granted(id, Grant::all()); + successor.refused(id); + let busy = Error::Session(SessionError::TooManyRequests); + assert_eq!(successor.retry(id, &busy), Some(RETRY_INITIAL)); + + draining.refused(id); + draining.ended(id, Error::Session(SessionError::Unauthorized)); + assert!(Selector::Token(id).ended(&handle.state.read()).is_none()); + + assert_eq!(successor.reopen(id).as_deref(), Some(b"jwt".as_slice())); + successor.granted(id, Grant::all()); + assert_eq!(auth.grant().peek(), Some(Grant::all())); + } + /// A retry that wakes after this side sent a GOAWAY opens nothing: the successor presents it. #[test] fn a_draining_session_presents_no_retry() { diff --git a/rs/moq-net/src/ietf/auth.rs b/rs/moq-net/src/ietf/auth.rs index 9394dc41b7e..923b4c19291 100644 --- a/rs/moq-net/src/ietf/auth.rs +++ b/rs/moq-net/src/ietf/auth.rs @@ -295,27 +295,11 @@ where let going_away = going_away.clone(); let violation = violation.clone(); tasks.push(async move { - let mut token = token; - let err = loop { - // After a GOAWAY the peer must not see new requests. - if going_away.is_set() { - break Error::GoingAway; - } - let err = present.run(token).await; - let Some(delay) = present.handle.retry(present.id, &err) else { - break err; - }; - tracing::debug!(?delay, "auth token refused transiently; presenting it again"); - let mut deadline = crate::time::Deadline::after(&present.runtime, delay); - kio::wait(|waiter| match present.handle.poll_withdrawn(present.id, waiter) { - Poll::Ready(()) => Poll::Ready(()), - Poll::Pending => deadline.poll(waiter), - }) - .await; - match present.handle.reopen(present.id) { - Some(next) => token = next, - None => break Error::Cancel, - } + // After a GOAWAY the peer must not see new requests. AUTH_ERROR carries no code + // that may pass, so unlike moq-lite a refusal is never presented again. + let err = match going_away.is_set() { + true => Error::GoingAway, + false => present.run(token).await, }; let err = crate::auth::protocol_error(err); if matches!(err, Error::ProtocolViolation) { diff --git a/rs/moq-tokio/src/connection.rs b/rs/moq-tokio/src/connection.rs index e05ced89f6a..00932338119 100644 --- a/rs/moq-tokio/src/connection.rs +++ b/rs/moq-tokio/src/connection.rs @@ -873,6 +873,11 @@ impl Connection { if !timeout.is_zero() && retry.start.elapsed() >= timeout { return Err(timeout_error(timeout, retry.last_error.as_ref())); } + // An ended auth handle refuses every later session, so no dial can recover. A fresh + // watch only reports an error once its handle has ended. + if let Poll::Ready(Err(err)) = shared.auth.grant().poll_changed(&kio::Waiter::noop()) { + return Err(err.into()); + } match Self::dial_any(shared, &client, &addrs, &mut draining).await { Ok((addr, dialed)) => { @@ -1307,6 +1312,7 @@ impl Connection { /// presented on each reconnect, so a route naming one keeps it, and its grant follows /// the live session. It ends with the connection: [`Error::MoqNet`]'s error, or /// [`moq_net::Error::Unauthorized`] for a rejection, or [`moq_net::Error::Cancel`]. + /// Aborting it ends the connection at its next reconnect, with the abort's error. pub fn auth(&self) -> moq_net::auth::Handle { self.auth.clone() } @@ -2566,6 +2572,34 @@ mod tests { assert!(matches!(closed, moq_net::Error::Cancel), "{closed:?}"); } + /// Ending the connection's auth handle ends the loop at its next reconnect, rather than + /// redialing sessions the handle refuses. + #[cfg(feature = "tcp")] + #[tokio::test] + async fn an_ended_auth_handle_ends_the_loop() { + const WAIT: Duration = Duration::from_secs(10); + + let (addr, mut accepted, _task) = serve(crate::origin::spawn()).await; + let mut config = crate::connect::Config::default(); + config.backoff.initial = Some(MIN_BACKOFF); + config.backoff.max = Some(MIN_BACKOFF); + let connection = config + .init(Default::default()) + .unwrap() + .connect(url(&format!("tcp://{addr}/"))); + + let session = tokio::time::timeout(WAIT, accepted.recv()).await.unwrap().unwrap(); + connection.auth().abort(moq_net::Error::App(9)); + session.abort(moq_net::Error::Cancel); + let closed = tokio::time::timeout(WAIT, connection.closed()) + .await + .expect("the loop kept redialing on an ended auth handle"); + assert!( + matches!(closed, Err(Error::MoqNet(moq_net::Error::App(9)))), + "{closed:?}" + ); + } + /// A session that ends the loop reports through `closed`, not as a retry reason. #[cfg(feature = "tcp")] #[tokio::test] From c0b800c7a18bc681952b43eb10eae6572635c191 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 17:37:51 -0700 Subject: [PATCH 15/31] fix(js/net): a GOING_AWAY refusal leaves a shared token for the successor moq-transport's AUTH_ERROR GOING_AWAY judged nothing, but the presenter counted it as a refusal, so it ended a shared handle's token before the next session could present it. Matches the Rust presenter, which never marks GoingAway as refused. Co-Authored-By: Claude Opus 5.5 --- js/net/src/auth.test.ts | 33 +++++++++++++++++++++++++++++++++ js/net/src/auth_session.ts | 10 +++++----- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/js/net/src/auth.test.ts b/js/net/src/auth.test.ts index 74898fa7b4a..64a31e31386 100644 --- a/js/net/src/auth.test.ts +++ b/js/net/src/auth.test.ts @@ -1071,6 +1071,39 @@ test("a reset AUTH stream fails the token on moq-transport", async () => { } }); +test("a GOING_AWAY refusal leaves a shared token for the next session on moq-transport", async () => { + const protocol = Ietf.ALPN.DRAFT_22; + const handle = new Handle(); + const first = await connect({ publish: new OriginProducer(), protocol, auth: handle }); + await waitFor(handle.grant, (g) => g !== undefined); + const read = spyOn(IetfAuthWire.prototype, "read").mockResolvedValue({ + refused: new StreamError(StreamCode.GoingAway), + }); + const warned = spyOn(console, "warn").mockImplementation(() => {}); + let rejected: unknown; + const adding = handle.add("token"); + adding.catch((err) => { + rejected = err; + }); + try { + await settles(() => warned.mock.calls.some(([line]) => line === "auth token refused")); + for (let i = 0; i < 10; i++) await nextTask(); + expect(rejected).toBeUndefined(); + } finally { + read.mockRestore(); + warned.mockRestore(); + first.client.abort(); + first.server.abort(); + } + + const second = await connect({ publish: new OriginProducer(), protocol, auth: handle }); + answer(second.server.auth.requests(), (token) => (token ? grant(["second"], []) : grant([], []))); + const token = await adding; + await waitFor(token.grant, (g) => g?.publish.equals(patterns("second")) === true); + second.client.abort(); + second.server.abort(); +}); + // lite-07 presents the connection's credential on an Auth Stream right away; lite-06 never // opens one. test.each([ diff --git a/js/net/src/auth_session.ts b/js/net/src/auth_session.ts index 2e60c544e3e..a0523650b35 100644 --- a/js/net/src/auth_session.ts +++ b/js/net/src/auth_session.ts @@ -840,12 +840,12 @@ export class AuthSession { console.warn("auth token refused", reply.refused); // A refused setup token leaves an empty union, not an unknown (unrestricted) // one. A grant the acceptor could not tell, or never judged, is unknown, not empty. - const unjudged = - reply.refused instanceof Unsupported || - (reply.refused instanceof StreamError && reply.refused.code === StreamCode.GoingAway); - if (!unjudged) this.#replied = true; + const goingAway = + reply.refused instanceof StreamError && reply.refused.code === StreamCode.GoingAway; + if (!goingAway && !(reply.refused instanceof Unsupported)) this.#replied = true; result = reply.refused; - refused = true; + // A peer going away judged nothing, so the successor presents the token instead. + refused = !goingAway; stream.close(); break; } From ffa33948e8a61824672e5b4df3226acd32a4c496 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 22 Oct 2026 14:21:32 -0700 Subject: [PATCH 16/31] fix(net): await the manual clock and build auth tests without async Main's ManualClock.advance now returns a promise, so the auth and migrate tests await it. Auth::id is test-only rather than async-only, since a_draining_session_takes_no_new_token uses it in the default-free build. Co-Authored-By: Claude Opus 5.5 --- js/net/src/auth.test.ts | 6 +++--- js/net/src/connection/migrate.test.ts | 2 +- rs/moq-net/src/auth.rs | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/js/net/src/auth.test.ts b/js/net/src/auth.test.ts index a0d8709d089..f6730137478 100644 --- a/js/net/src/auth.test.ts +++ b/js/net/src/auth.test.ts @@ -683,11 +683,11 @@ test("lite-07 presents a transiently refused token again after a backoff", async presented = true; return request; }); - clock.advance(Nano.fromMilli(Milli(backoff - 1))); + await clock.advance(Nano.fromMilli(Milli(backoff - 1))); for (let i = 0; i < 10; i++) await nextTask(); expect(presented).toBe(false); expect(added).toBe(false); - clock.advance(Nano.fromMilli(Milli(1))); + await clock.advance(Nano.fromMilli(Milli(1))); return await next; }; @@ -888,7 +888,7 @@ test("lite-07 ignores a draining session's late refusal while its successor retr expect(ended).toBe(false); const retried = successorRequests.next(); - clock.advance(Nano.fromMilli(Milli(1000))); + await clock.advance(Nano.fromMilli(Milli(1000))); const again = await retried; if (!again) throw new Error("the successor did not present the token again"); issued.push(again.accept(grant(["third"], []))); diff --git a/js/net/src/connection/migrate.test.ts b/js/net/src/connection/migrate.test.ts index 134452df88f..f91babd1c27 100644 --- a/js/net/src/connection/migrate.test.ts +++ b/js/net/src/connection/migrate.test.ts @@ -298,7 +298,7 @@ test("the connection's tokens ride every session across a GOAWAY", async () => { const until = async (done: () => boolean) => { for (let i = 0; i < 100 && !done(); i++) { await nextTask(); - clock.advance(Nano.fromMilli(Milli(1))); + await clock.advance(Nano.fromMilli(Milli(1))); } expect(done()).toBe(true); }; diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index 893093efff6..66de8d0b1f0 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -1315,7 +1315,7 @@ pub struct Auth { presented: std::sync::Arc, } -#[cfg(all(test, feature = "async"))] +#[cfg(test)] impl Auth { /// The id the session presents this credential under. pub(crate) fn id(&self) -> u64 { From 36c103d9fb76bdb565f4109dcacf6d6057b858eb Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 22 Oct 2026 15:00:36 -0700 Subject: [PATCH 17/31] docs(net): say a followed redirect is presented the connection's tokens Co-Authored-By: Claude Opus 5.5 --- doc/lib/js/net.md | 2 +- js/net/src/connection/goaway.ts | 5 +++-- rs/moq-tokio/src/connection.rs | 3 ++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/doc/lib/js/net.md b/doc/lib/js/net.md index 660eb47b8d6..60e2e01a73c 100644 --- a/doc/lib/js/net.md +++ b/doc/lib/js/net.md @@ -40,7 +40,7 @@ broadcast.announce(); - **Follow restarts.** `origin.request(path, { announced: true })` follows whichever publisher instance serves the path: its `active` changes to a fresh broadcast on a restart, clears on an end, and resolves again on the next start, so read it reactively and close the request when done. Instances are never stitched, so subscribe again to each new broadcast. Another route of the same epoch taking over (epochs travel only on `moq-lite-07-wip`) keeps `active` as is, and each track subscription resumes there from the first group it lacks. A refusal ends the request. Add `epoch` to pin one instance; a plain `request(path)` keeps its accepted instance, can resolve blindly, and ends once that instance stops serving. To list what is live, iterate `announced(scope)`. - **Discovery follows what you watch.** A `consume` origin asks the relay for announcements only under the prefixes its readers watch with `announced(scope)` or `request(path, { announced: true })`, stops asking about a prefix 2s after nothing at or beneath it is watched, and asks again on reconnect. A plain `request(path)` asks nothing and subscribes blind, but once it resolves through a discovered route it watches its path until closed, so it outlives the listing that found it. A relay that refuses an announce request turns that session's announced requests blind too. - **Connections share by URL and versions.** `new Connection({ url })` pools and reconnects with backoff, which the elements use. An ordered `versions` list, spelled like `moq-lite-06` as in Rust, applies to both WebTransport and WebSocket; equivalent lists share, while a different order or an omitted list gets its own pool entry. Supplying your own transport options or origin selects a private loop. Receive estimates are on demand: observe `connection.probe` reactively or subscribe while you need them; `peek()` alone opens no PROBE stream. -- **GOAWAY moves the session.** The connection dials the replacement at once while the old session keeps serving its groups, up to `goaway.handover` (default 10s, or the relay's deadline when that is sooner). New requests keep opening on the old session until the replacement's route outranks it, which is a restart without an epoch: an unpinned announced request follows the replacement, while a blind or epoch-pinned request stays on its accepted instance. With an epoch, which travels only when `moq-lite-07-wip` is offered, the replacement serves the same broadcast, so open subscriptions move across without missing a group. An empty GOAWAY redials the same URL. A redirect stays on the same host unless `goaway.redirect` is `"follow"`. +- **GOAWAY moves the session.** The connection dials the replacement at once while the old session keeps serving its groups, up to `goaway.handover` (default 10s, or the relay's deadline when that is sooner). New requests keep opening on the old session until the replacement's route outranks it, which is a restart without an epoch: an unpinned announced request follows the replacement, while a blind or epoch-pinned request stays on its accepted instance. With an epoch, which travels only when `moq-lite-07-wip` is offered, the replacement serves the same broadcast, so open subscriptions move across without missing a group. An empty GOAWAY redials the same URL. A redirect stays on the same host unless `goaway.redirect` is `"follow"`, which also presents the tokens on the connection's `auth` handle to the new host. - **One send estimate per connection.** `Bandwidth.Allocator` divides it by track priority, max-min fair within a tier. An idle track claims nothing. Publishers reserve against it so their targets sum to the estimate. - **List a broadcast's tracks.** `for await (const event of broadcast.tracks())` reports each track the broadcast lists (`added`, with its `Track.Info`), one `synced`, then `added` and `removed` as tracks come and go, until the broadcast ends; leaving the loop stops listing. Over a session it needs `moq-lite-07-wip`, syncs at once with what this side holds, and the peer's tracks follow as `added`. A track served only on request is never listed. See [discovery](/concept/moq-lite#discovery). - **One track per name.** Concurrent subscriptions share one request and producer. `createTrack` on a name with a request still queued answers it. The application owns sequence numbers: a track that replaces an ended one, created or requested, throws on an append that would reuse a sequence the name already wrote, so it writes its first group with an explicit sequence. Only a new broadcast starts over. diff --git a/js/net/src/connection/goaway.ts b/js/net/src/connection/goaway.ts index 75fca7f1d7e..73e0f2b0f9b 100644 --- a/js/net/src/connection/goaway.ts +++ b/js/net/src/connection/goaway.ts @@ -22,8 +22,9 @@ export interface Drain { * * - `same-host` (the default) follows it only onto the host already dialed, so a peer can move * the connection between ports or schemes but not to another host. - * - `follow` also lets the peer name the host. The name is dialed as written, so only use it - * with a relay trusted not to point the connection into the local network. + * - `follow` also lets the peer name the host. The name is dialed as written, and the new host + * is presented every token on the connection's `auth` handle, so only use it with a relay + * trusted not to point the connection into the local network or at an untrusted host. * - `ignore` reconnects to the current URL whatever the peer names. * * A malformed or refused URI ends the connection with {@link RefusedRedirect}. diff --git a/rs/moq-tokio/src/connection.rs b/rs/moq-tokio/src/connection.rs index f9553895e73..ce44ea538fb 100644 --- a/rs/moq-tokio/src/connection.rs +++ b/rs/moq-tokio/src/connection.rs @@ -204,7 +204,8 @@ pub enum Redirect { /// range, or the metadata service, and nothing here resolves it to find out. A /// *literal* loopback, private, link-local, or IPC target is still refused, but /// that catches a misconfigured peer, not a hostile one. Use it only with an - /// upstream trusted with the local network. + /// upstream trusted with the local network and with the tokens on + /// [`Connection::auth`], which every session presents, on any host. Follow, /// Follow only when the host matches the configured URL, so a peer can move us /// between ports or schemes but not to another host. The default. From c7d069e7e70663feacb57dd23bf710874abde6aa Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 22 Oct 2026 15:12:40 -0700 Subject: [PATCH 18/31] fix(net): a lost retry stream does not end a token as unsupported A token that waited out a transient refusal was presented on a fresh stream that forgot the peer had replied, so losing that stream before its reply read as a peer without AUTH and ended a shared handle's token on every session. The retry now keeps the earlier reply, so the loss leaves the token for the next session, in Rust and JS. Co-Authored-By: Claude Opus 5.5 --- js/net/src/auth.test.ts | 42 +++++++++++++++++++ js/net/src/auth_session.ts | 11 +++-- rs/moq-net/src/lite/session.rs | 76 ++++++++++++++++++++++++++++++---- 3 files changed, 118 insertions(+), 11 deletions(-) diff --git a/js/net/src/auth.test.ts b/js/net/src/auth.test.ts index f6730137478..adc140349df 100644 --- a/js/net/src/auth.test.ts +++ b/js/net/src/auth.test.ts @@ -725,6 +725,48 @@ test("lite-07 presents a transiently refused token again after a backoff", async } }); +// The peer replied to the first presentation, so it knows AUTH: a reset is a lost stream. +test("lite-07 keeps a shared token whose retry stream is reset", async () => { + const clock = new Manual(); + const restore = clock.install(); + const warned = spyOn(console, "warn").mockImplementation(() => {}); + const logged = (line: string) => warned.mock.calls.filter(([first]) => first === line).length; + const handle = new Handle(); + const { client, server } = await connect({ + publish: new OriginProducer(), + protocol: Lite.ALPN_07_WIP, + auth: handle, + }); + const requests = server.auth.requests(); + let read: ReturnType | undefined; + try { + const setup = await requests.next(); + setup?.accept(grant([], [])); + + let rejected: unknown; + handle.add("token").catch((err) => { + rejected = err; + }); + const request = await requests.next(); + request?.reject(SessionCode.TooManyRequests, "busy"); + await settles(() => logged("auth token refused") > 0); + + read = spyOn(LiteAuthWire.prototype, "read").mockRejectedValue( + new StreamError(StreamCode.Cancel, { message: "reset" }), + ); + await clock.advance(Nano.fromMilli(Milli(1000))); + await settles(() => logged("auth token ended") > 0); + for (let i = 0; i < 10; i++) await nextTask(); + expect(rejected).toBeUndefined(); + } finally { + read?.mockRestore(); + client.abort(); + server.abort(); + warned.mockRestore(); + restore(); + } +}); + test("lite-07 presents a token added after a GOAWAY only on the successor", async () => { const protocol = Lite.ALPN_07_WIP; const handle = new Handle(); diff --git a/js/net/src/auth_session.ts b/js/net/src/auth_session.ts index a0523650b35..e4e3e28b43a 100644 --- a/js/net/src/auth_session.ts +++ b/js/net/src/auth_session.ts @@ -794,13 +794,16 @@ export class AuthSession { let closed = false; // The peer refused the last attempt with a reply, rather than losing its stream. let refused = false; - // The peer granted the token at some point, so a clean close ends the grant. + // The peer granted the token, so a clean close ends the grant. A retry clears it. let granted = false; + // The peer replied to an earlier attempt, so it knows AUTH. + let replied = false; let delay: Milli | undefined; for (;;) { refused = false; - // Whether this attempt's stream got a grant, so a close is not the peer predating AUTH. - let answered = false; + // Whether the peer answered this token, so a close is not the peer predating AUTH: + // a grant on this stream, or a reply to an earlier one that this retry follows. + let answered = replied; try { const stream = await wire.present(presented.token); presented.stream = stream; @@ -844,6 +847,7 @@ export class AuthSession { reply.refused instanceof StreamError && reply.refused.code === StreamCode.GoingAway; if (!goingAway && !(reply.refused instanceof Unsupported)) this.#replied = true; result = reply.refused; + replied = true; // A peer going away judged nothing, so the successor presents the token instead. refused = !goingAway; stream.close(); @@ -871,6 +875,7 @@ export class AuthSession { // carries such a code. if (!refused || !transient(result) || this.#closed || presented.withdrawn) break; presented.grant = undefined; + granted = false; // Unanswered again, as on its first presentation: a request or publication the token // might cover waits for the retry's verdict rather than being judged without it. this.#unsettle(presented); diff --git a/rs/moq-net/src/lite/session.rs b/rs/moq-net/src/lite/session.rs index 7e1682aa8d6..49b21cb1a25 100644 --- a/rs/moq-net/src/lite/session.rs +++ b/rs/moq-net/src/lite/session.rs @@ -513,7 +513,7 @@ impl Present { handle: self.handle.clone(), violation: self.violation.clone(), id, - state: PresentState::Open { token }, + state: PresentState::Open { token, answered: false }, }); } let _ = self.tokens.poll(waiter); @@ -540,6 +540,9 @@ struct PresentToken { enum PresentState { Open { token: bytes::Bytes, + /// The peer already replied to this token, so it knows AUTH: a retry's stream lost + /// before its reply is not the peer predating AUTH. + answered: bool, }, Run { stream: Stream, @@ -557,7 +560,10 @@ impl kio::Task for PresentToken { fn poll(&mut self, waiter: &kio::Waiter) -> Poll<()> { let err = ready!(self.poll_present(waiter)); - let answered = matches!(self.state, PresentState::Run { answered: true, .. }); + let answered = matches!( + self.state, + PresentState::Open { answered: true, .. } | PresentState::Run { answered: true, .. } + ); if let PresentState::Run { stream, .. } = std::mem::replace(&mut self.state, PresentState::Done) { stream.writer.abort(&Error::Cancel); } @@ -590,12 +596,13 @@ impl PresentToken { let mut cx = Context::from_waker(waiter.waker()); loop { match &mut self.state { - PresentState::Open { token } => { + PresentState::Open { token, answered } => { // After a GOAWAY, sent or received, the peer must not see new streams. if !self.handle.active() { return Poll::Ready(Error::GoingAway); } let token = token.clone(); + let answered = *answered; let mut stream = match ready!(Stream::poll_open(&mut self.session, self.version, &mut cx)) { Ok(stream) => stream, Err(err) => return Poll::Ready(err), @@ -607,10 +614,7 @@ impl PresentToken { if let Err(err) = res { return Poll::Ready(err); } - self.state = PresentState::Run { - stream, - answered: false, - }; + self.state = PresentState::Run { stream, answered }; } PresentState::Run { stream, answered } => { if let Err(err) = ready!(stream.writer.poll_flush(&mut cx)) { @@ -682,7 +686,7 @@ impl PresentToken { let Some(token) = self.handle.reopen(self.id) else { return Poll::Ready(Error::Cancel); }; - self.state = PresentState::Open { token }; + self.state = PresentState::Open { token, answered: true }; } PresentState::Done => return Poll::Ready(Error::Cancel), } @@ -965,6 +969,62 @@ mod tests { assert_eq!(log.closes()[0].0, SessionError::ProtocolViolation.to_code()); } + /// A retry's stream that ends before its reply is lost, not a peer without AUTH: the + /// peer already answered the token, so a shared handle keeps it for the next session. + #[moq_clock::test] + async fn a_lost_retry_keeps_a_shared_token() { + let version = Version::Lite07; + let mut grant = Vec::new(); + let mut w = crate::coding::Encoder::new(&mut grant, version.into()); + w.varint(0).unwrap(); + let prefix = w.prefix_varint(); + for _ in 0..3 { + w.varint(0).unwrap(); + } + w.fill(prefix).unwrap(); + let mut busy = Vec::new(); + let mut w = crate::coding::Encoder::new(&mut busy, version.into()); + w.varint(1).unwrap(); + let prefix = w.prefix_varint(); + w.varint(SessionError::TooManyRequests.to_code().into()).unwrap(); + w.bytes(b"busy").unwrap(); + w.fill(prefix).unwrap(); + + // The setup token is granted, ours is refused transiently, and its retry's stream ends. + let transport = ScriptedSession::per_stream_eof(vec![grant, busy, Vec::new()]); + let log = transport.log.clone(); + let handle = crate::auth::Handle::new(); + let start = start(Config { + runtime: crate::time::sim(), + client: true, + limits: Default::default(), + session: transport, + setup_stream: None, + publish: None, + subscribe: None, + peer_hop: None, + version, + our_setup: Setup::default(), + peer_setup: None, + auth: crate::auth::Link::attach(&handle, true, false).unwrap(), + }) + .unwrap(); + let mut driver = start.driver; + let token = handle.present(bytes::Bytes::from_static(b"token")).unwrap(); + + let res = moq_clock::timeout( + std::time::Duration::from_secs(5), + kio::wait(|waiter| driver.poll(waiter)), + ) + .await; + assert!(res.is_err(), "the session ended: {res:?}"); + assert_eq!(log.bi_opens(), 3, "the token was not presented again"); + assert!( + futures::FutureExt::now_or_never(token.closed()).is_none(), + "a lost retry ended the token" + ); + } + #[moq_clock::test] async fn malformed_presenter_auth_closes_the_session() { for script in [vec![7, 1, 2], vec![7, 1, 0, 0]] { From a4c2e3c724f93449efc0121175acd76056eb9364 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 22 Oct 2026 15:22:20 -0700 Subject: [PATCH 19/31] fix(ffi): keep a granted token whose session drops before add returns The FFI refused a token with no grant as one only requests carry, but a shared handle's token also reads no grant once its session drops, so a grant racing a disconnect came back Unsupported. Auth::in_band records the grant on the handle, so the FFI asks that instead. Co-Authored-By: Claude Opus 5.5 --- rs/moq-ffi/src/auth.rs | 8 ++++---- rs/moq-net/src/auth.rs | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/rs/moq-ffi/src/auth.rs b/rs/moq-ffi/src/auth.rs index 523019e4bb7..3c7004b63b3 100644 --- a/rs/moq-ffi/src/auth.rs +++ b/rs/moq-ffi/src/auth.rs @@ -35,10 +35,10 @@ impl From for MoqGrant { /// Present `token` on every session of `handle`, resolving once a peer accepts it. async fn add(handle: moq_net::auth::Handle, token: String) -> Result { let auth = handle.add(token).await?; - // A token the peer accepted carries a grant until it ends. Without AUTH, moq-transport - // accepts one at once for the requests that name it, but nothing here can name one, so it - // would never be sent. One already revoked is left for `closed` to report. - if auth.grant().peek().is_none() && poll_now(auth.closed()).is_pending() { + // Without AUTH, moq-transport accepts a token at once for the requests that name it, but + // nothing here can name one, so it would never be sent. A grant in band is no such token, + // even once its session drops, and one already revoked is left for `closed` to report. + if !auth.in_band() && poll_now(auth.closed()).is_pending() { return Err(MoqError::Unsupported); } Ok(auth) diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index 66de8d0b1f0..e59582c07bf 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -171,6 +171,9 @@ struct Token { value: Bytes, /// The first verdict any session's peer gave: `Ok` for a grant, or why it ended. answered: Option>, + /// Whether a session's peer granted it on an AUTH stream, rather than only accepting it + /// for the requests that name it. + granted: bool, /// Why the token ended for good: a peer's refusal or revocation, or the handle's end. ended: Option, } @@ -564,6 +567,7 @@ impl Handle { Token { value: token.clone(), answered: None, + granted: false, ended: None, }, ); @@ -983,6 +987,9 @@ impl Link { slot.retry = None; link.replied = true; link.recompute(); + if let Some(token) = state.tokens.get_mut(&id) { + token.granted = true; + } state.answer(id, Ok(())); }); } @@ -1349,6 +1356,15 @@ impl Auth { Watch::new(self.presented.state.clone(), self.presented.selector()) } + /// Whether a peer has granted this credential in band, on its AUTH stream, rather than + /// only accepting it for the requests that name it (moq-transport without AUTH). + /// + /// Unlike [`grant`](Self::grant), it stays true while no session is live. + pub fn in_band(&self) -> bool { + let state = self.presented.state.read(); + state.tokens.get(&self.presented.id).is_some_and(|token| token.granted) + } + /// Wait for the credential to end for good: a peer's refusal or revocation as /// [`Error::Session`], [`Error::Cancel`] when the peer closed it without one, or the /// handle's end. A session ending is not one: the next session presents it again. @@ -2835,6 +2851,26 @@ mod tests { ); } + /// A token granted in band stays so while no session is live, unlike one only requests + /// carry, so a caller can tell the two apart after a disconnect. + #[test] + fn a_grant_in_band_outlives_its_session() { + let handle = Handle::new(); + let session = Link::attach(&handle, true, false).unwrap(); + let auth = handle.present(Bytes::from_static(b"jwt")).unwrap(); + assert!(!auth.in_band()); + session.granted(opened(&session), Grant::all()); + session.close(Error::Cancel); + assert_eq!(auth.grant().peek(), None); + assert!(auth.in_band()); + + let handle = Handle::new(); + let _session = Link::attach(&handle, false, true).unwrap(); + let auth = handle.present(Bytes::from_static(b"jwt")).unwrap(); + assert!(matches!(auth.poll_answered(&kio::Waiter::noop()), Poll::Ready(Ok(())))); + assert!(!auth.in_band()); + } + /// A session's own handle has no successor: a token added after its GOAWAY fails at once, /// and one whose stream ends without a verdict ends for good. #[test] From 4496e4af5d0482cad50c92e9f944b2519a239b30 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 22 Oct 2026 15:36:48 -0700 Subject: [PATCH 20/31] fix(js/net): settle the setup wait when an auth session closes Close marks every token here answered without counting the setup token down, so setupAnswered() parked forever on a session closed before the peer answered it. Co-Authored-By: Claude Opus 5.5 --- js/net/src/auth.test.ts | 22 ++++++++++++++++++++++ js/net/src/auth_session.ts | 1 + 2 files changed, 23 insertions(+) diff --git a/js/net/src/auth.test.ts b/js/net/src/auth.test.ts index adc140349df..2179706bcad 100644 --- a/js/net/src/auth.test.ts +++ b/js/net/src/auth.test.ts @@ -656,6 +656,28 @@ describe.each([Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_22])("%s", (protocol) => { }); }); +// Close ends every token here unanswered, so nothing waiting on the setup token stays parked. +test("closing a session settles its unanswered setup token", async () => { + const { client, server } = await connect({ publish: new OriginProducer(), protocol: Lite.ALPN_07_WIP }); + const requests = server.auth.requests(); + try { + await requests.next(); + let settled = false; + void wireOf(client) + .auth.setupAnswered() + .then(() => { + settled = true; + }); + for (let i = 0; i < 10; i++) await nextTask(); + expect(settled).toBe(false); + client.abort(); + await settles(() => settled); + } finally { + client.abort(); + server.abort(); + } +}); + // moq-transport's AUTH_ERROR carries no code that may pass, so only lite retries. test("lite-07 presents a transiently refused token again after a backoff", async () => { const clock = new Manual(); diff --git a/js/net/src/auth_session.ts b/js/net/src/auth_session.ts index e4e3e28b43a..ae4cd027d0f 100644 --- a/js/net/src/auth_session.ts +++ b/js/net/src/auth_session.ts @@ -770,6 +770,7 @@ export class AuthSession { if (this.#closed) return; this.#closed = true; this.#pending.set(0); + this.#setupPending.set(0); for (const presented of this.#presented) { presented.ended = true; presented.isAnswered = true; From 081b7565e22405025aab7484539c4c20f60aa502 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 22 Oct 2026 15:42:55 -0700 Subject: [PATCH 21/31] docs(js/net): say a private connection's auth handle follows its URL Co-Authored-By: Claude Opus 5.5 --- js/net/src/connection/pool.ts | 8 +++++--- js/net/src/connection/reload.ts | 3 ++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/js/net/src/connection/pool.ts b/js/net/src/connection/pool.ts index ef2f7fc2fa1..a85b3ee143b 100644 --- a/js/net/src/connection/pool.ts +++ b/js/net/src/connection/pool.ts @@ -188,11 +188,13 @@ export class Connection { readonly origin: Getter; /** - * The auth handle for the current URL, or undefined while disabled or URL-less. + * The auth handle every session presents, or undefined while disabled or URL-less. * * Every session the connection makes presents the tokens it holds, so a route naming one - * keeps it across a reconnect, and its grant follows the live session. A shared handle's - * is the same one every other handle on this URL uses, since they share the session. + * keeps it across a reconnect, and its grant follows the live session. A private + * connection keeps one handle across URL changes, so its tokens go to the new URL, and + * starts another when `versions` changes. A shared connection's is the one every other + * handle on its URL uses, since they share the session. */ readonly auth: Getter; diff --git a/js/net/src/connection/reload.ts b/js/net/src/connection/reload.ts index d9bc70d47b4..71b61cd61b1 100644 --- a/js/net/src/connection/reload.ts +++ b/js/net/src/connection/reload.ts @@ -153,7 +153,8 @@ export class Reload { * The auth handle every session of the loop is built on, spanning reconnects. * * Each session presents every token it holds, so a route naming one keeps it across a - * reconnect, and its grant follows the live session. It ends when the loop closes, and + * reconnect, and its grant follows the live session. It spans URL changes too, so its + * tokens go to whatever URL the loop dials next. It ends when the loop closes, and * aborting it stops the loop at its next reconnect, with the abort's error. */ readonly auth = new AuthHandle(); From 7d894b0d37c32c5623be79b288ac8ddb55a61b76 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 22 Oct 2026 16:23:48 -0700 Subject: [PATCH 22/31] refactor(net)!: resolve auth add with how the peer took the token Auth::in_band is gone. Handle::add resolves with (Auth, Answer), where Answer::Granted is a grant on the token's AUTH stream and Answer::Requests a token only the requests naming it carry. The answer is recorded once, so moq-ffi reads it instead of inferring request-only from a missing grant, which a session dropping before add returns also leaves. Co-Authored-By: Claude Opus 5.5 --- doc/lib/rs/moq-net.md | 2 +- rs/moq-ffi/src/auth.rs | 101 ++++++++++++++++++-- rs/moq-net/MIGRATE.md | 9 ++ rs/moq-net/benches/auth.rs | 2 +- rs/moq-net/src/auth.rs | 106 ++++++++++++--------- rs/moq-net/src/ietf/session.rs | 3 +- rs/moq-net/tests/integration/auth.rs | 43 +++++---- rs/moq-net/tests/integration/extensions.rs | 3 +- rs/moq-relay/benches/auth.rs | 2 +- rs/moq-relay/src/auth/refresh.rs | 6 +- rs/moq-tokio/src/connection.rs | 2 +- 11 files changed, 197 insertions(+), 82 deletions(-) diff --git a/doc/lib/rs/moq-net.md b/doc/lib/rs/moq-net.md index 2c0e321885b..b8acd829e1a 100644 --- a/doc/lib/rs/moq-net.md +++ b/doc/lib/rs/moq-net.md @@ -26,7 +26,7 @@ What you use it for, beyond what the concept page already describes: - **Use the model without a runtime.** Disable default features on `moq-net` to use its `poll_*` APIs and origin driver with caller-supplied time. The default `async` feature adds async helpers, `time::run`, and both wire sessions. - **You drive the session, or `moq-tokio` does.** `connect` and `accept` return a session plus a driver that never reads the clock itself. `moq_net::time::run` polls it on tokio or in the browser. `moq-tokio` and `moq-wasm` hide that. A custom transport implements `moq_net::transport::poll`, and `moq_net::transport::memory::pair` links two sessions in one process with no sockets, for tests or in-process links. - **A session caps what its peer can hold.** `session::Limits` (via `Client::with_limits` and `Server::with_limits`) bounds announces, subscriptions, and FETCH requests independently per session; the defaults suit a relay mesh, so lower them for untrusted peers. Past a cap, the session closes with `TOO_MANY_REQUESTS`. On moq-transport drafts 14 to 16 they also size the request-ID window, which every request counts against, so very low limits can starve it. Peer-declared lengths are capped before they are buffered. -- **Authorization is in band.** On moq-lite 07 (`moq-lite-07-wip`, opt-in) and on moq-transport draft-17+ with the [MoQ Auth extension](/draft/moq-auth), `session.auth()` presents tokens without reconnecting and watches the grant the peer sent back. A handle passed to `Client::with_auth` outlives each session, so its tokens, and the routes naming them, carry across your own reconnects. A server verifies tokens itself by taking `handshake.auth().requests()` before `ok()`. `authorize(&grant)` narrows or widens a live session on any version: what falls outside resets with `Unauthorized` and the session stays up. A client that publishes outside its grant closes the session, naming the path. +- **Authorization is in band.** On moq-lite 07 (`moq-lite-07-wip`, opt-in) and on moq-transport draft-17+ with the [MoQ Auth extension](/draft/moq-auth), `session.auth()` presents tokens without reconnecting and watches the grant the peer sent back. `add(token)` resolves with the token and how the peer took it: `Answer::Granted` on its AUTH stream, or `Answer::Requests` on moq-transport without AUTH, where only a request that names it carries it. A handle passed to `Client::with_auth` outlives each session, so its tokens, and the routes naming them, carry across your own reconnects. A server verifies tokens itself by taking `handshake.auth().requests()` before `ok()`. `authorize(&grant)` narrows or widens a live session on any version: what falls outside resets with `Unauthorized` and the session stays up. A client that publishes outside its grant closes the session, naming the path. - **A graceful close waits.** `session.close().await` withdraws announcements and drains for up to one second. Application delivery requires explicit `moq-lite-07-wip`; older Lite versions (including default lite-06) and IETF wait only for transport ACKs. `abort` ends immediately. IETF drafts 14 through 16 send withdrawals without waiting. ```bash diff --git a/rs/moq-ffi/src/auth.rs b/rs/moq-ffi/src/auth.rs index 3c7004b63b3..c35c3a22fd9 100644 --- a/rs/moq-ffi/src/auth.rs +++ b/rs/moq-ffi/src/auth.rs @@ -34,14 +34,13 @@ impl From for MoqGrant { /// Present `token` on every session of `handle`, resolving once a peer accepts it. async fn add(handle: moq_net::auth::Handle, token: String) -> Result { - let auth = handle.add(token).await?; - // Without AUTH, moq-transport accepts a token at once for the requests that name it, but - // nothing here can name one, so it would never be sent. A grant in band is no such token, - // even once its session drops, and one already revoked is left for `closed` to report. - if !auth.in_band() && poll_now(auth.closed()).is_pending() { - return Err(MoqError::Unsupported); + // Without AUTH, moq-transport accepts a token only for the requests that name it, but + // nothing here can name one, so it would never be sent. The answer says how the peer + // accepted it, which a grant missing after its session drops cannot. + match handle.add(token).await? { + (auth, moq_net::auth::Answer::Granted) => Ok(auth), + (_, moq_net::auth::Answer::Requests) => Err(MoqError::Unsupported), } - Ok(auth) } /// Poll `future` once without waiting. @@ -165,3 +164,91 @@ impl MoqAuthToken { self.token.cancel(); } } + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use crate::session::{MoqClient, MoqClientConfig}; + + const TIMEOUT: Duration = Duration::from_secs(10); + + /// Wait until `auth` reports `want`, following its changes. + async fn await_grant(auth: &super::MoqAuth, want: Option>) { + let publish = || auth.grant().map(|grant| grant.publish); + tokio::time::timeout(TIMEOUT, async { + while publish() != want { + auth.grant_changed().await.expect("grant watch ended"); + } + }) + .await + .unwrap_or_else(|_| panic!("timed out waiting for {want:?}, last {:?}", publish())); + } + + /// A token the peer granted is not refused as request-only when its session drops before + /// `add` reads the answer, though its grant then reads `None`. + #[tokio::test] + async fn a_grant_survives_its_session_dropping_before_add_returns() { + let version: moq_net::Version = "moq-lite-07-wip".parse().unwrap(); + let mut config = moq_tokio::listen::Config::default(); + config.tcp.bind = Some("127.0.0.1:0".parse().unwrap()); + config.version = vec![version]; + let mut listener = config.init(Default::default()).unwrap().listen().await.unwrap(); + let addr = listener.tcp_local_addr().unwrap(); + + let (drop_tx, drop_rx) = tokio::sync::oneshot::channel::<()>(); + let serving = tokio::spawn(async move { + let request = listener.accept().await.expect("accept"); + // Nothing redials: the client stays disconnected once the session drops. + drop(listener); + let mut requests = request.auth().requests().unwrap(); + let (session, driver) = request.ok().await.unwrap(); + let mut issued = Vec::new(); + for _ in 0..2 { + let request = requests.next().await.expect("token"); + let pattern = match request.token().as_ref() { + b"" => "setup/**", + _ => "extra/**", + }; + issued.push(request.accept(moq_net::auth::Grant { + publish: pattern.parse::().unwrap().into(), + ..Default::default() + })); + } + let _ = drop_rx.await; + drop((session, driver, issued, requests)); + }); + + let client = MoqClient::new(MoqClientConfig { + versions: vec!["moq-lite-07-wip".into()], + bind: Some("127.0.0.1:0".into()), + ..Default::default() + }) + .unwrap(); + let session = tokio::time::timeout(TIMEOUT, client.connect(format!("tcp://{addr}"))) + .await + .expect("connect timed out") + .unwrap(); + let auth = session.auth(); + await_grant(&auth, Some(vec!["setup/**".into()])).await; + + // Presented and granted, but not read until the session is gone. + let mut adding = Box::pin(super::add(auth.handle.clone(), "extra".into())); + assert!(super::poll_now(adding.as_mut()).is_pending()); + await_grant(&auth, Some(vec!["extra/**".into(), "setup/**".into()])).await; + drop_tx.send(()).unwrap(); + await_grant(&auth, None).await; + + let std::task::Poll::Ready(added) = super::poll_now(adding.as_mut()) else { + panic!("add did not resolve"); + }; + let token = added.expect("a granted token refused after its session dropped"); + assert_eq!(token.grant().peek(), None, "no session is live"); + + session.cancel(0); + tokio::time::timeout(TIMEOUT, serving) + .await + .expect("server ended") + .unwrap(); + } +} diff --git a/rs/moq-net/MIGRATE.md b/rs/moq-net/MIGRATE.md index 61eeb87061b..ea7745ac883 100644 --- a/rs/moq-net/MIGRATE.md +++ b/rs/moq-net/MIGRATE.md @@ -13,6 +13,15 @@ The handle backs one active session at a time, plus the ones draining after a GO `Auth::closed()` resolves only on the peer's verdict (a refusal, `Unsupported`, or a clean close after a grant) or the handle's end, which `Handle::abort` triggers; a session ending is not one. A handle a session creates for itself still ends with that session. On moq-lite, a token refused with a code that may pass (`Internal`, `TooManyRequests`, `Timeout`) is presented again after a backoff of 1s doubling to 30s, instead of `add` failing with that code. +`Handle::add` resolves with `(Auth, Answer)` instead of `Auth`: `Answer::Granted` when a peer granted the token on its AUTH stream, or `Answer::Requests` when only the requests that name it carry it (moq-transport without AUTH). +The answer holds after the session drops, unlike `Auth::grant()`, so read it rather than inferring support from a missing grant. + +```rust +// Before +let token = handle.add(jwt).await?; +// After +let (token, answer) = handle.add(jwt).await?; +``` ### Receive streams are read through leases (#5878) diff --git a/rs/moq-net/benches/auth.rs b/rs/moq-net/benches/auth.rs index 31b18d2d737..ba67658362f 100644 --- a/rs/moq-net/benches/auth.rs +++ b/rs/moq-net/benches/auth.rs @@ -184,7 +184,7 @@ fn renewal(c: &mut Criterion) { let (session, _, requests) = &mut publishers[0]; let handle = session.auth(); let auths = runtime - .block_on(async { [handle.add("a").await.unwrap(), handle.add("b").await.unwrap()] }); + .block_on(async { [handle.add("a").await.unwrap().0, handle.add("b").await.unwrap().0] }); let broadcasts: Vec<_> = (0..routes) .map(|route| origin.create_broadcast(format!("room/{route}").as_str()).unwrap()) .collect(); diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index e59582c07bf..098084fa56f 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -69,6 +69,15 @@ macro_rules! ready_or { }; } +/// How a peer accepted a token [`Handle::add`] presented. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Answer { + /// Granted on its AUTH stream: its grant joins the union. + Granted, + /// Taken only for the requests that name it, on moq-transport without AUTH. + Requests, +} + /// What a peer lets this side do, in this side's own paths. /// /// The paths are relative to the session's root, the same paths the session @@ -169,11 +178,8 @@ pub(crate) struct State { /// A token the application added, presented on every session of its handle. struct Token { value: Bytes, - /// The first verdict any session's peer gave: `Ok` for a grant, or why it ended. - answered: Option>, - /// Whether a session's peer granted it on an AUTH stream, rather than only accepting it - /// for the requests that name it. - granted: bool, + /// The first verdict any session's peer gave: how it accepted the token, or why it ended. + answered: Option>, /// Why the token ended for good: a peer's refusal or revocation, or the handle's end. ended: Option, } @@ -413,13 +419,13 @@ impl State { self.links.values().rev().find(|link| link.closed.is_none()) } - /// Record a session's answer to token `id`: a grant settles the application's `add`, and - /// a refusal is the verdict that ends the token on every session. - fn answer(&mut self, id: u64, answer: Result<()>) { + /// Record a session's answer to token `id`: an acceptance settles the application's `add`, + /// and a refusal is the verdict that ends the token on every session. + fn answer(&mut self, id: u64, answer: Result) { match answer { - Ok(()) => { + Ok(answer) => { if let Some(token) = self.tokens.get_mut(&id) { - token.answered.get_or_insert(Ok(())); + token.answered.get_or_insert(Ok(answer)); } } Err(err) => self.end_token(id, err), @@ -528,13 +534,15 @@ impl Handle { Watch::new(self.state.clone(), Selector::Union) } - /// Present another token on every session of this handle, resolving once a peer answers it. + /// Present another token on every session of this handle, resolving once a peer accepts it + /// with how it did. /// /// The grant joins the union while any clone of the returned [`Auth`] lives. With no /// session live it waits for the next one. A refusal that may pass, such as an /// unreachable auth server, is presented again after a backoff. On moq-transport - /// without AUTH there is no answer to wait for: it resolves at once with a grant that - /// stays unknown, and only a request that names it carries the token. + /// without AUTH there is no answer to wait for: it resolves at once with + /// [`Answer::Requests`] and a grant that stays unknown, and only a request that names it + /// carries the token. /// /// # Errors /// @@ -542,10 +550,10 @@ impl Handle { /// (moq-lite without AUTH, or a peer that takes no tokens in band), the peer's refusal /// as [`Error::Session`], or the handle's end. #[cfg(feature = "async")] - pub async fn add(&self, token: impl Into) -> Result { + pub async fn add(&self, token: impl Into) -> Result<(Auth, Answer)> { let auth = self.present(token.into())?; - kio::wait(|waiter| auth.poll_answered(waiter)).await?; - Ok(auth) + let answer = kio::wait(|waiter| auth.poll_answered(waiter)).await?; + Ok((auth, answer)) } /// Register a token and queue it on every open session. @@ -567,7 +575,6 @@ impl Handle { Token { value: token.clone(), answered: None, - granted: false, ended: None, }, ); @@ -577,7 +584,7 @@ impl Handle { .filter_map(|link| link.offer(id, &token)) .collect(); for answer in answers { - state.answer(id, answer); + state.answer(id, answer.map(|()| Answer::Requests)); } Ok(Auth { presented: std::sync::Arc::new(Presented { @@ -790,7 +797,7 @@ impl Link { .collect(); state.links.insert(id, link); for (token_id, answer) in answers { - state.answer(token_id, answer); + state.answer(token_id, answer.map(|()| Answer::Requests)); } Ok(Self { handle: handle.clone(), @@ -987,10 +994,7 @@ impl Link { slot.retry = None; link.replied = true; link.recompute(); - if let Some(token) = state.tokens.get_mut(&id) { - token.granted = true; - } - state.answer(id, Ok(())); + state.answer(id, Ok(Answer::Granted)); }); } @@ -1266,7 +1270,7 @@ impl Link { // Nothing will read a withdrawn slot again. link.slots.retain(|_, slot| !slot.withdrawn); for (id, answer) in answers { - state.answer(id, answer); + state.answer(id, answer.map(|()| Answer::Requests)); } }); } @@ -1356,15 +1360,6 @@ impl Auth { Watch::new(self.presented.state.clone(), self.presented.selector()) } - /// Whether a peer has granted this credential in band, on its AUTH stream, rather than - /// only accepting it for the requests that name it (moq-transport without AUTH). - /// - /// Unlike [`grant`](Self::grant), it stays true while no session is live. - pub fn in_band(&self) -> bool { - let state = self.presented.state.read(); - state.tokens.get(&self.presented.id).is_some_and(|token| token.granted) - } - /// Wait for the credential to end for good: a peer's refusal or revocation as /// [`Error::Session`], [`Error::Cancel`] when the peer closed it without one, or the /// handle's end. A session ending is not one: the next session presents it again. @@ -1386,7 +1381,7 @@ impl Auth { /// Poll for the first verdict on a handle's token: a grant on any session, or the /// refusal or end that settles it. #[cfg_attr(not(feature = "async"), allow(dead_code))] - fn poll_answered(&self, waiter: &kio::Waiter) -> Poll> { + fn poll_answered(&self, waiter: &kio::Waiter) -> Poll> { let id = self.presented.id; let answered = |state: &State| match state.tokens.get(&id) { Some(token) => token.answered.clone(), @@ -2746,7 +2741,10 @@ mod tests { fn a_request_carries_an_auth_without_the_auth_stream() { let ietf = Link::ietf(false); let auth = ietf.present(Bytes::from_static(b"jwt"), false).unwrap(); - assert!(matches!(auth.poll_answered(&kio::Waiter::noop()), Poll::Ready(Ok(())))); + assert!(matches!( + auth.poll_answered(&kio::Waiter::noop()), + Poll::Ready(Ok(Answer::Requests)) + )); assert_eq!(auth.grant().peek(), None); assert_eq!(ietf.token(&auth).as_deref(), Some(b"jwt".as_slice())); assert!( @@ -2770,7 +2768,10 @@ mod tests { let auth = handle.present(Bytes::from_static(b"jwt"), false).unwrap(); assert!(auth.poll_answered(&kio::Waiter::noop()).is_pending()); handle.unsupported(); - assert!(matches!(auth.poll_answered(&kio::Waiter::noop()), Poll::Ready(Ok(())))); + assert!(matches!( + auth.poll_answered(&kio::Waiter::noop()), + Poll::Ready(Ok(Answer::Requests)) + )); assert_eq!(handle.token(&auth).as_deref(), Some(b"jwt".as_slice())); } @@ -2825,7 +2826,10 @@ mod tests { let auth = handle.present(Bytes::from_static(b"jwt")).unwrap(); let id = opened(&first); first.granted(id, Grant::all()); - assert!(matches!(auth.poll_answered(&kio::Waiter::noop()), Poll::Ready(Ok(())))); + assert!(matches!( + auth.poll_answered(&kio::Waiter::noop()), + Poll::Ready(Ok(Answer::Granted)) + )); assert_eq!(auth.grant().peek(), Some(Grant::all())); first.close(Error::Cancel); @@ -2851,24 +2855,28 @@ mod tests { ); } - /// A token granted in band stays so while no session is live, unlike one only requests - /// carry, so a caller can tell the two apart after a disconnect. + /// The answer records how the peer accepted a token, so a grant whose session drops before + /// the caller reads it is still told apart from a token only requests carry. #[test] - fn a_grant_in_band_outlives_its_session() { + fn the_answer_outlives_its_session() { let handle = Handle::new(); let session = Link::attach(&handle, true, false).unwrap(); let auth = handle.present(Bytes::from_static(b"jwt")).unwrap(); - assert!(!auth.in_band()); session.granted(opened(&session), Grant::all()); session.close(Error::Cancel); assert_eq!(auth.grant().peek(), None); - assert!(auth.in_band()); + assert!(matches!( + auth.poll_answered(&kio::Waiter::noop()), + Poll::Ready(Ok(Answer::Granted)) + )); let handle = Handle::new(); let _session = Link::attach(&handle, false, true).unwrap(); let auth = handle.present(Bytes::from_static(b"jwt")).unwrap(); - assert!(matches!(auth.poll_answered(&kio::Waiter::noop()), Poll::Ready(Ok(())))); - assert!(!auth.in_band()); + assert!(matches!( + auth.poll_answered(&kio::Waiter::noop()), + Poll::Ready(Ok(Answer::Requests)) + )); } /// A session's own handle has no successor: a token added after its GOAWAY fails at once, @@ -2908,7 +2916,10 @@ mod tests { assert!(Selector::Token(auth.id()).ended(&handle.state.read()).is_none()); let id = opened(&successor); successor.granted(id, Grant::all()); - assert!(matches!(auth.poll_answered(&kio::Waiter::noop()), Poll::Ready(Ok(())))); + assert!(matches!( + auth.poll_answered(&kio::Waiter::noop()), + Poll::Ready(Ok(Answer::Granted)) + )); } /// A draining session's late refusal ends the token there alone while its successor @@ -2996,7 +3007,10 @@ mod tests { link.reopen(id); link.granted(id, Grant::all()); - assert!(matches!(auth.poll_answered(&kio::Waiter::noop()), Poll::Ready(Ok(())))); + assert!(matches!( + auth.poll_answered(&kio::Waiter::noop()), + Poll::Ready(Ok(Answer::Granted)) + )); } } diff --git a/rs/moq-net/src/ietf/session.rs b/rs/moq-net/src/ietf/session.rs index 3bf4ffbe250..b5f57f04132 100644 --- a/rs/moq-net/src/ietf/session.rs +++ b/rs/moq-net/src/ietf/session.rs @@ -2141,10 +2141,11 @@ mod tests { let session = auth_session(false); let (handle, log) = (&session.handle, &session.log); - let auth = moq_clock::timeout(std::time::Duration::from_secs(1), handle.handle().add("token")) + let (auth, answer) = moq_clock::timeout(std::time::Duration::from_secs(1), handle.handle().add("token")) .await .expect("resolved promptly") .expect("kept for requests"); + assert_eq!(answer, crate::auth::Answer::Requests); assert_eq!(auth.grant().peek(), None, "no grant without the extension"); assert_eq!(handle.handle().grant().peek(), None, "no grant without the extension"); diff --git a/rs/moq-net/tests/integration/auth.rs b/rs/moq-net/tests/integration/auth.rs index ebd9a85461a..c2fbf691b3f 100644 --- a/rs/moq-net/tests/integration/auth.rs +++ b/rs/moq-net/tests/integration/auth.rs @@ -169,7 +169,8 @@ async fn moqt_22_renewing_a_cancelled_announce_keeps_the_session() { within(async { let (mut pair, client_origin, _server_origin) = connect_route_auth(MOQT_22).await; let mut tokens = serve(pair.requests.take().unwrap(), |_| Some(Grant::all())); - let first = pair.client.auth().add("first").await.expect("kept for requests"); + let (first, answer) = pair.client.auth().add("first").await.expect("kept for requests"); + assert_eq!(answer, auth::Answer::Requests); let broadcast = client_origin.create_broadcast("room/x").unwrap(); broadcast.announce(origin::Route::default().with_auth(first)).unwrap(); let (_, first) = tokens.next().await.expect("the announce's token"); @@ -181,7 +182,7 @@ async fn moqt_22_renewing_a_cancelled_announce_keeps_the_session() { let (token, _again) = tokens.next().await.expect("offered again after the cancel"); assert_eq!(token, b"first"); moq_clock::sleep(Duration::from_secs(1)).await; - let second = pair.client.auth().add("second").await.expect("kept for requests"); + let (second, _) = pair.client.auth().add("second").await.expect("kept for requests"); broadcast.announce(origin::Route::default().with_auth(second)).unwrap(); let (token, _second) = tokens.next().await.expect("announced again"); assert_eq!(token, b"second"); @@ -200,12 +201,12 @@ async fn moqt_22_a_cancelled_renewal_keeps_the_session() { let mut tokens = serve(pair.requests.take().unwrap(), |token| { (token == b"first").then(Grant::all) }); - let first = pair.client.auth().add("first").await.expect("kept for requests"); + let (first, _) = pair.client.auth().add("first").await.expect("kept for requests"); let broadcast = client_origin.create_broadcast("room/x").unwrap(); broadcast.announce(origin::Route::default().with_auth(first)).unwrap(); let (_, first) = tokens.next().await.expect("the announce's token"); - let second = pair.client.auth().add("second").await.expect("kept for requests"); + let (second, _) = pair.client.auth().add("second").await.expect("kept for requests"); broadcast.announce(origin::Route::default().with_auth(second)).unwrap(); moq_clock::sleep(Duration::from_secs(1)).await; drop(first); @@ -224,7 +225,7 @@ async fn moqt_22_dropping_the_old_grant_with_its_renewal_keeps_the_announce() { within(async { let (mut pair, client_origin, server_origin) = connect_route_auth(MOQT_22).await; let mut requests = pair.requests.take().unwrap(); - let first = pair.client.auth().add("first").await.expect("kept for requests"); + let (first, _) = pair.client.auth().add("first").await.expect("kept for requests"); let broadcast = client_origin.create_broadcast("room/x").unwrap(); broadcast.announce(origin::Route::default().with_auth(first)).unwrap(); let first = requests @@ -234,7 +235,7 @@ async fn moqt_22_dropping_the_old_grant_with_its_renewal_keeps_the_announce() { .accept(Grant::all()); wait_announced(&server_origin.consume(), "room/x", true).await; - let second = pair.client.auth().add("second").await.expect("kept for requests"); + let (second, _) = pair.client.auth().add("second").await.expect("kept for requests"); broadcast.announce(origin::Route::default().with_auth(second)).unwrap(); let renewal = requests.next().await.expect("the renewal's token"); assert_eq!(renewal.token().as_ref(), b"second"); @@ -277,7 +278,7 @@ async fn moqt_22_a_revoked_route_auth_remakes_its_announce() { let (token, _setup) = tokens.next().await.expect("the setup token"); assert!(token.is_empty()); - let first = pair.client.auth().add("first").await.expect("granted"); + let (first, _) = pair.client.auth().add("first").await.expect("granted"); let (token, presented) = tokens.next().await.expect("the AUTH stream's token"); assert_eq!(token, b"first"); let broadcast = client_origin.create_broadcast("room/x").unwrap(); @@ -339,7 +340,7 @@ async fn a_route_auth_survives_a_reconnect(version: &'static str) { let server_origin = produce_origin(1); let mut first = connect_route_auth_on(version, &client_origin, &server_origin, Some(handle.clone())).await; let mut tokens = serve(first.requests.take().unwrap(), |_| Some(Grant::all())); - let token = handle.add("first").await.expect("kept for requests"); + let (token, _) = handle.add("first").await.expect("kept for requests"); let broadcast = client_origin.create_broadcast("room/x").unwrap(); broadcast.announce(origin::Route::default().with_auth(token)).unwrap(); let (token, _first) = tokens.next().await.expect("the announce's token"); @@ -365,14 +366,14 @@ async fn a_route_auth_renews_its_announce(version: &'static str) { let (mut pair, client_origin, server_origin) = connect_route_auth(version).await; let mut tokens = serve(pair.requests.take().unwrap(), |_| Some(Grant::all())); - let first = pair.client.auth().add("first").await.expect("kept for requests"); + let (first, _) = pair.client.auth().add("first").await.expect("kept for requests"); let broadcast = client_origin.create_broadcast("room/x").unwrap(); broadcast.announce(origin::Route::default().with_auth(first)).unwrap(); let (token, _first) = tokens.next().await.expect("the announce's token"); assert_eq!(token, b"first"); wait_announced(&server_origin.consume(), "room/x", true).await; - let second = pair.client.auth().add("second").await.expect("kept for requests"); + let (second, _) = pair.client.auth().add("second").await.expect("kept for requests"); broadcast.announce(origin::Route::default().with_auth(second)).unwrap(); let (token, _second) = tokens.next().await.expect("the renewal's token"); assert_eq!(token, b"second"); @@ -775,7 +776,7 @@ async fn tokens_union_and_withdrawing_one_shrinks_it(version: &'static str) { }); let (_, _setup) = issued.next().await.unwrap(); - let t1 = pair.client.auth().add("t1").await.expect("t1 granted"); + let (t1, _) = pair.client.auth().add("t1").await.expect("t1 granted"); let (_, t1_issued) = issued.next().await.unwrap(); assert_eq!(t1.grant().peek(), Some(grant(&["b"], &[]))); assert_eq!(granted(&pair.client).await, grant(&["a", "b"], &[])); @@ -817,7 +818,7 @@ async fn an_update_replaces_one_tokens_grant(version: &'static str) { _ => None, }); let (_, _setup) = issued.next().await.unwrap(); - let t1 = pair.client.auth().add("t1").await.unwrap(); + let (t1, _) = pair.client.auth().add("t1").await.unwrap(); let (_, t1_issued) = issued.next().await.unwrap(); t1_issued.update(grant(&["c"], &["c"])); @@ -860,7 +861,7 @@ async fn a_revoked_grant_withdraws_and_can_be_restored(version: &'static str) { assert!(publisher.consume().routed("a/x").await.is_some()); assert_eq!(pair.client_transport.close_reason(), None); - let _again = pair.client.auth().add("again").await.expect("re-authorized"); + let (_again, _) = pair.client.auth().add("again").await.expect("re-authorized"); wait_announced(&served, "a/x", true).await; }) .await @@ -1027,7 +1028,7 @@ async fn older_versions_have_no_grant(version: &'static str) { match version.starts_with("moq-lite") { true => assert!(matches!(pair.client.auth().add("token").await, Err(Error::Unsupported))), false => { - let auth = pair.client.auth().add("token").await.expect("kept for requests"); + let (auth, _) = pair.client.auth().add("token").await.expect("kept for requests"); assert_eq!(auth.grant().peek(), None); assert_eq!(pair.client.auth().grant().peek(), None); } @@ -1272,7 +1273,7 @@ async fn an_unrepresentable_update_revokes_only_its_token(version: &'static str) _ => None, }); let (_, _setup) = issued.next().await.unwrap(); - let t1 = pair.client.auth().add("t1").await.unwrap(); + let (t1, _) = pair.client.auth().add("t1").await.unwrap(); let (_, t1_issued) = issued.next().await.unwrap(); assert_eq!(granted(&pair.client).await, grant(&["a", "b"], &[])); @@ -1345,7 +1346,7 @@ async fn a_grant_update_too_large_for_one_message_is_unsupported(version: &'stat 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 (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) @@ -1403,7 +1404,7 @@ async fn pattern_grants_arrive_exactly(version: &'static str) { let mut held = Vec::new(); for token in ["exact", "wildcard", "mixed", "root"] { - let added = pair.client.auth().add(token).await.expect(token); + let (added, _) = pair.client.auth().add(token).await.expect(token); assert_eq!(added.grant().peek(), table(token.as_bytes()), "{token}"); held.push(added); } @@ -1905,7 +1906,8 @@ async fn a_shared_handle_presents_its_token_on_every_session(version: &'static s b"" => Some(Grant::default()), _ => Some(grant(&["first"], &[])), }); - let token = added.await.expect("joined").expect("granted on the first session"); + let (token, answer) = added.await.expect("joined").expect("granted on the first session"); + assert_eq!(answer, auth::Answer::Granted); let _first = presented(&mut tokens).await; wait_for(token.grant(), |g| g == &Some(grant(&["first"], &[]))).await; @@ -2007,7 +2009,7 @@ async fn a_handle_backs_one_active_session(version: &'static str) { "the draining session dropped its late grant" ); - let later = handle.add("later").await.expect("granted on the second session"); + let (later, _) = handle.add("later").await.expect("granted on the second session"); let (token, _later) = second_tokens.next().await.expect("the active session presents it"); assert_eq!(token, b"later"); assert!( @@ -2044,7 +2046,8 @@ async fn a_transient_refusal_is_presented_again(version: &'static str) { } } }); - let token = pair.client.auth().add("token").await.expect("granted on the retry"); + let (token, answer) = pair.client.auth().add("token").await.expect("granted on the retry"); + assert_eq!(answer, auth::Answer::Granted); assert_eq!(token.grant().peek(), Some(Grant::default())); let _setup = issued.next().await; let _token = issued.next().await; diff --git a/rs/moq-net/tests/integration/extensions.rs b/rs/moq-net/tests/integration/extensions.rs index d9b5ac39370..7e8364c60db 100644 --- a/rs/moq-net/tests/integration/extensions.rs +++ b/rs/moq-net/tests/integration/extensions.rs @@ -139,10 +139,11 @@ async fn a_declined_auth_extension_is_not_negotiated() { assert_eq!(session.auth().grant().peek(), None, "{context}"); // Only a request that names it can carry the token, so it resolves at once with // a grant that stays unknown. - let added = moq_clock::timeout(SETTLE, session.auth().add("token")) + let (added, answer) = moq_clock::timeout(SETTLE, session.auth().add("token")) .await .unwrap_or_else(|_| panic!("{context}: add never resolved")) .unwrap_or_else(|err| panic!("{context}: {err:?}")); + assert_eq!(answer, moq_net::auth::Answer::Requests, "{context}"); assert_eq!(added.grant().peek(), None, "{context}"); assert_eq!(session.auth().grant().peek(), None, "{context}"); } diff --git a/rs/moq-relay/benches/auth.rs b/rs/moq-relay/benches/auth.rs index dfe37ffc63f..13eba744ef5 100644 --- a/rs/moq-relay/benches/auth.rs +++ b/rs/moq-relay/benches/auth.rs @@ -84,7 +84,7 @@ impl Fixture { producers.push(receive.recv().await.unwrap().1); let auth = connection.auth(); let added = futures::future::join_all((0..tokens).map(|index| auth.add(format!("token{index}")))).await; - let added: Vec<_> = added.into_iter().map(Result::unwrap).collect(); + let added: Vec<_> = added.into_iter().map(|added| added.unwrap().0).collect(); if session == 0 { let mut observed = added.last().unwrap().grant(); observed.changed().await.unwrap(); diff --git a/rs/moq-relay/src/auth/refresh.rs b/rs/moq-relay/src/auth/refresh.rs index 0ffd3d1c705..e2fecce242b 100644 --- a/rs/moq-relay/src/auth/refresh.rs +++ b/rs/moq-relay/src/auth/refresh.rs @@ -911,7 +911,7 @@ mod tests { assert_eq!(admission.request.query.as_deref(), Some("jwt=replacement")); admission.grant(lease::Consumer::fixed(grant("a/**", 20))); }); - let token = token.unwrap(); + let (token, _) = token.unwrap(); clock.advance(Duration::from_secs(4)); assert!( settles_pending(&mut supervising).await, @@ -961,7 +961,7 @@ mod tests { let (token, ()) = tokio::join!(auth.add("replacement"), async { admissions.next().await.unwrap().grant(consumer); }); - let token = token.unwrap(); + let (token, _) = token.unwrap(); assert!( token.grant().peek().unwrap().subscribe.is_empty(), "publisher SETUP role widened" @@ -1010,7 +1010,7 @@ mod tests { assert!( matches!(result, Err(moq_net::Error::Session(SessionError::Unauthorized))), "an empty token reissued a credential the relay removed: {:?}", - result.map(|token| token.grant().peek()) + result.map(|(token, _)| token.grant().peek()) ); assert!(!supervising.is_finished()); client.abort(moq_net::Error::Cancel); diff --git a/rs/moq-tokio/src/connection.rs b/rs/moq-tokio/src/connection.rs index ce44ea538fb..86abeae4620 100644 --- a/rs/moq-tokio/src/connection.rs +++ b/rs/moq-tokio/src/connection.rs @@ -2566,7 +2566,7 @@ mod tests { break; } if let Some(added) = added.take() { - token = Some(added.await.unwrap().expect("granted")); + token = Some(added.await.unwrap().expect("granted").0); } let mut grant = token.as_ref().unwrap().grant(); while grant.peek() != Some(moq_net::auth::Grant::all()) { From c0e30345bb44fd0f7ef3cef0d95f81bc59eae248 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 23 Oct 2026 16:51:01 -0700 Subject: [PATCH 23/31] fix(auth): a draining session never ends a token as a clean close - A retry that wakes on a draining lite session ends with GoingAway, not Cancel, so a session's own handle never reads it as the peer's clean close. - The moq-transport presenter checks the session's own draining state, so a token queued before this side's GOAWAY opens no request on it. - A connection's auth handle ends with Closed or Transport instead of Cancel, so moq-ffi's `closed()` reports the connection's end as an error again. - The FFI and binding docs say a reconnect does not end a token. Co-Authored-By: Claude Opus 5.5 --- go/wrapper/session.go | 9 +++--- py/moq-rs/moq/session.py | 6 ++-- rs/moq-ffi/src/auth.rs | 6 ++-- rs/moq-net/src/auth.rs | 46 ++++++++++++++++++---------- rs/moq-net/src/ietf/auth.rs | 13 +++----- rs/moq-net/src/ietf/session.rs | 1 - rs/moq-net/src/lite/session.rs | 5 +-- rs/moq-net/tests/integration/auth.rs | 45 +++++++++++++++++++++++++++ rs/moq-tokio/src/connection.rs | 19 +++++++++--- swift/Sources/Moq/Auth.swift | 6 ++-- 10 files changed, 113 insertions(+), 43 deletions(-) diff --git a/go/wrapper/session.go b/go/wrapper/session.go index bf5f2f57373..4d778a35a85 100644 --- a/go/wrapper/session.go +++ b/go/wrapper/session.go @@ -96,8 +96,8 @@ func (a *Auth) Grant() *Grant { } // GrantChanged blocks until the grant differs from the one this handle last -// reported, or held when it was made, and returns it. It errors once the session -// ends. +// reported, or held when it was made, and returns it. It errors once a client's +// connection or a server's session ends; a reconnect is not an end. func (a *Auth) GrantChanged(ctx context.Context) (*Grant, error) { grant, err := bridge.Call(ctx, nil, a.inner.GrantChanged) return grantFromFFI(grant), err @@ -128,8 +128,9 @@ func (t *AuthToken) Grant() *Grant { return grantFromFFI(t.inner.Grant()) } -// Closed blocks until the token ends, returning the peer's revocation or the -// session's close, or nil when the peer closed it without an error. +// Closed blocks until the token ends, returning the peer's revocation or the end +// of a client's connection or a server's session, or nil when the peer closed it +// without an error. A reconnect does not end it. func (t *AuthToken) Closed(ctx context.Context) error { return bridge.CallErr(ctx, nil, t.inner.Closed) } diff --git a/py/moq-rs/moq/session.py b/py/moq-rs/moq/session.py index e1782c99579..593fd3f0bbf 100644 --- a/py/moq-rs/moq/session.py +++ b/py/moq-rs/moq/session.py @@ -155,7 +155,8 @@ async def grant_changed(self) -> Grant | None: """Wait for the grant to differ from the one last reported, or held when this handle was made, returning it. - Raises once nothing will change it again, because the session ended.""" + Raises once nothing will change it again: a client's connection or a server's + session ended. A reconnect is not an end.""" return Grant._from_ffi(await self._inner.grant_changed()) async def add(self, token: str) -> AuthToken: @@ -178,7 +179,8 @@ def grant(self) -> Grant | None: return Grant._from_ffi(self._inner.grant()) async def closed(self) -> None: - """Wait for the token to end, raising the peer's revocation or the session's close.""" + """Wait for the token to end, raising the peer's revocation or the end of a client's + connection or a server's session. A reconnect does not end it.""" await self._inner.closed() def cancel(self) -> None: diff --git a/rs/moq-ffi/src/auth.rs b/rs/moq-ffi/src/auth.rs index c35c3a22fd9..57483a80389 100644 --- a/rs/moq-ffi/src/auth.rs +++ b/rs/moq-ffi/src/auth.rs @@ -94,7 +94,8 @@ impl MoqAuth { /// Calls on one handle share what it last reported; an independent watcher takes its own /// handle from [`MoqSession::auth`](crate::session::MoqSession::auth). /// - /// Errors once nothing will change it again, because the session ended. + /// Errors once nothing will change it again: a client's connection or a server's session + /// ended. A reconnect is not an end. pub async fn grant_changed(&self) -> Result, MoqError> { self.changed .run(|mut watch| async move { Ok(watch.changed().await?.map(Into::into)) }) @@ -145,7 +146,8 @@ impl MoqAuthToken { } /// Wait for the token to end: `Ok` when the peer closed it without an error, the peer's - /// revocation as [`MoqError::Protocol`], or the session's close. + /// revocation as [`MoqError::Protocol`], or the end of a client's connection or a server's + /// session as an error. A reconnect does not end it. pub async fn closed(&self) -> Result<(), MoqError> { self.token .run(|token| async move { diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index e00f1c767aa..0c864e435eb 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -1039,21 +1039,26 @@ impl Link { }) } - /// The token to present again once a [`retry`](Self::retry) delay passes, or `None` - /// when it is no longer wanted here, or the session is draining and its successor - /// presents it instead. + /// The token to present again once a [`retry`](Self::retry) delay passes. + /// + /// # Errors + /// + /// [`Error::GoingAway`] once the session is draining, so a successor presents it instead + /// and a handle of the session's own never reads it as the peer's clean close, or + /// [`Error::Cancel`] when it is no longer wanted here. #[cfg_attr(not(feature = "async"), allow(dead_code))] - pub(crate) fn reopen(&self, id: u64) -> Option { + pub(crate) fn reopen(&self, id: u64) -> Result { self.with(|link| { if !link.active() { - return None; + return Err(Error::GoingAway); } - let slot = link.slots.get_mut(&id)?; - if slot.withdrawn || slot.ended.is_some() { - return None; + match link.slots.get_mut(&id) { + Some(slot) if !slot.withdrawn && slot.ended.is_none() => { + slot.streamed = true; + Ok(slot.token.clone()) + } + _ => Err(Error::Cancel), } - slot.streamed = true; - Some(slot.token.clone()) }) } @@ -2550,7 +2555,7 @@ mod tests { gate.poll_ready(&waiter).is_pending(), "judged before the retry answered" ); - handle.reopen(id); + handle.reopen(id).unwrap(); handle.granted(id, Grant::all()); assert!(gate.poll_ready(&waiter).is_ready()); assert!(handle.within_grant(Direction::Publish, "room")); @@ -2967,19 +2972,20 @@ mod tests { draining.ended(id, Error::Session(SessionError::Unauthorized)); assert!(Selector::Token(id).ended(&handle.state.read()).is_none()); - assert_eq!(successor.reopen(id).as_deref(), Some(b"jwt".as_slice())); + assert_eq!(successor.reopen(id).unwrap().as_ref(), b"jwt"); successor.granted(id, Grant::all()); assert_eq!(auth.grant().peek(), Some(Grant::all())); } - /// A retry that wakes after this side sent a GOAWAY opens nothing: the successor presents it. + /// A retry that wakes after this side sent a GOAWAY opens nothing: a successor presents + /// it, and on a session's own handle the token ends as going away, not as a clean close. #[test] fn a_draining_session_presents_no_retry() { let link = Link::new(true); let (goaway, _protocol) = crate::goaway::Handle::new(false); let goaway = std::sync::Arc::new(goaway); link.drain_with(goaway.clone()); - let _auth = link.present(Bytes::from_static(b"jwt"), false).unwrap(); + let auth = link.present(Bytes::from_static(b"jwt"), false).unwrap(); let id = opened(&link); link.refused(id); let busy = Error::Session(SessionError::TooManyRequests); @@ -2987,7 +2993,13 @@ mod tests { goaway.producer().send(crate::goaway::Goaway::new()).unwrap(); assert!(!link.active()); - assert_eq!(link.reopen(id), None); + let err = link.reopen(id).unwrap_err(); + assert!(matches!(err, Error::GoingAway), "{err:?}"); + link.ended(id, err); + assert!(matches!( + Selector::Token(auth.id()).ended(&link.state().read()), + Some(Error::GoingAway) + )); } /// A refusal that may pass is presented again after a doubling backoff, and settles nothing. @@ -3001,12 +3013,12 @@ mod tests { link.refused(id); assert_eq!(link.retry(id, &busy), Some(RETRY_INITIAL)); assert!(auth.poll_answered(&kio::Waiter::noop()).is_pending()); - assert_eq!(link.reopen(id).as_deref(), Some(b"jwt".as_slice())); + assert_eq!(link.reopen(id).unwrap().as_ref(), b"jwt"); link.refused(id); assert_eq!(link.retry(id, &busy), Some(RETRY_INITIAL * 2)); assert_eq!(link.retry(id, &busy), None, "only a refusal retries"); - link.reopen(id); + link.reopen(id).unwrap(); link.granted(id, Grant::all()); assert!(matches!( auth.poll_answered(&kio::Waiter::noop()), diff --git a/rs/moq-net/src/ietf/auth.rs b/rs/moq-net/src/ietf/auth.rs index 535d5891baf..4993621e77f 100644 --- a/rs/moq-net/src/ietf/auth.rs +++ b/rs/moq-net/src/ietf/auth.rs @@ -253,7 +253,6 @@ pub(super) fn peer_grant( /// says it negotiated the extension. Never returns: a session without it just fails /// the tokens as unsupported. #[cfg(feature = "async")] -#[allow(clippy::too_many_arguments)] pub(super) async fn run_present( runtime: crate::time::Deadlines, session: S, @@ -261,7 +260,6 @@ pub(super) async fn run_present( handle: Link, peer_setup: peer::PeerSetup, version: Version, - going_away: crate::goaway::GoingAway, stats: crate::stats::Session, ) -> Result<(), Error> where @@ -295,14 +293,13 @@ where version, id, }; - let going_away = going_away.clone(); let violation = violation.clone(); tasks.push(async move { - // After a GOAWAY the peer must not see new requests. AUTH_ERROR carries no code - // that may pass, so unlike moq-lite a refusal is never presented again. - let err = match going_away.is_set() { - true => Error::GoingAway, - false => present.run(token).await, + // After a GOAWAY, sent or received, the peer must not see new requests. AUTH_ERROR + // carries no code that may pass, so unlike moq-lite a refusal is never presented again. + let err = match present.handle.active() { + true => present.run(token).await, + false => Error::GoingAway, }; let err = crate::auth::protocol_error(err); if matches!(err, Error::ProtocolViolation) { diff --git a/rs/moq-net/src/ietf/session.rs b/rs/moq-net/src/ietf/session.rs index 230867db5b2..6a21eb58a12 100644 --- a/rs/moq-net/src/ietf/session.rs +++ b/rs/moq-net/src/ietf/session.rs @@ -473,7 +473,6 @@ where auth.clone(), peer_setup.clone(), version, - goaway.going_away.clone(), stats.clone(), ); // Only a side that offered MoQ Auth answers the peer's Auth requests: one that diff --git a/rs/moq-net/src/lite/session.rs b/rs/moq-net/src/lite/session.rs index 1b8df7ca58a..b545a95095c 100644 --- a/rs/moq-net/src/lite/session.rs +++ b/rs/moq-net/src/lite/session.rs @@ -699,8 +699,9 @@ impl PresentToken { return Poll::Ready(Error::Cancel); } ready!(deadline.poll(waiter)); - let Some(token) = self.handle.reopen(self.id) else { - return Poll::Ready(Error::Cancel); + let token = match self.handle.reopen(self.id) { + Ok(token) => token, + Err(err) => return Poll::Ready(err), }; self.state = PresentState::Open { token, answered: true }; } diff --git a/rs/moq-net/tests/integration/auth.rs b/rs/moq-net/tests/integration/auth.rs index c2fbf691b3f..50506eecc9e 100644 --- a/rs/moq-net/tests/integration/auth.rs +++ b/rs/moq-net/tests/integration/auth.rs @@ -72,6 +72,7 @@ cases!( nothing_outside_the_grant_reaches_the_peer, a_shared_handle_presents_its_token_on_every_session, a_handle_backs_one_active_session, + a_token_queued_before_our_goaway_rides_only_the_successor, a_broadcast_received_from_the_peer_is_not_ours_to_check, ); @@ -2022,6 +2023,50 @@ async fn a_handle_backs_one_active_session(version: &'static str) { .expect("timed out"); } +/// A token queued while the session is active, but dequeued only after this side sent a +/// GOAWAY, opens no request on the draining session: its successor presents it. +async fn a_token_queued_before_our_goaway_rides_only_the_successor(version: &'static str) { + within(async { + use futures::FutureExt; + + let handle = auth::Handle::new(); + let options = || Options { + version: Some(version), + client_publish: Some(produce_origin(2)), + server_requests: true, + client_auth: Some(handle.clone()), + ..Default::default() + }; + let mut first = connect(options()).await; + let mut first_tokens = serve(first.requests.take().unwrap(), |_| Some(Grant::all())); + let (setup, _setup) = first_tokens.next().await.expect("the setup token"); + assert!(setup.is_empty()); + + // Its first poll queues the token; the driver runs only once this side drains. + let mut adding = Box::pin(handle.add("late")); + assert!(adding.as_mut().now_or_never().is_none()); + first.client.drain().send(moq_net::goaway::Goaway::new()).unwrap(); + moq_clock::sleep(Duration::from_secs(1)).await; + assert!( + first_tokens.next().now_or_never().is_none(), + "the draining session presented a new token" + ); + + let mut second = connect(options()).await; + let mut second_tokens = serve(second.requests.take().unwrap(), |_| Some(Grant::all())); + let mut tokens = vec![ + second_tokens.next().await.expect("a token").0, + second_tokens.next().await.expect("another token").0, + ]; + tokens.sort(); + assert_eq!(tokens, [b"".to_vec(), b"late".to_vec()]); + let (_late, answer) = adding.await.expect("granted on the successor"); + assert_eq!(answer, auth::Answer::Granted); + }) + .await + .expect("timed out"); +} + /// A refusal that may pass, such as an overloaded verifier, presents the token again /// after a backoff rather than ending it. async fn a_transient_refusal_is_presented_again(version: &'static str) { diff --git a/rs/moq-tokio/src/connection.rs b/rs/moq-tokio/src/connection.rs index 86abeae4620..3bcfcd942a3 100644 --- a/rs/moq-tokio/src/connection.rs +++ b/rs/moq-tokio/src/connection.rs @@ -601,10 +601,16 @@ impl Drop for Shared { }; // No session will present the tokens again, so they end with the loop. let aborted = self.closed.lock().unwrap().clone(); - self.auth.abort(match (aborted, failed) { + let err = match (aborted, failed) { (Some(err), _) | (None, Some(Error::MoqNet(err))) => err, (None, Some(err)) if err.is_auth() => moq_net::Error::Unauthorized, - _ => moq_net::Error::Cancel, + (None, Some(err)) => moq_net::Error::Transport(err.to_string()), + (None, None) => moq_net::Error::Closed, + }; + self.auth.abort(match err { + // A token reads `Cancel` as its peer ending the grant cleanly. + moq_net::Error::Cancel => moq_net::Error::Closed, + err => err, }); } } @@ -1326,8 +1332,10 @@ impl Connection { /// The auth handle every session of this connection is built on: its tokens are /// presented on each reconnect, so a route naming one keeps it, and its grant follows - /// the live session. It ends with the connection: [`Error::MoqNet`]'s error, or - /// [`moq_net::Error::Unauthorized`] for a rejection, or [`moq_net::Error::Cancel`]. + /// the live session. It ends with the connection: the abort's or [`Error::MoqNet`]'s + /// error, [`moq_net::Error::Unauthorized`] for a rejection, [`moq_net::Error::Transport`] + /// for any other failure, or [`moq_net::Error::Closed`] for a stop. Never + /// [`moq_net::Error::Cancel`], which a token reads as its peer ending the grant cleanly. /// Aborting it ends the connection at its next reconnect, with the abort's error. pub fn auth(&self) -> moq_net::auth::Handle { self.auth.clone() @@ -2576,8 +2584,9 @@ mod tests { } drop(connection); let token = token.unwrap(); + // Not `Cancel`, which reads as the peer ending the grant cleanly. let closed = tokio::time::timeout(WAIT, token.closed()).await.unwrap(); - assert!(matches!(closed, moq_net::Error::Cancel), "{closed:?}"); + assert!(matches!(closed, moq_net::Error::Closed), "{closed:?}"); } /// Ending the connection's auth handle ends the loop at its next reconnect, rather than diff --git a/swift/Sources/Moq/Auth.swift b/swift/Sources/Moq/Auth.swift index 6bca59e7aef..67c55b1c4e9 100644 --- a/swift/Sources/Moq/Auth.swift +++ b/swift/Sources/Moq/Auth.swift @@ -21,7 +21,8 @@ public final class Auth: Sendable { /// Suspend until the grant differs from the one last reported, or held when this /// handle was made, returning it. - /// Throws once nothing will change it again, because the session ended. + /// Throws once nothing will change it again: a client's connection or a server's + /// session ended. A reconnect is not an end. public func grantChanged() async throws -> Grant? { try await ffi.grantChanged() } /// Present another token, returning once the peer accepts it. @@ -44,7 +45,8 @@ public final class AuthToken: Sendable { /// This token's own grant, or `nil` after it ended. public func grant() -> Grant? { ffi.grant() } - /// Suspend until the token ends, throwing the peer's revocation or the session's close. + /// Suspend until the token ends, throwing the peer's revocation or the end of a client's + /// connection or a server's session. A reconnect does not end it. public func closed() async throws { try await ffi.closed() } /// Withdraw the token, removing its grant from the union. From d7e075e0981d42f902d626dc779bd67ff634f21f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 23 Oct 2026 17:07:28 -0700 Subject: [PATCH 24/31] chore(dart): regenerate the FFI checksums for the auth doc changes Co-Authored-By: Claude Opus 5.5 --- dart/moq_ffi/lib/src/moq.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dart/moq_ffi/lib/src/moq.dart b/dart/moq_ffi/lib/src/moq.dart index 9f42b179cd6..f4e37e96554 100644 --- a/dart/moq_ffi/lib/src/moq.dart +++ b/dart/moq_ffi/lib/src/moq.dart @@ -19221,13 +19221,13 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqauth_grant() != 14588) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqauth_grant_changed() != 5195) { + if (uniffi_moq_ffi_checksum_method_moqauth_grant_changed() != 38858) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqauthtoken_cancel() != 59324) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqauthtoken_closed() != 31020) { + if (uniffi_moq_ffi_checksum_method_moqauthtoken_closed() != 40912) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqauthtoken_grant() != 31654) { From b7ef99a8366306a9d14d150256c4d575e3bda8fc Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 23 Oct 2026 18:21:50 -0700 Subject: [PATCH 25/31] test(net): box the lost-retry test's transport after main's erasure Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/lite/session.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rs/moq-net/src/lite/session.rs b/rs/moq-net/src/lite/session.rs index 6105338d671..18c1ab03228 100644 --- a/rs/moq-net/src/lite/session.rs +++ b/rs/moq-net/src/lite/session.rs @@ -1016,7 +1016,7 @@ mod tests { stats: Default::default(), client: true, limits: Default::default(), - session: transport, + session: crate::driver::Transport::new(transport), setup_stream: None, publish: None, subscribe: None, From f984b0fb568334830bec2c0a024a7fe8352fd854 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 23 Oct 2026 18:41:47 -0700 Subject: [PATCH 26/31] test(relay): an invalid attachment label is retried, not ended The relay refuses an attachment whose grant carries an invalid stats label as an auth server fault, which may pass, so the client now presents the token again instead of failing `add`. The test checks the retry and that the initial grant survives, then withdraws the token before a valid one. Co-Authored-By: Claude Opus 5.5 --- rs/moq-relay/src/auth/refresh.rs | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/rs/moq-relay/src/auth/refresh.rs b/rs/moq-relay/src/auth/refresh.rs index c09a90ed648..b8a432f8c3e 100644 --- a/rs/moq-relay/src/auth/refresh.rs +++ b/rs/moq-relay/src/auth/refresh.rs @@ -638,23 +638,27 @@ mod tests { async fn an_invalid_attachment_is_refused_without_losing_the_existing_grant() { let (lease, mut admissions) = admission(grant("a/**", 3000)).await; let (client, supervising) = sessions(lease).await; - let auth = client.auth(); - let (result, ()) = tokio::join!(auth.add("invalid"), async { - let mut invalid = grant("a/**", 3000); - invalid.label = Some("project/*".into()); - admissions.next().await.unwrap().grant(lease::Consumer::fixed(invalid)); - }); - assert!(result.is_err(), "the invalid label was discarded"); + let invalid = present(&client, "invalid"); + let mut label = grant("a/**", 3000); + label.label = Some("project/*".into()); + admissions.next().await.unwrap().grant(lease::Consumer::fixed(label)); + // Refused as an auth server fault, which may pass, so the client presents it again. + let retried = admissions.next().await.unwrap(); + assert_eq!(retried.request.query.as_deref(), Some("jwt=invalid")); + assert!(!invalid.is_finished(), "the invalid label was accepted or ended the token"); assert!( !supervising.is_finished(), "a refused attachment ended the initial grant" ); + // Withdrawn, the retried token leaves the queue. + invalid.abort(); + let _ = invalid.await; + drop(retried); + let auth = client.auth(); let (result, ()) = tokio::join!(auth.add("valid"), async { - admissions - .next() - .await - .unwrap() - .grant(lease::Consumer::fixed(grant("a/**", 3000))); + let admission = admissions.next().await.unwrap(); + assert_eq!(admission.request.query.as_deref(), Some("jwt=valid")); + admission.grant(lease::Consumer::fixed(grant("a/**", 3000))); }); result.unwrap(); client.abort(moq_net::Error::Cancel); From cc4c1395cb3f7ea327f1836bd98edba598190459 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 23 Oct 2026 18:43:07 -0700 Subject: [PATCH 27/31] style(relay): format the invalid attachment test Co-Authored-By: Claude Opus 5.5 --- rs/moq-relay/src/auth/refresh.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/rs/moq-relay/src/auth/refresh.rs b/rs/moq-relay/src/auth/refresh.rs index b8a432f8c3e..4a3aa4550f7 100644 --- a/rs/moq-relay/src/auth/refresh.rs +++ b/rs/moq-relay/src/auth/refresh.rs @@ -645,7 +645,10 @@ mod tests { // Refused as an auth server fault, which may pass, so the client presents it again. let retried = admissions.next().await.unwrap(); assert_eq!(retried.request.query.as_deref(), Some("jwt=invalid")); - assert!(!invalid.is_finished(), "the invalid label was accepted or ended the token"); + assert!( + !invalid.is_finished(), + "the invalid label was accepted or ended the token" + ); assert!( !supervising.is_finished(), "a refused attachment ended the initial grant" From 2f6b5dd6c8e28482bfa0865a5033c87dd2197012 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 23 Oct 2026 19:17:06 -0700 Subject: [PATCH 28/31] test(js/net): drive the ended-auth-handle reload test with a manual clock Co-Authored-By: Claude Opus 5.5 --- js/net/src/connection/reload.test.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/js/net/src/connection/reload.test.ts b/js/net/src/connection/reload.test.ts index fa080022766..0a4a6e8645a 100644 --- a/js/net/src/connection/reload.test.ts +++ b/js/net/src/connection/reload.test.ts @@ -1,5 +1,5 @@ import { expect, spyOn, test } from "bun:test"; -import { Milli } from "@moq/clock"; +import { Manual, Milli, Nano } from "@moq/clock"; import { Effect, Signal } from "@moq/signals"; import * as Announce from "../announced.ts"; import type { Producer as BroadcastProducer } from "../broadcast.ts"; @@ -10,6 +10,7 @@ import * as Lite from "../lite/index.ts"; import { createMockTransportPair } from "../mock.ts"; import { Producer as OriginProducer } from "../origin.ts"; import * as Path from "../path.ts"; +import { nextTask } from "../util/yield.ts"; import { wireOf } from "../wire.ts"; import { type AcceptProps, accept as acceptSession } from "./index.ts"; import { pumpAnnounced, Redirects, Reload, type ReloadProps } from "./reload.ts"; @@ -256,6 +257,17 @@ test("an ended auth handle gives up instead of retrying", async () => { return pair.client; }; globalThis.WebTransport = stub as unknown as typeof WebTransport; + const clock = new Manual(); + const restore = clock.install(); + // Bounded by task turns rather than wall time, advancing the clock a millisecond per turn + // so a reconnect, if the loop tried one, would be dialed well within the bound. + const until = async (done: () => boolean) => { + for (let i = 0; i < 100 && !done(); i++) { + await nextTask(); + await clock.advance(Nano.fromMilli(Milli(1))); + } + expect(done()).toBe(true); + }; // An unbounded retry window, so only the ended handle can stop the loop. const reload = new Reload({ @@ -266,17 +278,18 @@ test("an ended auth handle gives up instead of retrying", async () => { }); const warned = spyOn(console, "warn").mockImplementation(() => {}); try { - await waitUntil(() => reload.established.peek() !== undefined); + await until(() => reload.established.peek() !== undefined); const aborted = new Error("auth handle aborted"); reload.auth.abort(aborted); (await servers[0])?.abort(); - await waitUntil(() => reload.error.peek() !== undefined); + await until(() => reload.error.peek() !== undefined); expect(reload.error.peek()).toBe(aborted); expect(servers.length).toBe(1); } finally { warned.mockRestore(); reload.close(); + restore(); globalThis.WebTransport = original; } }); From a52d00bfb2cbb8c502bc0e246c7a5b7cdd47d34c Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 24 Oct 2026 12:59:38 -0700 Subject: [PATCH 29/31] test: restore manual clocks through Installed, and cover a token added while a browser dial fails Main's Manual.install returns an Installed to close. The wasm UniFFI case checks a token added mid-dial rejects once the dial is refused, rather than waiting forever on the dial's handle. Co-Authored-By: Claude Opus 5.5 --- js/net/src/auth.test.ts | 12 ++++++------ js/net/src/connection/migrate.test.ts | 4 ++-- js/net/src/connection/reload.test.ts | 4 ++-- test/wasm/uniffi/cases.ts | 17 +++++++++++++++++ 4 files changed, 27 insertions(+), 10 deletions(-) diff --git a/js/net/src/auth.test.ts b/js/net/src/auth.test.ts index 9482c1ac4a9..96ab771a01b 100644 --- a/js/net/src/auth.test.ts +++ b/js/net/src/auth.test.ts @@ -627,7 +627,7 @@ test("closing a session settles its unanswered setup token", async () => { // moq-transport's AUTH_ERROR carries no code that may pass, so only lite retries. test("lite-07 presents a transiently refused token again after a backoff", async () => { const clock = new Manual(); - const restore = clock.install(); + const installed = clock.install(); const warned = spyOn(console, "warn").mockImplementation(() => {}); const refusals = () => warned.mock.calls.filter(([line]) => line === "auth token refused").length; const { client, server } = await connect({ publish: new OriginProducer(), protocol: Lite.ALPN_07_WIP }); @@ -689,14 +689,14 @@ test("lite-07 presents a transiently refused token again after a backoff", async client.abort(); server.abort(); warned.mockRestore(); - restore(); + installed.close(); } }); // The peer replied to the first presentation, so it knows AUTH: a reset is a lost stream. test("lite-07 keeps a shared token whose retry stream is reset", async () => { const clock = new Manual(); - const restore = clock.install(); + const installed = clock.install(); const warned = spyOn(console, "warn").mockImplementation(() => {}); const logged = (line: string) => warned.mock.calls.filter(([first]) => first === line).length; const handle = new Handle(); @@ -731,7 +731,7 @@ test("lite-07 keeps a shared token whose retry stream is reset", async () => { client.abort(); server.abort(); warned.mockRestore(); - restore(); + installed.close(); } }); @@ -844,7 +844,7 @@ test("lite-07 ignores a draining session's late refusal once its successor grant test("lite-07 ignores a draining session's late refusal while its successor retries", async () => { const clock = new Manual(); - const restore = clock.install(); + const installed = clock.install(); const warned = spyOn(console, "warn").mockImplementation(() => {}); const refusals = () => warned.mock.calls.filter(([line]) => line === "auth token refused").length; const protocol = Lite.ALPN_07_WIP; @@ -911,7 +911,7 @@ test("lite-07 ignores a draining session's late refusal while its successor retr next.client.abort(); next.server.abort(); warned.mockRestore(); - restore(); + installed.close(); } }); diff --git a/js/net/src/connection/migrate.test.ts b/js/net/src/connection/migrate.test.ts index 7c2c070fca1..5c4c1a2485f 100644 --- a/js/net/src/connection/migrate.test.ts +++ b/js/net/src/connection/migrate.test.ts @@ -292,7 +292,7 @@ test("a redirect onto an already pooled key leaves both entries to their handles test("the connection's tokens ride every session across a GOAWAY", async () => { const clock = new Manual(); - const restore = clock.install(); + const installed = clock.install(); // Bounded by task turns rather than wall time, advancing the clock a millisecond per turn // so the reconnect's delay passes long before the draining session's handover. const until = async (done: () => boolean) => { @@ -364,7 +364,7 @@ test("the connection's tokens ride every session across a GOAWAY", async () => { expect(await token.closed).toBeInstanceOf(Error); } finally { reload.close(); - restore(); + installed.close(); } }); diff --git a/js/net/src/connection/reload.test.ts b/js/net/src/connection/reload.test.ts index 0376142b54b..b4055a8e007 100644 --- a/js/net/src/connection/reload.test.ts +++ b/js/net/src/connection/reload.test.ts @@ -258,7 +258,7 @@ test("an ended auth handle gives up instead of retrying", async () => { }; globalThis.WebTransport = stub as unknown as typeof WebTransport; const clock = new Manual(); - const restore = clock.install(); + const installed = clock.install(); // Bounded by task turns rather than wall time, advancing the clock a millisecond per turn // so a reconnect, if the loop tried one, would be dialed well within the bound. const until = async (done: () => boolean) => { @@ -289,7 +289,7 @@ test("an ended auth handle gives up instead of retrying", async () => { } finally { warned.mockRestore(); reload.close(); - restore(); + installed.close(); globalThis.WebTransport = original; } }); diff --git a/test/wasm/uniffi/cases.ts b/test/wasm/uniffi/cases.ts index fb08d31051b..118808fb967 100644 --- a/test/wasm/uniffi/cases.ts +++ b/test/wasm/uniffi/cases.ts @@ -321,6 +321,23 @@ const remote: Record Promise> = { return closed; }, + "a token added while dialing ends with a refused dial": async (relay) => { + const session = await dial(relay, "00".repeat(32)); + // Taken before the dial settles, so the token waits for a session that never comes. + const adding = session.auth().add("token"); + const state = await settled(session); + if (state.phase !== MoqConnectionPhase.Closed) throw new Error(`settled ${state.phase}`); + const added = await settle( + adding, + 5000, + "add", + () => "resolved", + (err: unknown) => (err as Error).message, + ); + if (added === "resolved") throw new Error("add resolved after a refused dial"); + return added; + }, + "cancelling a dial closes it": async (relay) => { const session = await dial(relay); session.cancel(0); From 6fa7c3d359026330ea0c3cadb990aa4873233cbc Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 24 Oct 2026 13:43:47 -0700 Subject: [PATCH 30/31] quest: drop the deleted issuer quest's link from net-exports Co-Authored-By: Claude Opus 5.5 --- quest/m1/api-audit/net-exports.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/quest/m1/api-audit/net-exports.md b/quest/m1/api-audit/net-exports.md index 02ca6aca806..695c612833b 100644 --- a/quest/m1/api-audit/net-exports.md +++ b/quest/m1/api-audit/net-exports.md @@ -42,7 +42,7 @@ Decided 2026-10-23 (API audit follow-up, maintainer): Kept, so the next audit skips them: `transport::memory` stays public and ungated, since an in-process transport is real API that other crates' tests use. `Route::{auth, with_auth}` stay public, since `doc/concept/standard.md` -documents them and [auth issuer](/quest/m1/auth/issuer.md) extends them +documents them and a shared `auth::Handle` keeps them across a reconnect (decided 2026-10-22, maintainer). Public API: removals and renames in moq-net and `@moq/net`. Wire: none. From 37fb3dc0cc593ba00a370c6bafca16ed7eb21f56 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 24 Oct 2026 13:46:29 -0700 Subject: [PATCH 31/31] test(relay): read how the uring relay took a token from its Answer Main's uring extensions test expected the replaced connection handle to refuse a token without MoQ Auth. The session-independent handle keeps it for the requests that name it instead, which Answer::Requests reports. Co-Authored-By: Claude Opus 5.5 --- rs/moq-relay/tests/integration/runtime_uring.rs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/rs/moq-relay/tests/integration/runtime_uring.rs b/rs/moq-relay/tests/integration/runtime_uring.rs index 98081f150a8..9ee77a18f77 100644 --- a/rs/moq-relay/tests/integration/runtime_uring.rs +++ b/rs/moq-relay/tests/integration/runtime_uring.rs @@ -1006,16 +1006,18 @@ async fn uring_workers_offer_the_configured_extensions() { assert!(admission.attached(), "an AUTH token arrived as a new session"); admission.grant(grant()); }); - token + let (_, answer) = token .expect("the token was never answered") .expect("the token was refused"); + assert_eq!(answer, moq_net::auth::Answer::Granted); } false => { - let err = presented.await.expect("the token was never answered").err(); - assert!( - matches!(err, Some(moq_tokio::Error::MoqNet(moq_net::Error::Unsupported))), - "a token without MoQ Auth: {err:?}" - ); + // Without MoQ Auth no AUTH stream carries it: only a request naming it does. + let (_, answer) = presented + .await + .expect("the token was never answered") + .expect("a moq-transport request can carry the token"); + assert_eq!(answer, moq_net::auth::Answer::Requests, "a token without MoQ Auth"); } }