Skip to content

fix: close the session on the draft-20 protocol violations from the compliance backlog - #111

Merged
floatdrop merged 9 commits into
draft-20from
draft20-backlog-violations
Sep 26, 2026
Merged

floatdrop merged 9 commits into
draft-20from
draft20-backlog-violations

Conversation

@floatdrop

@floatdrop floatdrop commented Sep 26, 2026 •

Copy link
Copy Markdown
Owner

Works through the "protocol violations that MUST close the session" part of the draft-20 compliance backlog in STATUS.md. There are eight commits, and each one:

  • has a regression test that failed before the fix, either at HEAD or against a mutant where the new API can't compile at HEAD;
  • passes -race and golangci-lint;
  • went through moqt-reviewer.

Commits

  • Session-fatal parameter values (§5.1.2, §10.2.8, §10.2.18, §1.4.3). CheckPeerParams now validates the values of GROUP_ORDER, FORWARD, INCLUDE_PROPERTIES, LOCATION_FILTER (including end-group overflow) and FILL_PARAMETERS:
    • a value that doesn't parse closes with KEY_VALUE_FORMATTING_ERROR;
    • any other violation closes with PROTOCOL_VIOLATION;
    • the relay's own copies of these checks are gone.
  • Session-fatal Track Property values (§12.5, §12.6). DEFAULT_PUBLISHER_GROUP_ORDER outside {1, 2}, or DYNAMIC_GROUPS above 1, closes the session. This includes values inside Immutable Properties, and applies on SUBSCRIBE_OK, FETCH_OK, TRACK_STATUS_OK, SUBSCRIBE_TRACKS_OK and PUBLISH.
  • Duplicate Track Alias (§11.1). RegisterInboundTrack closes the session with DUPLICATE_TRACK_ALIAS. The relay no longer answers REQUEST_ERROR MALFORMED_TRACK.
  • Alias release (§5.1, §11.1). A Subscription or IncomingPublication releases its alias once the subscription is Terminated, so a later reuse is legal:
    • it releases on Close, on the publisher's FIN (read through the handle or by its broker), or when Serve cancels;
    • after the publisher's FIN, Close sends a FIN rather than a reset.
  • GOAWAY on a request stream (§10.4). A second GOAWAY, or one carrying a URI received by a server, closes the session. This applies in RequestBroker.Serve and in the relay's request readers.
  • REQUEST_ERROR Redirect (§10.6.1). The Redirect is enforced and exposed:
    • a Connect URI received by a server, or a Track Name on a namespace-scoped request, closes the session;
    • RequestRejectedError.Redirect carries the redirect, and Request.Reject can now send one.
  • First response to SUBSCRIBE_NAMESPACE / SUBSCRIBE_TRACKS (§10.19, §10.20). A first response other than REQUEST_OK or REQUEST_ERROR closes the session.
  • Second SUBSCRIBE_OK / PUBLISH_OK (§5.1, following the SHOULD). This is scoped to streams where this side sent the request. PUBLISH_OK is a REQUEST_OK on the wire, so it only counts before any REQUEST_UPDATE has been sent.
  • Namespace-subscription follow-ups (§10.19, §10.9, §10.10). NamespaceSubscription and TrackSubscription get typed brokers:
    • a NAMESPACE_DONE with no NAMESPACE before it closes the session, and the check follows a TRACK_NAMESPACE_PREFIX update from the REQUEST_OK that accepts it (§10.9.2);
    • a PUBLISH_STATE_NOTIFY or REQUEST_UPDATE from the publisher closes the session, in the broker and in ReadPublishSkipped;
    • ReadPublishSkipped skips a single GOAWAY instead of failing on it.

API changes

  • RequestRejectedError.Redirect is a new field. Request.Reject now accepts code REDIRECT when a Redirect is given.
  • session.RequestGoaways is a new type, for callers that read a request stream themselves.
  • registry.NewUpstreamSub now takes the broker. The relay's upstream SUBSCRIBE uses the Subscription's own broker.
  • RegisterInboundTrack closes the session on a duplicate, so callers no longer have to.
  • Subscription and IncomingPublication release their Track Alias on termination, so callers no longer unregister it.
  • NamespaceSubscription and TrackSubscription embed the request handle, so they gain Broker() and Update(). Update goes through the broker, so the broker's Serve must be running for it to return.

Assumptions to review

  • GOAWAY sent as the first response to SUBSCRIBE_NAMESPACE or SUBSCRIBE_TRACKS closes the session. §10.4 says "A GOAWAY MAY also be sent on a request stream", but §10.19 and §10.20 say "any message other than" REQUEST_OK/REQUEST_ERROR MUST close. We take the literal MUST.
  • SUBSCRIBE_TRACKS_OK Track Property values are checked. §10.5 doesn't say whether that message carries Track Properties.
  • A second PUBLISH that reuses a pending PUBLISH's alias closes the session. The first PUBLISH registers its alias before REQUEST_OK so that early objects route. That is slightly stricter than §11.1's "Established".
  • ReadPublishSkipped skips a single GOAWAY instead of returning it. A caller that wants to re-issue the request at the GOAWAY's URI (§10.4 SHOULD) should read with the broker, which hands the GOAWAY to its callback.
  • NAMESPACE_DONE ordering stops being checked after any Update gives up. The dropped waiter makes later answers pair unreliably (the rule from 3341989), so where a prefix switch lands is no longer known.
  • Update answers get only the Connect URI check. An answer to a REQUEST_UPDATE doesn't get the Track Name rule, because its reader doesn't know the request type. An update handler's REDIRECT is sent as INTERNAL_ERROR, since §10.6.2 doesn't list REQUEST_UPDATE.

Benchmarks

BenchmarkControlRoundTrip goes from 46 to 47 allocs/op. Its Close now releases the alias, so each iteration re-registers it and allocates the awaitInboundTrack wake channel. Everything else matches the draft-20 base.

Still in the backlog

These are listed in STATUS.md:

  • rejecting FETCH flags ≥ 128 early;
  • the publication/defaults batch.

Test plan

  • go test ./...
  • go test -race ./pkg/moqt/session/... ./pkg/relay/...
  • golangci-lint run ./...
  • make bench-quick allocs/op compared against the draft-20 base
  • CI

🤖 Generated with Claude Code

floatdrop and others added 9 commits September 26, 2026 20:00
…parameter value (§5.1.2, §10.2.8, §10.2.18, §1.4.3)

Values the draft makes session-fatal were checked only on some paths:
GROUP_ORDER outside {1, 2} (§10.2.8) on SUBSCRIBE and SUBSCRIBE_TRACKS
but not on PUBLISH, FETCH or inside FILL_PARAMETERS; FORWARD above 1
(§10.2.18) not in PUBLISH_STATE_NOTIFY or AcceptPublish; and a
LOCATION_FILTER whose StartGroup + EndGroupDelta overflows (§5.1.2:
"MUST close the session with a PROTOCOL_VIOLATION") answered per request
with MALFORMED_TRACK, INVALID_FILTER or a fill reset, a documented
deviation.

Parameters.CheckScope, which Session.CheckPeerParams runs on every
receive path (openers, responses, REQUEST_UPDATE, PUBLISH_STATE_NOTIFY),
now also validates, in the list and inside its FILL_PARAMETERS:
GROUP_ORDER, FORWARD, INCLUDE_PROPERTIES and LOCATION_FILTER. An end
Group overflow is PROTOCOL_VIOLATION. A LOCATION_FILTER or a
FILL_PARAMETERS nested list that does not match its serialization is
KEY_VALUE_FORMATTING_ERROR (§1.4.3, new message.ErrValueFormatting); an
unknown parameter or a Type past 2^64-1 inside FILL_PARAMETERS stays
PROTOCOL_VIOLATION (§10.2). Following the MUST over the old deviation was
the maintainer's choice.

The relay's per-path checks (checkForwardParam, checkGroupOrderParam, the
deviation branch) and AcceptSubscribe's FORWARD check are gone as
unreachable. The only per-request parameter error the relay has left is a
Range Filter's, INVALID_FILTER (§5.1.4, §10.2.12), which also replaces
the FETCH-only MALFORMED_TRACK there. The "Out-of-range GROUP_ORDER on
FETCH" Limitation is resolved.

The new session tests assert each close code, and the relay test covers
PUBLISH, FETCH, SUBSCRIBE, FILL_PARAMETERS and a REQUEST_UPDATE; both
fail on the unpatched code. Four relay tests that pinned the deviation now
trigger their per-request failure with an invalid Range Filter.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ty value (§12.5, §12.6)

"If an endpoint receives a value outside this range, it MUST close the
session with a PROTOCOL_VIOLATION": DEFAULT_PUBLISHER_GROUP_ORDER outside
{1, 2} (§12.5) and DYNAMIC_GROUPS above 1 (§12.6). The session checks
on receipt of SUBSCRIBE_OK, FETCH_OK, TRACK_STATUS_OK and
SUBSCRIBE_TRACKS_OK, and on a PUBLISH in AcceptRequest, before the
application can reject it. Values inside Immutable Properties are
searched too (§12.7), and a mutable value is still checked when the
Immutable Properties next to it do not parse.

The relay registry no longer carries its own out-of-range path: an
out-of-range DYNAMIC_GROUPS used to only decline a NEW_GROUP_REQUEST.
The session now guarantees the range.

Assumption: §10.5 does not say whether SUBSCRIBE_TRACKS_OK carries Track
Properties. Since §12.5/§12.6 apply to any received value, they are
checked there too. If the draft means otherwise, only that check goes.

Verified red first: all 30 session subtests (6 paths x 5 values), both
relay PUBLISH cases, and the registry case where a mutable Ascending
wins over an immutable Descending (§12.7).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
"If a subscriber receives a PUBLISH or SUBSCRIBE_OK that uses the same
Track Alias as a different Track with an Established subscription, it
MUST close the session with error DUPLICATE_TRACK_ALIAS." Until now
RegisterInboundTrack only returned *ErrDuplicateTrackAlias and left
the close to its callers. Session.Subscribe and AcceptPublish returned
the error, and the relay answered the PUBLISH with REQUEST_ERROR
MALFORMED_TRACK. RegisterInboundTrack now closes the session itself.

A PUBLISH registers its alias before REQUEST_OK, while it is still
Pending (Publisher), so objects that arrive before PUBLISH_OK route
(§10.11). A second PUBLISH that reuses that alias for another Track
therefore closes the session too. The publisher has broken §11.1's
"MUST NOT be used ... simultaneously" either way.

Releasing an alias when a session-layer Subscription or
IncomingPublication ends is a separate change; it stays in the
backlog.

Verified red first: TestRegisterInboundTrackAlias, the four
SUBSCRIBE_OK/PUBLISH orderings in TestDuplicateTrackAliasCloses, and
TestPublish_DuplicateAliasClosesSession.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…rminated (§5.1, §11.1)

§11.1 closes the session only when a Track Alias is reused for "a different
Track with an Established subscription". A session-layer Subscription or
IncomingPublication never released its alias, so after its subscription
ended, a publisher's legitimate reuse closed the session with
DUPLICATE_TRACK_ALIAS.

A subscription is Terminated by the subscriber's STOP_SENDING or by the
publisher's "PUBLISH_DONE and closing the stream" (§5.1). The handle
releases its alias once, on the first of these:
  - its own Close or its broker's Close;
  - the publisher's FIN, read through the handle (it now overrides Read)
    or by its broker's Serve;
  - Serve cancelling the stream.
An onMsg stop does not release: the subscription may still be live. Once
the publisher's FIN has been read, Close FINs rather than resets (§3.3.2).
The release is once per handle, because subscriptions to one Track may
share an alias (§5.1) and each holds its own registration.

The handle keeps the alias and an atomic flag, and the broker points back
at the handle, so the change adds no closures. BenchmarkControlRoundTrip
goes from 46 to 47 allocs/op: its Close now releases the alias, so each
iteration registers it anew, which allocates the channel that wakes
awaitInboundTrack.

AcceptPublish also releases the alias when its REQUEST_OK cannot be
written: that PUBLISH never became Established.

The relay's own SUBSCRIBE now reads through the Subscription's broker:
registry.NewUpstreamSub takes the broker, and the relay's manual
UnregisterInboundTrackAlias on that path is gone. The accepted-PUBLISH
path still registers and releases by hand.

Verified red first:
  - TestAliasReleasedWhenTerminated (5 terminations x 2 paths),
    TestCloseAfterPublisherFinFINs and
    TestAcceptPublishWriteFailureReleasesAlias at HEAD;
  - TestAliasReleasedOncePerSubscription against a release that is not
    once-only (no CompareAndSwap);
  - the "read" and Close-FIN cases against mutants without the Read
    override and without the broker's peerFinished;
  - TestSubscribe_UpstreamAliasReusableAfterTeardown with the broker
    release removed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…est stream (§10.4)

"The endpoint MUST close the session with a PROTOCOL_VIOLATION if it
receives more than one GOAWAY on the control stream or on a single
request stream", and "If a server receives a GOAWAY with a non-zero New
Session URI Length it MUST close the session with a PROTOCOL_VIOLATION".
The control stream already enforced both; request streams let any
number of GOAWAYs through to the reader.

session.RequestGoaways is a zero-value checker for one request stream.
Its URI rule is the one the control stream uses (checkGoawayURI).
RequestBroker.Serve and the relay's readRequestStream (the downstream
SUBSCRIBE, FETCH and namespace streams) keep one per stream, on the
stack, so the readers allocate nothing more. A single GOAWAY still
reaches the reader. Migrating the request (a SHOULD) is not done.

Still open, and recorded in STATUS.md: a GOAWAY that arrives before a
request's initial response, or on a SUBSCRIBE_TRACKS stream read with
ReadPublishSkipped, is an error rather than being checked.

Serve's REQUEST_UPDATE handling moved into receiveUpdate, unchanged, to
keep Serve under the gocyclo limit.

The relay test asserts only that the session closes: the in-process
pipe does not carry the close code to the peer. The session tests pin
PROTOCOL_VIOLATION.

Verified red first: TestRequestStreamGoawayViolationCloses (a second
GOAWAY to the client, a second to the server, a URI to the server) and
the two violating TestRelay_RequestStreamGoaway cases.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A Redirect was dropped after parsing. The session did not close on the
two cases §10.6.1 makes fatal, and the application could not follow
one:
  - "If a server receives a Redirect with a non-zero Connect URI Length
    it MUST close the session with a PROTOCOL_VIOLATION";
  - a non-empty Track Name "in a Redirect for a namespace-scoped request"
    (SUBSCRIBE_NAMESPACE, PUBLISH_NAMESPACE, SUBSCRIBE_TRACKS) MUST
    close the session with a PROTOCOL_VIOLATION.

RequestRejectedError now carries the Redirect, and every place a
REQUEST_ERROR is read enforces both rules:
  - awaitRequestResponse and AwaitPublishOK;
  - an Update's answer, read directly or routed by the broker;
  - an unsolicited REQUEST_ERROR reaching Serve's callback.
An answer to a REQUEST_UPDATE gets only the Connect URI rule, because
its reader does not know the request.

Request.Reject used to refuse the REDIRECT code outright. It now sends
one with its Redirect. It still refuses a code/Redirect mismatch
(§10.6.2), and anything the peer would have to close for. An
UpdateHandler's REDIRECT is sent as INTERNAL_ERROR, since §10.6.2 does
not list REQUEST_UPDATE among the requests REDIRECT answers. Before,
that REDIRECT went out without its block and the peer closed the
session on the malformed message.

The relay still turns an upstream REDIRECT into INTERNAL_ERROR. Its
readRequestStream does not check a REQUEST_ERROR arriving after the
response; that gap is recorded in STATUS.md.

Verified red first:
  - TestReceivedRedirectViolationCloses (5 paths),
    TestReceivedRedirectViolationClosesOnUpdate (3 modes) and
    TestUpdateHandlerRedirectSentAsInternalError at HEAD;
  - TestRedirectFollowable and TestRejectRefusesSessionFatalRedirect need
    the new field, so they were checked against mutants instead: without
    Reject's sender guard, and without Serve's pre-route check (which
    fails exactly the unsolicited case).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…or SUBSCRIBE_TRACKS (§10.19, §10.20)

"If the subscriber receives any message other than a REQUEST_OK or a
REQUEST_ERROR as the first message on the response half of the stream,
then it MUST close the session with a PROTOCOL_VIOLATION." Before, the
request just failed. It still does, and the session now closes too.

Other requests keep only the failed request. §5.1 and §6.2 put their
first-response MUST on the sender and give the receiver no MUST to close.
TestOtherFirstResponseKeepsSession pins that carve-out for SUBSCRIBE and
PUBLISH_NAMESPACE: it fails if every request type closes.

Assumption: a GOAWAY sent as the first message also closes the session.
§10.4 says "A GOAWAY MAY also be sent on a request stream", but
§10.19/§10.20 say "any message other than" REQUEST_OK/REQUEST_ERROR. We
take the literal MUST, so a peer that migrates a SUBSCRIBE_NAMESPACE or
SUBSCRIBE_TRACKS with a GOAWAY before answering it is disconnected. If
the draft means otherwise, the GOAWAY case moves to the §10.4 check.

Verified red first: all six TestNamespaceScopedFirstResponseCloses cases
(two requests, each answered with NAMESPACE, GOAWAY or SUBSCRIBE_OK).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…LISH_OK (§5.1)

"A publisher MUST send exactly one SUBSCRIBE_OK or REQUEST_ERROR in
response to a SUBSCRIBE. A subscriber MUST send exactly one PUBLISH_OK
or REQUEST_ERROR in response to a PUBLISH. The peer SHOULD close the
session with a protocol error if it receives more than one." We follow
the SHOULD.

The check applies only where this side sent the request, because only
there has the peer already answered.
  - Session: RequestBroker.Serve on a Subscription's broker, or on the
    broker of a Publication from Session.Publish. A SUBSCRIBE_OK on a
    Subscription closes. A REQUEST_OK or REQUEST_ERROR closes if no
    REQUEST_UPDATE has been sent. PUBLISH_OK is a REQUEST_OK on the wire,
    so it can't be told apart from an update's answer; and §10.9's
    coalescing makes counting unsafe once an update is out. After an
    Update, including one that gave up, such a response still reaches
    the callback.
  - Relay: readSubscribeUpdates on a forwarded PUBLISH, where the relay
    sends no REQUEST_UPDATE, closes on a REQUEST_OK or REQUEST_ERROR.
  - Streams this side answered keep HEAD's behaviour, and so does a
    plain NewRequestBroker. On those the peer is the requester, so none
    of these is a second response.

Verified red first:
  - at HEAD: TestSecondResponseCloses (a second SUBSCRIBE_OK, and a
    REQUEST_ERROR after SUBSCRIBE_OK or PUBLISH_OK, a second PUBLISH_OK)
    and TestRelay_SecondResponseCloses (REQUEST_OK and REQUEST_ERROR);
  - against mutants: TestLateUpdateAnswerKeepsSession (never set
    updated), TestResponderStreamResponseKeepsSession (every broker
    checked) and TestSubscribeOKOnPublishKeepsSession (SUBSCRIBE_OK
    checked on a PUBLISH).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… brokers (§10.19, §10.9, §10.10)

Apps read SUBSCRIBE_NAMESPACE streams with raw message.Parse (the Example
did too), so the session could enforce none of these MUSTs:
  - "If a subscriber receives a NAMESPACE_DONE before the corresponding
    NAMESPACE, it MUST close the session with a 'PROTOCOL_VIOLATION'"
    (§10.19);
  - a PUBLISH_STATE_NOTIFY for anything but a subscription (§10.10);
  - a REQUEST_UPDATE from other than the request's sender (§10.9).
ReadPublishSkipped reported such messages as ordinary errors, and treated
a legal GOAWAY as one too.

NamespaceSubscription and TrackSubscription now embed requestHandle,
like Subscription, and gain Broker() and Update(). Close is unchanged.

Their brokers:
  - refuse PUBLISH_STATE_NOTIFY and REQUEST_UPDATE from the publisher;
  - on a SUBSCRIBE_NAMESPACE, track the full namespaces NAMESPACE
    announced, across Serve calls, and close on a NAMESPACE_DONE for
    one that is not announced.

Suffixes resolve against the prefix in force, which switches at the
REQUEST_OK that accepts a TRACK_NAMESPACE_PREFIX update (§10.9.2). An
Update that gives up drops its waiter (the rule from 3341989), so later
answers no longer pair reliably. After that the check stops rather than
close on a conforming peer.

Update on both handles always goes through the broker, since reading
the answer directly would take a NAMESPACE for it. The broker's Serve
must be running for Update to return.

GOAWAY state now lives on the handle, shared by ReadPublishSkipped and
the broker. Serve's read-error handling moved into readFailed to keep it
under the gocyclo limit, and the Example reads through the broker.

Assumption, as chosen: ReadPublishSkipped skips a single GOAWAY instead
of returning it. A caller that would re-issue the request at its New
Session URI (§10.4 SHOULD) reads with the broker, whose Serve hands the
GOAWAY over.

Verified red first:
  - at HEAD: the ReadPublishSkipped cases of
    TestTrackSubscriptionPeerMessagesClose and TestReadPublishSkippedGoaway;
  - on the working tree before its fix:
    TestNamespaceSubscriptionLostUpdateShiftsPairing;
  - against mutants, for the broker cases that need the new API:
    no namespace tracking, a broker that allows publisher messages,
    tracking reset per Serve call, no prefix switch, and a lost prefix
    update that keeps checking.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@floatdrop
floatdrop merged commit 0c5a040 into draft-20 Sep 26, 2026
11 checks passed
@floatdrop
floatdrop deleted the draft20-backlog-violations branch September 26, 2026 17:12
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