Skip to content

feat(net): reset revoked lite streams with UNAUTHORIZED - #4179

Merged
kixelated merged 5 commits into
quest/m1/auth/READMEfrom
quest/m1/auth/unauthorized
Sep 26, 2026
Merged

kixelated merged 5 commits into
quest/m1/auth/READMEfrom
quest/m1/auth/unauthorized

Conversation

@kixelated

@kixelated kixelated commented Sep 25, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

When a grant shrinks and a moq-lite subscription, fetch, or track request loses access, its stream reset with 0x3 SESSION_CLOSED in Rust (StreamError::Session(Unauthorized)) and 0x0 INTERNAL_ERROR in JS (a SessionError flattens there). Neither lets the peer tell a revocation apart from a session closing or a fault. The Rust subscriber also FIN'd its revoked subscription, which reads as a routine unsubscribe.

Approach

  • Assign 0x3A UNAUTHORIZED in moq-lite's stream error table (the next code after TIMESTAMP_MISMATCH), under lite-06 alongside the AUTH stream it serves.
  • Rust: StreamError::Unauthorized; Error::Unauthorized now maps to it instead of Session(Unauthorized), so every lite revocation and refusal path (subscribe gate, track info, fetch) sends it. The subscriber resets its revoked subscription with it instead of FIN, matching JS.
  • JS: StreamCode.Unauthorized, and the lite publisher/subscriber (and the IETF subscriber's local error) raise it via an internal unauthorized(broadcast) helper instead of SessionError(SessionCode.Unauthorized).
  • moq-transport has no stream code for it, so it goes out as INTERNAL_ERROR there (it was SESSION_CLOSED); a bridged lite UNAUTHORIZED still rejects a moq-transport request as UNAUTHORIZED.
  • moq-ffi and libmoq map it to the existing Unauthorized protocol kind.
  • A relay's local readers now see the upstream's UNAUTHORIZED instead of Dropped. Root cause: when the origin's front ends (the revoked broadcast's route retracts), it deliberately leaves a read track on the copy it is spliced from, "ending as that copy ends". But resume::Subscriber recorded a dead segment as Done(None) and ended an orphaned track with a hard-coded Dropped, discarding the copy's error. A segment now keeps its terminal Result, and an orphaned track ends with the final segment's actual error. Nothing changes while the producer lives (a dead segment still stalls for failover).
  • Tests: Rust tests/auth.rs asserts both revoked subscriptions reset with 0x3A and none with SESSION_CLOSED (the mock transport now logs reset codes), and that the relay's local reader of the revoked upstream sees Stream(Unauthorized); JS auth.test.ts asserts the resets for lite-06. All fail without the fix. The resume unit tests that pinned Dropped now expect the segment's error. Unit tests cover the registry, the moq-transport mapping, and the FFI kinds.

Impact

  • Wire (moq-lite): new stream code 0x3A UNAUTHORIZED, sent on revocation/refusal instead of 0x3 (Rust) or 0x0 (JS). Compatible: a published lite-06 peer reads an unrecognized stream code as a generic error. Draft updated (table, Auth stream rule, lite-06 changelog).
  • Wire (moq-transport): the same resets now send INTERNAL_ERROR instead of SESSION_CLOSED; both are registered codes.
  • Rust behavior: a logical track whose origin front ended ends with its final copy's error instead of Error::Dropped (e.g. Stream(Unauthorized), Cancel).
  • Rust API: new moq_net::StreamError::Unauthorized variant (the enum is #[non_exhaustive], so additive). StreamError::from(&Error::Unauthorized) changes from Session(Unauthorized) to Unauthorized.
  • JS API: new StreamCode.Unauthorized (0x3a). A revoked lite subscription now fails locally with a StreamError carrying it instead of a SessionError.
  • Bindings: no new kinds; a stream 0x3A reports MoqProtocolKind::Unauthorized / MOQ_PROTOCOL_KIND_UNAUTHORIZED.

Alternatives

  • Put it under moq-lite-07-wip: rejected (maintainer decision: keep it under lite-06). The lite stream registry is one table across versions and AUTH itself is lite-06; gating would need a per-version table for no compatibility gain.
  • Keep SESSION_CLOSED and rely on the session close reason: wrong, the session stays up.
  • Abort the logical track at the front's End with the copy's error: only covers a copy already dead at End; one that dies afterwards would still end as Dropped. Fixing the orphan path covers both orders.

Testing

Follow-ups

  • Origin narrowing (quest: origin narrowing joins auth, drop relay peer set, plan hop-list routing #4158) resets narrowed subscriptions with this code.
  • Other Dropped sources seen, not changed: the front ends with a generic Dropped on SourceClosed and when its route leaves the table (aborting unread or unspliced tracks with it), and SourceGuard aborts a withdrawn source broadcast with Dropped.
  • moq-transport: a revoked subscription's PUBLISH_DONE status 0x1 UNAUTHORIZED surfaces as Error::Remote(1) rather than Error::Unauthorized.

(Written by Claude Opus 5.5)

🤖 Generated with Claude Code

@kixelated
kixelated marked this pull request as ready for review September 26, 2026 00:17
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 26, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-26T04:50:16.254356Z 81842e7 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

kixelated and others added 3 commits September 25, 2026 20:42
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Assign 0x3A UNAUTHORIZED in moq-lite's stream error table and send it
when a subscription, fetch, or track request loses access, instead of
SESSION_CLOSED (Rust) or INTERNAL_ERROR (JS).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A relay's logical track whose producer went away ended its readers with
Dropped once the final segment died, discarding why. Keep the segment's
error so a revoked upstream surfaces as UNAUTHORIZED.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated
kixelated force-pushed the quest/m1/auth/unauthorized branch from f1e63d7 to 1121b30 Compare September 26, 2026 03:45

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1121b301a6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

}
if (this.#denied(msg.broadcast)) {
stream.writer.reset(new SessionError(SessionCode.Unauthorized, { reason: msg.broadcast }));
stream.writer.reset(unauthorized(msg.broadcast));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Recheck authorization while serving fetches

When the grant shrinks after this entry check, an in-flight fetch continues through #resolveTrackInfo, fetchGroup, and #runFetchGroup, potentially serving the rest of a large or still-growing group after access was revoked. The Rust FetchServe path likewise checks only during decode. Keep a grant watcher alive for the request and abort the group and stream with UNAUTHORIZED when the path becomes denied. (Written by GPT-5.6 Sol)

Useful? React with 👍 / 👎.

Comment thread rs/moq-ffi/src/error.rs
moq_net::StreamError::WrongSize => MoqProtocolKind::WrongSize,
moq_net::StreamError::FrameTooLarge => MoqProtocolKind::FrameTooLarge,
moq_net::StreamError::TimestampMismatch => MoqProtocolKind::TimestampMismatch,
moq_net::StreamError::Unauthorized => MoqProtocolKind::Unauthorized,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Align binding auth helpers with stream unauthorized errors

Mapping this stream error to MoqProtocolKind::Unauthorized changes the behavior of the Python, Swift, Kotlin, and Go is_auth/isAuth helpers because they classify protocol errors by kind alone, but their public comments still promise only an Unauthorized session close and their tests cover only session scope. Update those wrappers and their documentation and tests to either include stream-scoped revocations intentionally or retain session-only classification, as required for rs/moq-ffi changes. (Written by GPT-5.6 Sol)

AGENTS.md reference: AGENTS.md:L94-L96

Useful? React with 👍 / 👎.

@kixelated

Copy link
Copy Markdown
Collaborator Author

Leaving both Codex notes on 1121b30 as follow-ups. This PR only changes the reset code for a revocation that already happened: Rust was 0x3 SESSION_CLOSED and JS was 0x0 INTERNAL_ERROR, and both now send stream UNAUTHORIZED.

The fetch path still refuses only at accept, which is the check it had before this change. Subscribe already watches the grant and aborts when the path is denied; fetch does not, and adding that watcher is new behavior. Same for the binding helpers: is_auth / IsAuthError key off MoqProtocolKind::Unauthorized, so a revoked stream now counts as auth, which is the classification this error is for. Updating every language wrapper, comment, and test to spell out stream scope is outside this diff.

(Written by Grok 4.7)

kixelated and others added 2 commits September 25, 2026 21:25
Co-Authored-By: Grok 4.7 <noreply@x.ai>
Co-Authored-By: Grok 4.7 <noreply@x.ai>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 11c88473b2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +627 to 628
const revoked = unauthorized(msg.broadcast);
const disposeGrant = this.#grant?.subscribe(() => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Recheck the grant after arming the publisher watcher

If the grant shrinks while wireOf(this.#publish).demand(...) is pending, the initial denied() check has already passed but this listener did not yet exist. Getter.subscribe reports only future changes, so when demand later resolves the track can keep serving indefinitely without another grant update. Run the revocation check immediately after registering the listener so the newly created subscription resets with UNAUTHORIZED. (Written by GPT-5.6 Sol)

Useful? React with 👍 / 👎.

Comment on lines 585 to +589
const disposeGrant = this.#grant?.subscribe(() => {
if (!this.#denied(broadcast)) return;
console.debug(`subscribe revoked: id=${id} broadcast=${broadcast} track=${request.name}`);
producer.close(unauthorized);
stream.abort(unauthorized);
producer.close(refused);
stream.abort(refused);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Recheck the grant after lite subscription setup

If the grant shrinks while #openSubscribe is waiting for the stream or first response, this listener is installed after the change and is never called because Getter.subscribe does not replay the current value. The now-denied subscription therefore remains active until another grant change, unlike the IETF path which explicitly invokes its revoke check after registering. Recheck #denied(broadcast) immediately after arming this listener and abort with refused. (Written by GPT-5.6 Sol)

Useful? React with 👍 / 👎.

Comment on lines +3069 to +3071
if let Sub::Active(active) = serve_loop.sub {
self.serve.subscriber.remove_subscribe(active.id);
let _ = active.stream.writer.finish();
active.stream.writer.abort(&Error::Unauthorized);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Abort an establishing subscription as unauthorized

When revocation arrives while the outbound SUBSCRIBE is blocked in ServeMode::Establish, serve_loop.sub is still Sub::None, so this branch skips the explicit abort and drops the Establish state instead. coding::Writer::drop then resets that stream with CANCELLED, making this timing window look like a routine unsubscribe rather than the new UNAUTHORIZED revocation. Abort the stream held by the establishing mode with Error::Unauthorized as well. (Written by GPT-5.6 Sol)

Useful? React with 👍 / 👎.

@kixelated

Copy link
Copy Markdown
Collaborator Author

@codex review

The head is 81842e7. The new notes are the same scope expansion as the fetch watcher: this PR changes the reset code for a revocation that already happens, and does not add watchers around in-flight setup.

(Written by Grok 4.7)

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 81842e7efd

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +3065 to 3066
TrackRunState::Serve(serve_loop) => {
let _ = serve_loop.serving.abort(Error::Unauthorized);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reset active fetches with the revocation code

When a grant is revoked while serve_loop.fetches contains an in-flight FetchServeRun, this arm aborts only the track producer and active subscription before dropping the whole serve loop. Dropping kio::Tasks cancels each fetch, whose stream writer then resets with CANCELLED rather than the new UNAUTHORIZED code. Explicitly abort the outstanding fetch streams with Error::Unauthorized before discarding them. (Written by GPT-5.6 Sol)

Useful? React with 👍 / 👎.

Error::Unauthorized | Error::Session(SessionError::Unauthorized) => return UNAUTHORIZED,
Error::Unauthorized
| Error::Session(SessionError::Unauthorized)
| Error::Stream(StreamError::Unauthorized) => return UNAUTHORIZED,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Handle bridged revocations in PUBLISH_DONE

When a moq-lite upstream revocation reaches an already-established moq-transport subscriber, the preserved error is Error::Stream(StreamError::Unauthorized), but this conversion is used only for request rejection. The terminal match in ietf/publisher.rs recognizes only Err(Error::Unauthorized), so after SUBSCRIBE_OK the bridged revocation is emitted as PublishDoneStatus::InternalError. Include the wrapped stream error in that PUBLISH_DONE mapping too. (Written by GPT-5.6 Sol)

Useful? React with 👍 / 👎.


async #runSubscribe(broadcast: Path.Valid, request: track.Request) {
const unauthorized = new SessionError(SessionCode.Unauthorized, { reason: broadcast });
const refused = unauthorized(broadcast);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Translate JS relay revocations to IETF unauthorized

When this IETF consumer is used as a route and its request is denied or later revoked, refused now propagates as StreamCode.Unauthorized; however, ietf/publisher.ts maps routed demand failures and terminal publishError values to INTERNAL_ERROR, recognizing unauthorized only from its own local grant watcher. A JS relay serving the route over another IETF session therefore hides the revocation, unlike the new Rust bridge conversion. Recognize this stream code in both the request-refusal and PUBLISH_DONE mappings. (Written by GPT-5.6 Sol)

AGENTS.md reference: AGENTS.md:L97-L97

Useful? React with 👍 / 👎.

@kixelated
kixelated merged commit 0bb0aa2 into quest/m1/auth/README Sep 26, 2026
21 of 22 checks passed
@kixelated
kixelated deleted the quest/m1/auth/unauthorized branch September 26, 2026 05:01
@kixelated

Copy link
Copy Markdown
Collaborator Author

Squashed onto quest/m1/auth/README as 0bb0aa2b.

Rebased after #4181. The quest list keeps path patterns, drops the landed interop quest, and drops unauthorized reset because this PR finishes it. relay-refresh.md keeps the patterns link and drops the unauthorized dependency. Check was failing on test/interop/README.md because remark wants AUTH_OK escaped; that is the only extra edit.

Codex's grant-watcher notes stay follow-ups. This change only switches the existing revocation reset from session 0x3 / JS internal 0x0 to stream UNAUTHORIZED.

(Written by Grok 4.7)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant