From 7ec6c2a28e7e9dad1e2ae51da02dcbb597d15b2b Mon Sep 17 00:00:00 2001 From: Vsevolod Strukchinsky Date: Sat, 26 Sep 2026 09:50:31 +0500 Subject: [PATCH 01/12] docs(message,wire,uri): trim comments to contract, citation and why MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shorten the comments added by the draft-20 compliance work: drop long spec quotations, repeated quotes and restatements of the code, keeping doc contracts, one-line § citations, and the marked interpretations (Range Filter scope, repeated Immutable Properties, filter replacement by type). Comment-only; no code change. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/moqt/message/datastream.go | 7 ++-- pkg/moqt/message/fetch_object.go | 6 ++-- pkg/moqt/message/fill.go | 3 +- pkg/moqt/message/object_properties.go | 27 +++++--------- pkg/moqt/message/param_scope.go | 52 ++++++++++----------------- pkg/moqt/message/params.go | 16 +++------ pkg/moqt/message/properties.go | 26 +++++--------- pkg/moqt/message/rangefilter.go | 31 +++++++--------- pkg/moqt/message/subgroup_object.go | 20 ++++------- pkg/moqt/message/token.go | 4 +-- pkg/moqt/message/types.go | 12 +++---- pkg/moqt/uri/syntax.go | 16 ++++----- pkg/moqt/wire/kv.go | 6 ++-- pkg/moqt/wire/wire.go | 9 ++--- 14 files changed, 81 insertions(+), 154 deletions(-) diff --git a/pkg/moqt/message/datastream.go b/pkg/moqt/message/datastream.go index 3295f437..1d855ab3 100644 --- a/pkg/moqt/message/datastream.go +++ b/pkg/moqt/message/datastream.go @@ -9,8 +9,7 @@ import ( // UnknownDataStreamTypeError is returned when the leading Type of an inbound // data uni-stream is not one of the recognized data-stream types. It is -// session-fatal (§3.4 "An endpoint that receives an unknown stream type MUST -// close the session"): session.AcceptDataStream closes the session with +// session-fatal (§3.4): session.AcceptDataStream closes the session with // PROTOCOL_VIOLATION before returning it. type UnknownDataStreamTypeError struct { Type uint64 @@ -23,9 +22,7 @@ func (e *UnknownDataStreamTypeError) Error() string { // ReservedSubgroupIDModeError is returned when the leading Type of an inbound // data uni-stream matches the SUBGROUP_HEADER pattern (bit 4 set, bit 7 clear) // but carries the reserved SUBGROUP_ID_MODE value 0b11 in bits 1-2. Per -// §11.4.2, this MUST be treated as a session-level PROTOCOL_VIOLATION. (A truly -// unknown stream type is session-fatal too, §3.4; the distinct type only -// names the cause.) +// §11.4.2, this MUST be treated as a session-level PROTOCOL_VIOLATION. type ReservedSubgroupIDModeError struct { Type uint64 } diff --git a/pkg/moqt/message/fetch_object.go b/pkg/moqt/message/fetch_object.go index 9fd862af..f8c73329 100644 --- a/pkg/moqt/message/fetch_object.go +++ b/pkg/moqt/message/fetch_object.go @@ -21,9 +21,9 @@ type FetchObject struct { // Only present on the wire when the mode is FetchSubgroupIDExplicit (0x03). SubgroupID uint64 - // ObjectIDDelta is the delta from the previous Object ID, added with no - // +1 — or the absolute Object ID on the first object and whenever - // GroupIDDelta is present (§11.4.4.1). Present when the + // ObjectIDDelta is the delta from the previous Object ID (no +1), or the + // absolute Object ID on the first object and whenever GroupIDDelta is + // present (§11.4.4.1). Present when the // FetchFlagObjectIDDelta bit (0x04) is set. ObjectIDDelta uint64 diff --git a/pkg/moqt/message/fill.go b/pkg/moqt/message/fill.go index 46700968..2438d1ad 100644 --- a/pkg/moqt/message/fill.go +++ b/pkg/moqt/message/fill.go @@ -55,8 +55,7 @@ func FillParametersFromParam(ps Parameters) (inner Parameters, ok bool, err erro "moqt/message: %s not allowed inside FILL_PARAMETERS (PROTOCOL_VIOLATION §10.2.15)", ip.Type) } } - // A separate parameter scope, "encoded as if they were Parameters for a - // separate message" (§10.2.15), so §10.2's duplicate rule applies. + // A separate message's parameters (§10.2.15): §10.2's duplicate rule applies. if t, dup := inner.firstDuplicate(); dup { return nil, true, fmt.Errorf("moqt/message: duplicate %s inside FILL_PARAMETERS (PROTOCOL_VIOLATION §10.2)", t) } diff --git a/pkg/moqt/message/object_properties.go b/pkg/moqt/message/object_properties.go index f2590d2c..666eeffe 100644 --- a/pkg/moqt/message/object_properties.go +++ b/pkg/moqt/message/object_properties.go @@ -8,25 +8,14 @@ import ( ) // CheckObjectProperties reports whether an Object's raw Properties make its -// track malformed (§2.4.2) by any rule decidable from the Object alone: +// track malformed (§2.4.2) by a rule decidable from the Object alone: an +// unparsable pair or nested Immutable Properties (§12.7), a repeated Prior +// Group/Object ID Gap or one exceeding the Object's ID (§12.8, §12.9), or a +// Mandatory Track Property (§2.5.1). A repeated Immutable Properties is +// treated as malformed too, an interpretation: §12.7 forbids it but does not +// list it as malformed. Rules that need earlier Objects are not checked. // -// - a Key-Value-Pair that cannot be parsed, in the mutable list or inside -// Immutable Properties (§12.7); -// - Immutable Properties inside Immutable Properties (§12.7); -// - more than one Immutable Properties. §12.7 says only "An Object MUST NOT -// contain more than one instance of this property", outside its list of -// malformed conditions; treating it as malformed is this package's reading -// of §2.4.2's non-exhaustive list; -// - more than one Prior Group ID Gap or Prior Object ID Gap, counting both -// lists, or one larger than the Object's Group ID / Object ID (§12.8, -// §12.9); -// - a Mandatory Track Property used as an Object Property (§2.5.1). -// -// The §12.8 / §12.9 rules that need earlier Objects — a gap covering an -// Object already received, an Object inside a gap already communicated, -// differing Prior Group ID Gaps within a Group — are not checked. -// -// It runs once per Object, so it walks the pairs without allocating. +// Must not allocate: per-Object path. func CheckObjectProperties(raw []byte, groupID, objectID uint64) error { var c objectPropertiesCheck if err := c.walk(raw, false); err != nil { @@ -65,7 +54,7 @@ func (c *objectPropertiesCheck) walk(raw []byte, nested bool) error { return errors.New("moqt/message: Immutable Properties inside Immutable Properties (§12.7)") } if c.immutables++; c.immutables > 1 { - // An interpretation: see CheckObjectProperties. + // An interpretation, see CheckObjectProperties. return fmt.Errorf("moqt/message: Immutable Properties: %w (§12.7, §2.4.2)", errTooManyInstances) } if err := c.walk(kv.ByteVal, true); err != nil { diff --git a/pkg/moqt/message/param_scope.go b/pkg/moqt/message/param_scope.go index 516c12c5..0b47a002 100644 --- a/pkg/moqt/message/param_scope.go +++ b/pkg/moqt/message/param_scope.go @@ -8,16 +8,13 @@ import ( "strings" ) -// ErrUnknownParameter is wrapped by the parse error for a Message Parameter -// type this version does not define. §10.2: an endpoint that receives one -// "MUST close the session with PROTOCOL_VIOLATION". +// ErrUnknownParameter is wrapped by the parse error for an undefined Message +// Parameter type, a PROTOCOL_VIOLATION (§10.2). var ErrUnknownParameter = errors.New("moqt/message: unknown parameter type") -// ParamScope is the message form a parameter block arrived in, as the -// per-parameter scope rules of §10.2.1 tell them apart. A REQUEST_OK takes its -// form from the request it answers (§10.5: "PUBLISH_OK, REQUEST_UPDATE_OK, -// TRACK_STATUS_OK, ..."), and a REQUEST_UPDATE from the request it updates. -// Values are bit flags so a parameter's allowed forms are one mask. +// ParamScope is the message form a parameter block arrived in (§10.2.1). A +// REQUEST_OK takes its form from the request it answers (§10.5), and a +// REQUEST_UPDATE from the request it updates. Values are bit flags. type ParamScope uint32 const ( @@ -37,10 +34,9 @@ const ( ScopePublishNamespaceOK ScopePublishStateNotify ScopeRequestUpdateOK - // ScopeUpdateFromSubscriber is a REQUEST_UPDATE for a subscription — - // established by SUBSCRIBE or PUBLISH — sent by its subscriber; - // ScopeUpdateFromPublisher one sent by its publisher, on a PUBLISH it - // sent. §5.1.4 allows the Range Filters only "from the subscriber". + // ScopeUpdateFromSubscriber is a REQUEST_UPDATE on a subscription sent by + // its subscriber, ScopeUpdateFromPublisher one sent by the publisher of a + // PUBLISH. §5.1.4 allows the Range Filters only "from the subscriber". ScopeUpdateFromSubscriber ScopeUpdateFromPublisher ScopeUpdateFetch @@ -54,13 +50,8 @@ const ( scopeUpdateSubscription = ScopeUpdateFromSubscriber | ScopeUpdateFromPublisher scopeAnyUpdate = scopeUpdateSubscription | ScopeUpdateFetch | ScopeUpdateTrackStatus | ScopeUpdateSubscribeNamespace | ScopeUpdateSubscribeTracks | ScopeUpdatePublishNamespace - // §5.1.4: "All other filter parameters MAY appear multiple times in a - // FETCH, SUBSCRIBE, SUBSCRIBE_TRACKS, or REQUEST_UPDATE (on a - // subscription, from the subscriber only) message", and the Track - // Property filter in "a SUBSCRIBE_TRACKS message or REQUEST_UPDATE for - // it". Its opening sentence names SUBSCRIBE, FETCH and SUBSCRIBE_TRACKS - // for all five, so all five share one scope here; the text is ambiguous, - // and the reading that closes fewer sessions was chosen. + // §5.1.4 is ambiguous on where each filter may appear; all five share the + // widest reading, which closes fewer sessions. scopeRangeFilter = ScopeSubscribe | ScopeFetch | ScopeSubscribeTracks | ScopeUpdateFromSubscriber | ScopeUpdateSubscribeTracks ) @@ -227,18 +218,14 @@ func (e *ParamScopeError) Error() string { return fmt.Sprintf("moqt/message: %s not allowed in %s (PROTOCOL_VIOLATION §10.2.1)", e.Type, e.Scope) } -// CheckScope reports the first parameter of ps that may not appear in a -// message of the given scope, or that repeats where its definition does not -// allow it (see [Parameters.firstDuplicate]). A FILL_PARAMETERS value is a -// scope of its own (§10.2.15) and is checked against its table too, and an -// INCLUDE_PROPERTIES value must be 0 or 1 (§10.2.21). Every error is a -// session-level PROTOCOL_VIOLATION. +// CheckScope reports the first parameter of ps not allowed in a message of the +// given scope (§10.2.1) or repeated where it may not be (§10.2), and validates +// the FILL_PARAMETERS (§10.2.15) and INCLUDE_PROPERTIES (§10.2.21) values. +// Every error is a session-level PROTOCOL_VIOLATION. func (ps Parameters) CheckScope(scope ParamScope) error { for _, p := range ps { allowed := paramScopes[p.Type] - // §10.20.1: "Any Parameter that can be specified on a Subscription - // (ie: in SUBSCRIBE) is valid in SUBSCRIBE_TRACKS, unless otherwise - // specified." They become the subscriptions' initial parameters. + // §10.20.1: SUBSCRIBE parameters are valid in SUBSCRIBE_TRACKS. if allowed&ScopeSubscribe != 0 { allowed |= ScopeSubscribeTracks } @@ -258,12 +245,9 @@ func (ps Parameters) CheckScope(scope ParamScope) error { return nil } -// firstDuplicate reports the first parameter type that appears more than once -// in ps where its definition does not allow it (§10.2: "Senders MUST NOT -// repeat the same Parameter Type in a message unless the parameter definition -// explicitly allows multiple instances"). The Range Filters may repeat -// (§5.1.4), and so may AUTHORIZATION_TOKEN (§10.2.2: it "MAY be repeated -// within a message"). +// firstDuplicate reports the first parameter type repeated in ps where its +// definition does not allow it (§10.2). The Range Filters (§5.1.4) and +// AUTHORIZATION_TOKEN (§10.2.2) may repeat. func (ps Parameters) firstDuplicate() (ParamID, bool) { for i, p := range ps { if IsRangeFilterParam(p.Type) || p.Type == ParamAuthorizationToken { diff --git a/pkg/moqt/message/params.go b/pkg/moqt/message/params.go index c82e725d..11211fde 100644 --- a/pkg/moqt/message/params.go +++ b/pkg/moqt/message/params.go @@ -30,12 +30,9 @@ const ( ParamLocationFilter ParamID = 0x21 ParamGroupOrder ParamID = 0x22 ParamFillParameters ParamID = 0x23 - // Range Filter parameters (§5.1.4, §10.2.10-14). All five carry a - // length-prefixed blob (SetID, optional Property Type, delta-encoded - // Ranges) — see rangefilter.go. Message Parameters are not Key-Value-Pairs: - // "The encoding is specified by each parameter definition" (§10.2), so type - // parity says nothing about them, and all five are length-prefixed as - // §5.1.4's figures show (KindBytes, in paramKinds). + // Range Filter parameters (§5.1.4, §10.2.10-14), see rangefilter.go. All + // five are length-prefixed (KindBytes) whatever their type parity: Message + // Parameter encodings are per definition (§10.2), not Key-Value-Pairs. ParamSubgroupFilter ParamID = 0x25 ParamObjectIDFilter ParamID = 0x26 ParamPriorityFilter ParamID = 0x27 @@ -368,11 +365,8 @@ func (ps *Parameters) parse(r *wire.Reader) error { if err != nil { return err } - // count is an untrusted varint (up to 2^64-1, §1.4.1); never preallocate from it - // directly or a crafted message triggers an out-of-range makeslice panic. - // Each parameter occupies at least one byte on the wire (its type-delta - // varint), so the real count cannot exceed the remaining bytes — the loop - // surfaces a truncated count as a read error. + // count is untrusted (up to 2^64-1, §1.4.1): cap the preallocation by the + // remaining bytes, since each parameter takes at least one. //nolint:gosec // G115: Reader.Remaining() = len(buf)-off is always >= 0. out := make(Parameters, 0, min(count, uint64(r.Remaining()))) var prev uint64 diff --git a/pkg/moqt/message/properties.go b/pkg/moqt/message/properties.go index 529aab02..4906376e 100644 --- a/pkg/moqt/message/properties.go +++ b/pkg/moqt/message/properties.go @@ -63,12 +63,10 @@ const ( const DefaultPublisherPriority uint8 = 128 // TrackDefaultPublisherPriority returns the DEFAULT_PUBLISHER_PRIORITY (§12.4) -// carried in a raw Track Properties block, or [DefaultPublisherPriority] when -// the property is omitted. Per §12.7 the value may sit in the mutable list or -// inside Immutable Properties and both are searched, the mutable list first. -// §12.4 says "Priorities above 255 are invalid" without prescribing a -// reaction; an invalid value, like a malformed block, is read as omitted -// rather than truncated. +// in a raw Track Properties block, or [DefaultPublisherPriority] when it is +// omitted. Immutable Properties are searched too, the mutable value winning +// (§12.7). §12.4 prescribes no reaction to a value above 255; like a malformed +// block, it is read as omitted. func TrackDefaultPublisherPriority(trackProperties []byte) uint8 { pairs, err := ParseTrackProperties(trackProperties) if err != nil { @@ -102,16 +100,11 @@ func findDefaultPublisherPriority(pairs []wire.KVPair) (uint8, bool) { } // ExpandImmutable returns pairs followed by the contents of each Immutable -// Properties property (§12.7) among them — "When looking for the value of a -// property, processors MUST search both the mutable properties and the -// contents of Immutable Properties." A lookup that stops at the first match -// gets the mutable value when both carry one, as [TrackDefaultPublisherPriority] -// does; a loop in which a later pair overwrites an earlier one should range -// over the result backwards for the same outcome. +// Properties among them, for the lookup §12.7 requires. The first match is the +// mutable value; a last-wins loop must range over the result backwards. // // pairs is returned as is, without allocating, when it holds no Immutable -// Properties. Contents that do not parse are an error: §12.7 makes the track -// malformed when "A Key-Value-Pair cannot be parsed". +// Properties. Contents that do not parse are an error (§12.7). func ExpandImmutable(pairs []wire.KVPair) ([]wire.KVPair, error) { out := pairs for _, kv := range pairs { @@ -141,9 +134,8 @@ func parseSearchable(raw []byte) ([]wire.KVPair, error) { } // TrackMaxCacheDuration returns the MAX_CACHE_DURATION (§12.3) in a raw Track -// Properties block and whether it is present, searching Immutable Properties -// too with the mutable value winning (§12.7). A block that does not parse -// reads as having none. +// Properties block and whether it is present, the mutable value winning over +// Immutable Properties (§12.7). A block that does not parse has none. func TrackMaxCacheDuration(trackProperties []byte) (time.Duration, bool) { pairs, err := parseSearchable(trackProperties) if err != nil { diff --git a/pkg/moqt/message/rangefilter.go b/pkg/moqt/message/rangefilter.go index 5aace4af..0e15865e 100644 --- a/pkg/moqt/message/rangefilter.go +++ b/pkg/moqt/message/rangefilter.go @@ -76,8 +76,8 @@ func (f *RangeFilter) Bytes() []byte { } // RangeFilterParam builds the message Parameter (§10.2) carrying f. The value -// is a length-prefixed blob (KindBytes) for all five filter types — see the -// paramKinds note in params.go on why parameter encodings ignore type parity. +// is a length-prefixed blob (KindBytes) for all five filter types, see the +// note in params.go. func RangeFilterParam(f *RangeFilter) Parameter { return BytesParam(f.Type, f.Bytes()) } @@ -201,10 +201,8 @@ type filterKey struct { // RangeFiltersFromParams extracts every Range Filter parameter (§5.1.4) from ps, // validates each, rejects a duplicate (Type, SetID, Property Type) combination // (§5.1.4), and groups them by SetID. Returns (nil, nil) when ps carries no -// range filters — the "no filter" default, matching [LocationFilterFromParam]. -// A zero-length parameter is no filter (§5.1.4: "When Length is 0, there is no -// filter and no further fields are present"). The MAX_FILTER_RANGES limit -// needs the negotiated cap and is enforced separately by +// range filters, matching [LocationFilterFromParam]. A zero-length parameter +// is no filter (§5.1.4). The MAX_FILTER_RANGES limit is enforced separately by // [RangeFilterSet.Validate]. func RangeFiltersFromParams(ps Parameters) (*RangeFilterSet, error) { filters, err := parseRangeFilters(ps) @@ -214,13 +212,11 @@ func RangeFiltersFromParams(ps Parameters) (*RangeFilterSet, error) { return buildRangeFilterSet(filters) } -// Update applies a REQUEST_UPDATE's Range Filter parameters to s (§5.1.4): "In -// REQUEST_UPDATE, Length of 0 removes the filter; non-zero replaces it -// entirely. If a filter parameter is omitted from REQUEST_UPDATE, it is -// unchanged." A zero-length parameter carries no SetID, so a filter is named -// by its Parameter Type: every existing filter of a type the update carries is -// dropped, and the update's non-empty filters of that type take their place. -// Returns nil when no filter remains. s may be nil (no filters yet). +// Update applies a REQUEST_UPDATE's Range Filter parameters to s (§5.1.4): +// Length 0 removes a filter, non-zero replaces it, omitted leaves it. A +// zero-length parameter carries no SetID, so filters are replaced by Parameter +// Type: every existing filter of a type the update carries is dropped. Returns +// nil when no filter remains. s may be nil. func (s *RangeFilterSet) Update(ps Parameters) (*RangeFilterSet, error) { added, err := parseRangeFilters(ps) if err != nil { @@ -313,12 +309,9 @@ func (s *RangeFilterSet) Validate(maxFilterRanges uint64) error { return nil } -// propertyValue extracts property t's value from a decoded property KV set — -// the first one, so the mutable value when [ExpandImmutable] also found one -// inside Immutable Properties (§12.7). -// Even property types carry a varint value (in wire.KVPair.IntVal); Range -// Filters require an even Property Type (enforced by Validate), so an odd type -// never reaches here. +// propertyValue returns the first value of property t in pairs: the mutable +// one when [ExpandImmutable] also found it in Immutable Properties (§12.7). +// Validate admits only even (varint) Property Types. func propertyValue(pairs []wire.KVPair, t PropertyType) (uint64, bool) { for _, kv := range pairs { if kv.Type == t { diff --git a/pkg/moqt/message/subgroup_object.go b/pkg/moqt/message/subgroup_object.go index faafe20f..e4226f91 100644 --- a/pkg/moqt/message/subgroup_object.go +++ b/pkg/moqt/message/subgroup_object.go @@ -8,15 +8,12 @@ import ( "github.com/floatdrop/moq-go/pkg/moqt/wire" ) -// ErrIDOverflow reports a Group or Object ID reconstructed from a delta that -// falls outside 0..2^64-1. §11.4.2 and §11.4.4.1 make it a session-level -// PROTOCOL_VIOLATION. +// ErrIDOverflow reports a Group or Object ID reconstructed from a delta outside +// 0..2^64-1, a session-level PROTOCOL_VIOLATION (§11.4.2, §11.4.4.1). var ErrIDOverflow = errors.New("moqt/message: Group or Object ID outside 0..2^64-1") -// NextSubgroupObjectID applies a §11.4.2 Object ID Delta to the previous -// Object ID on a Subgroup stream: "The Object ID Delta + 1 is added to the -// previous Object ID". A result past 2^64-1 is [ErrIDOverflow], on which "the -// endpoint MUST close the session with a PROTOCOL_VIOLATION". +// NextSubgroupObjectID returns prev + delta + 1, the next Object ID on a +// Subgroup stream (§11.4.2), or [ErrIDOverflow] past 2^64-1. func NextSubgroupObjectID(prev, delta uint64) (uint64, error) { id, carry := bits.Add64(prev, delta, 1) if carry != 0 { @@ -82,9 +79,7 @@ func (o *SubgroupObject) Append(w *wire.Writer, hasProperties bool) { // had the Properties bit set, which determines if Properties are included. // // io.EOF is returned only for a stream that ends before the object's first -// byte. Once the Object ID Delta has been read, a FIN is a stream ending "in -// the middle of a serialized Object" (§11.4) and surfaces as -// io.ErrUnexpectedEOF. +// byte; a FIN mid-Object (§11.4) is io.ErrUnexpectedEOF. func (o *SubgroupObject) Parse(r wire.Decoder, hasProperties bool) error { delta, err := r.Varint() if err != nil { @@ -139,9 +134,8 @@ func (o *SubgroupObject) Validate() error { default: return fmt.Errorf("moqt/message: invalid object status 0x%X", o.ObjectStatus) } - // §11.2.1.2: "If an endpoint receives properties on an Object with - // status that is not Normal, it MUST close the session with a - // PROTOCOL_VIOLATION." A Properties Length of 0 carries none (§11.4.2). + // §11.2.1.2: no properties on a non-Normal status; a Properties Length + // of 0 carries none (§11.4.2). if o.ObjectStatus != ObjectStatusNormal && len(o.Properties) > 0 { return fmt.Errorf("moqt/message: object status 0x%X carries properties", o.ObjectStatus) } diff --git a/pkg/moqt/message/token.go b/pkg/moqt/message/token.go index 9b6947eb..62e6c8fa 100644 --- a/pkg/moqt/message/token.go +++ b/pkg/moqt/message/token.go @@ -143,9 +143,7 @@ func (t *Token) Parse(raw []byte) error { return fmt.Errorf("moqt/message: unknown token alias type 0x%X", at) } - // REGISTER and USE_VALUE take the rest as the Token Value; DELETE and - // USE_ALIAS carry "an Alias but no Type or Value" (§10.2.2), so anything - // after the alias means the structure did not decode. + // DELETE and USE_ALIAS carry only an Alias (§10.2.2). if n := r.Remaining(); n > 0 { return fmt.Errorf("moqt/message: %d trailing bytes after token alias", n) } diff --git a/pkg/moqt/message/types.go b/pkg/moqt/message/types.go index 154c4e72..2da0b960 100644 --- a/pkg/moqt/message/types.go +++ b/pkg/moqt/message/types.go @@ -85,14 +85,10 @@ func Parse(src io.Reader) (Message, error) { return ParsePayload(Type(t), payload) } -// ErrMalformedMessage is wrapped by every error [ParsePayload] returns, and so -// by every error [Parse] returns once it has read a whole frame: an unknown -// type, a Message Body that does not match its Length, or a field that fails -// validation. Each is session-fatal — §10: "An endpoint that receives an -// unknown message type MUST close the session", and "If the length does not -// match the length of the Message Body, the receiver MUST close the session -// with a PROTOCOL_VIOLATION". A frame that could not be read whole (the stream -// ended or was reset mid-frame) does not wrap it. +// ErrMalformedMessage is wrapped by every error [ParsePayload] returns, and by +// every error [Parse] returns once it has read a whole frame: an unknown type, +// a Length mismatch, or a field that fails validation, each session-fatal +// (§10). A frame that could not be read whole does not wrap it. var ErrMalformedMessage = errors.New("moqt/message: malformed message") // ParsePayload constructs a Message of the given Type and parses payload into diff --git a/pkg/moqt/uri/syntax.go b/pkg/moqt/uri/syntax.go index 2809f79e..06e505a2 100644 --- a/pkg/moqt/uri/syntax.go +++ b/pkg/moqt/uri/syntax.go @@ -8,15 +8,13 @@ import ( ) // CheckAuthority reports whether s is an RFC 3986 authority (§3.2) with a -// non-empty host, the form the AUTHORITY Setup Option carries (§10.3.1.1: -// it "follows the URI formatting rules [RFC3986]"; §3.1.1: "The authority -// portion MUST NOT contain an empty host portion"): +// non-empty host, the form the AUTHORITY Setup Option carries (§10.3.1.1, +// §3.1.1): // // authority = [ userinfo "@" ] host [ ":" port ] // -// Unlike [Parse], which goes through net/url, it accepts nothing RFC 3986 -// does not: no raw non-ASCII, no zone identifiers, no characters outside the -// grammar. +// Unlike [Parse] (net/url), it accepts nothing outside the RFC 3986 grammar: +// no raw non-ASCII, no zone identifiers. func CheckAuthority(s string) error { hostport := s if userinfo, rest, found := strings.Cut(s, "@"); found { @@ -39,10 +37,8 @@ func CheckAuthority(s string) error { } // CheckPathAndQuery reports whether s is an RFC 3986 path-abempty optionally -// followed by "?" and a query — the form the PATH Setup Option carries -// (§10.3.1.2: "the client MUST set the PATH option to the path-abempty portion -// of the URI; if query is present, the client MUST concatenate ?, followed by -// the query portion"): +// followed by "?" and a query, the form the PATH Setup Option carries +// (§10.3.1.2): // // path-abempty = *( "/" segment ) // segment = *pchar diff --git a/pkg/moqt/wire/kv.go b/pkg/moqt/wire/kv.go index 53d38029..32803e29 100644 --- a/pkg/moqt/wire/kv.go +++ b/pkg/moqt/wire/kv.go @@ -53,10 +53,8 @@ func (r *Reader) KVPair(prev uint64) (KVPair, uint64, error) { return r.kvPair(prev, true) } -// KVPairView is [Reader.KVPair] with a byte value that aliases the reader's -// buffer instead of copying it, for callers that only inspect the pairs -// (per-Object validation) and must not allocate. The value is valid only as -// long as the buffer is. +// KVPairView is [Reader.KVPair] without the copy, for per-Object inspection +// that must not allocate: a byte value aliases the reader's buffer. func (r *Reader) KVPairView(prev uint64) (KVPair, uint64, error) { return r.kvPair(prev, false) } diff --git a/pkg/moqt/wire/wire.go b/pkg/moqt/wire/wire.go index 7a13c831..36e49027 100644 --- a/pkg/moqt/wire/wire.go +++ b/pkg/moqt/wire/wire.go @@ -80,8 +80,7 @@ func (r *Reader) UInt8() (uint8, error) { // the caller owns; mutating it does not affect the Reader's buffer, and // retaining it does not pin the buffer for GC. Zero-length reads return nil. func (r *Reader) FixedBytes(n int) ([]byte, error) { - // n < 0 when a caller converted a peer-supplied varint >= 2^63 (§1.4.1 - // allows up to 2^64-1); no buffer is that long, so it is short too. + // n < 0 when a caller's int conversion of a varint >= 2^63 wrapped. if n < 0 || r.Remaining() < n { return nil, ErrShortBuffer } @@ -122,8 +121,7 @@ func (r *Reader) varintBytes(copyBytes bool) ([]byte, error) { if err != nil { return nil, err } - // §1.4.1 varints reach 2^64-1; bound n before it can wrap in the int - // conversion. + // §1.4.1 varints reach 2^64-1: bound n before the int conversion. if n > uint64(r.Remaining()) { //nolint:gosec // G115: Remaining() is len(buf)-off >= 0. return nil, ErrShortBuffer } @@ -277,8 +275,7 @@ func (s *StreamReader) VarintBytes() ([]byte, error) { if err != nil { return nil, err } - // §1.4.1 varints reach 2^64-1; bound n before it can wrap in the int - // conversion. + // §1.4.1 varints reach 2^64-1: bound n before the int conversion. if n > uint64(MaxStreamFieldSize) { //nolint:gosec // G115: a size cap, never negative. return nil, fmt.Errorf("%w: %d > %d", ErrFieldTooLarge, n, MaxStreamFieldSize) } From ea6359650dc22f9240ddf20cfebdc0b0d4b5d079 Mon Sep 17 00:00:00 2001 From: Vsevolod Strukchinsky Date: Sat, 26 Sep 2026 09:56:03 +0500 Subject: [PATCH 02/12] docs(session): trim comments to contract, citation and why Shorten doc comments on the draft-20 compliance additions in pkg/moqt/session to what a caller must know, cut long and repeated spec quotations to section citations, and drop history and restated code. Spec interpretations stay marked. Comment-only; the other changes are gofmt field alignment. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/moqt/session/broker.go | 103 ++---- pkg/moqt/session/conn.go | 41 +-- pkg/moqt/session/datagram.go | 5 +- pkg/moqt/session/datastream_in.go | 125 ++----- pkg/moqt/session/datastream_out.go | 36 +- pkg/moqt/session/fetch.go | 30 +- pkg/moqt/session/handshake.go | 54 +-- pkg/moqt/session/internal/conntest/suite.go | 10 +- pkg/moqt/session/namespace.go | 24 +- pkg/moqt/session/options.go | 31 +- pkg/moqt/session/publish.go | 98 ++--- pkg/moqt/session/request.go | 386 +++++++------------- pkg/moqt/session/session.go | 32 +- pkg/moqt/session/sessiontest/sessiontest.go | 9 +- pkg/moqt/session/subscribe.go | 5 +- pkg/moqt/session/token_verify.go | 103 ++---- pkg/moqt/session/track_properties.go | 51 +-- pkg/moqt/session/track_status.go | 40 +- pkg/moqt/session/trackalias.go | 62 +--- 19 files changed, 415 insertions(+), 830 deletions(-) diff --git a/pkg/moqt/session/broker.go b/pkg/moqt/session/broker.go index a1501dd0..795b41a9 100644 --- a/pkg/moqt/session/broker.go +++ b/pkg/moqt/session/broker.go @@ -26,12 +26,10 @@ import ( // - AUTHORIZATION_TOKEN parameters on follow-ups are resolved through the // session token cache (§10.2.2); a cache fault closes the session with // the mandated code. -// - A peer REQUEST_UPDATE is answered with the single REQUEST_OK or -// REQUEST_ERROR §10.9 mandates, as decided by the handler installed with -// [RequestBroker.HandleUpdates]. With no handler it is declined -// (REQUEST_ERROR NOT_SUPPORTED): acknowledging an update without applying -// it would misstate the request's state. [Publication.Broker] installs a -// handler that applies FORWARD itself. +// - A peer REQUEST_UPDATE is answered (§10.9) by the handler installed with +// [RequestBroker.HandleUpdates], or declined with NOT_SUPPORTED when there +// is none, since acknowledging an unapplied update would misstate the +// request's state. // - Everything else (PUBLISH_DONE, unsolicited responses, …) is handed to // Serve's callback. // @@ -58,41 +56,32 @@ type RequestBroker struct { streamClosed bool // onUpdate decides each peer REQUEST_UPDATE; nil declines it. - // onUpdateFailed runs after a declined update — §10.9.1's follow-up, - // e.g. PUBLISH_DONE UPDATE_FAILED for a subscription. Both are set + // onUpdateFailed runs after a declined update (§10.9.1). Both are set // before Serve runs. onUpdate UpdateHandler onUpdateFailed func() - // updateScope is the §10.2.1 scope peer REQUEST_UPDATEs are checked - // against; 0 skips the check. See [RequestBroker.UpdateScope]. + // updateScope is the §10.2.1 scope of peer REQUEST_UPDATEs; 0 skips the + // check. updateScope message.ParamScope - // noPeerUpdate / noPeerNotify record that the peer may not send - // REQUEST_UPDATE / PUBLISH_STATE_NOTIFY on this stream; see - // [RequestBroker.PeerMessages]. + // See [RequestBroker.PeerMessages]. noPeerUpdate bool noPeerNotify bool } -// PeerMessages declares which follow-ups the peer may send on this stream. -// §10.9: REQUEST_UPDATE comes only from "The sender of a request" or from "A -// subscriber ... of a subscription established with PUBLISH". §10.10: -// PUBLISH_STATE_NOTIFY "applies only to subscriptions, and is sent only by the -// publisher". A disallowed one closes the session with PROTOCOL_VIOLATION, as -// both sections require. Typed handles' Broker methods set this; a broker from -// [Session.NewRequestBroker] allows both until told otherwise. Call it before -// [RequestBroker.Serve]. +// PeerMessages declares whether the peer may send REQUEST_UPDATE (§10.9) and +// PUBLISH_STATE_NOTIFY (§10.10) on this stream; a disallowed one closes the +// session with PROTOCOL_VIOLATION. Typed handles set this; a broker from +// [Session.NewRequestBroker] allows both. Call it before [RequestBroker.Serve]. func (b *RequestBroker) PeerMessages(requestUpdate, publishStateNotify bool) { b.noPeerUpdate, b.noPeerNotify = !requestUpdate, !publishStateNotify } -// UpdateScope sets the §10.2.1 parameter scope of the peer's REQUEST_UPDATEs -// on this stream — [message.ScopeOfUpdate] of the request, or -// [message.ScopeUpdateFromSubscriber] for the subscriber of a PUBLISH — so one -// carrying a parameter outside it closes the session with PROTOCOL_VIOLATION. -// The typed handles' brokers have it set; a broker made with -// [Session.NewRequestBroker] checks nothing until told. Call it before +// UpdateScope sets the §10.2.1 parameter scope of the peer's REQUEST_UPDATEs; +// one carrying a parameter outside it closes the session with +// PROTOCOL_VIOLATION. Typed handles set this; a broker from +// [Session.NewRequestBroker] checks nothing. Call it before // [RequestBroker.Serve]. func (b *RequestBroker) UpdateScope(s message.ParamScope) { b.updateScope = s } @@ -103,9 +92,7 @@ func (b *RequestBroker) UpdateScope(s message.ParamScope) { b.updateScope = s } type UpdateHandler func(upd *message.RequestUpdate) (*message.RequestOK, error) // HandleUpdates installs the handler that decides peer REQUEST_UPDATEs, -// replacing any earlier one (for a [Publication], its built-in handling — -// which the new handler can still reuse via [Publication.ApplyUpdate]). Call -// it before [RequestBroker.Serve]. +// replacing any earlier one. Call it before [RequestBroker.Serve]. func (b *RequestBroker) HandleUpdates(h UpdateHandler) { b.onUpdate = h } // answerUpdate writes the §10.9 response to upd and reports whether the @@ -120,8 +107,7 @@ func (b *RequestBroker) answerUpdate(upd *message.RequestUpdate) (bool, error) { } else { ok, err = b.onUpdate(upd) } - // A handler that closed the session (e.g. §10.2.18's PROTOCOL_VIOLATION - // on a bad FORWARD) ends Serve; there is no request left to answer. + // A handler that closed the session leaves no request to answer. select { case <-b.sess.Done(): if err == nil { @@ -135,8 +121,7 @@ func (b *RequestBroker) answerUpdate(upd *message.RequestUpdate) (bool, error) { ok = &message.RequestOK{} } if len(ok.TrackProperties) > 0 { - // §10.5: REQUEST_UPDATE_OK's Track Properties are empty; sending - // them would make the peer close the session. + // §10.5: REQUEST_UPDATE_OK's Track Properties are empty. err = fmt.Errorf("%w: REQUEST_UPDATE_OK", ErrTrackPropertiesNotAllowed) } } @@ -179,11 +164,9 @@ func (s *Session) NewRequestBroker(stream Stream) *RequestBroker { return &RequestBroker{stream: stream, sess: s} } -// mapUpdateResponse converts a §10.9 response message into the -// (*message.RequestOK, error) shape Update-style callers return: REQUEST_OK -// passes through, REQUEST_ERROR becomes a *RequestRejectedError, anything -// else is a protocol-shape error. A REQUEST_UPDATE_OK carrying Track -// Properties closes the session (§10.5). +// mapUpdateResponse converts a §10.9 response: REQUEST_OK passes through, +// REQUEST_ERROR becomes a *RequestRejectedError, anything else is an error. A +// REQUEST_UPDATE_OK carrying Track Properties closes the session (§10.5). func (s *Session) mapUpdateResponse(msg message.Message) (*message.RequestOK, error) { switch m := msg.(type) { case *message.RequestOK: @@ -337,11 +320,9 @@ func (b *RequestBroker) closeUpdates() { } // Close cancels the request (§3.3.3): pending and future Updates fail with -// [ErrRequestStreamClosed], and both directions are reset with code — STOP_SENDING -// on the read side (unblocking a running Serve) and RESET_STREAM on the send -// side. A FIN would not end the request (§3.3.2). Serialized against in-flight -// writes; idempotent. Must not be called with locks that Serve's callback -// might need held. +// [ErrRequestStreamClosed] and both directions are reset with code, which +// unblocks a running Serve. Serialized against in-flight writes; idempotent. +// Must not be called with locks that Serve's callback might need held. func (b *RequestBroker) Close(code moqt.StreamResetCode) { b.closeUpdates() b.mu.Lock() @@ -359,20 +340,16 @@ func (b *RequestBroker) Close(code moqt.StreamResetCode) { // the parse), or onMsg returns false. On exit, pending and future Update // calls fail with [ErrRequestStreamClosed]. // -// Responses route to Update waiters; token parameters go through the -// session's token cache (a cache fault closes the session with the §10.2.2 -// code and ends Serve); peer REQUEST_UPDATEs are answered as the handler -// installed with [RequestBroker.HandleUpdates] decides, and declined with -// NOT_SUPPORTED when there is none. Every message — including each -// REQUEST_UPDATE and any unsolicited response — is passed to onMsg (nil means -// "discard"); return false from onMsg to stop serving. +// Responses route to Update waiters; a token cache fault closes the session +// (§10.2.2); peer REQUEST_UPDATEs are answered as described on +// [RequestBroker]. Every other message, including each REQUEST_UPDATE and any +// unsolicited response, is passed to onMsg (nil means "discard"); return false +// from onMsg to stop serving. // -// A follow-up that cannot be read (any non-EOF error) resets the read side -// with INTERNAL_ERROR so the peer learns reads stopped instead of filling flow -// control into a void; one that is malformed (an unknown type, or a body that -// does not match its Length) also closes the session with PROTOCOL_VIOLATION -// (§10). Serve returns nil on a clean FIN or an onMsg stop, ctx.Err() on -// cancellation, and the read/token error otherwise. +// A read error resets the read side with INTERNAL_ERROR; a malformed follow-up +// also closes the session with PROTOCOL_VIOLATION (§10). Serve returns nil on +// a clean FIN or an onMsg stop, ctx.Err() on cancellation, and the read/token +// error otherwise. func (b *RequestBroker) Serve(ctx context.Context, onMsg func(message.Message) bool) error { defer b.closeUpdates() stop := context.AfterFunc(ctx, func() { @@ -416,10 +393,9 @@ func (b *RequestBroker) Serve(ctx context.Context, onMsg func(message.Message) b switch m := msg.(type) { case *message.RequestOK, *message.RequestError: - // Every REQUEST_OK read here answers a REQUEST_UPDATE — the - // request's own response was read before the broker attached — - // so §10.5's empty-Track-Properties rule applies even to one that - // arrives after its Update gave up. + // The request's own response was read before the broker + // attached, so every REQUEST_OK here is a REQUEST_UPDATE_OK + // (§10.5), even one whose Update gave up. if err := b.sess.checkRequestOKTrackProperties(nil, m); err != nil { return err } @@ -461,9 +437,8 @@ func (b *RequestBroker) Serve(ctx context.Context, onMsg func(message.Message) b _ = b.sess.Close(moqt.SessionTooManyRequestUpdates, err.Error()) return err } - // §10.9: the receiver of a REQUEST_UPDATE "MUST respond with - // exactly one REQUEST_OK or REQUEST_ERROR"; the handler decides - // which. onMsg still observes the update. + // §10.9: "MUST respond with exactly one REQUEST_OK or + // REQUEST_ERROR". onMsg still observes the update. accepted, err := b.answerUpdate(m) if err != nil { return err diff --git a/pkg/moqt/session/conn.go b/pkg/moqt/session/conn.go index 358449bb..a2f214c3 100644 --- a/pkg/moqt/session/conn.go +++ b/pkg/moqt/session/conn.go @@ -30,22 +30,11 @@ type SendStream interface { CancelWrite(code uint64) // Context returns a context that is cancelled when this side's send - // direction ends: Close (FIN) or CancelWrite is called, or the peer stops - // reading it (STOP_SENDING). It does NOT track acknowledgement — quic-go - // cancels it as soon as Close queues the FIN. - // - // While this side's send direction is still open, a done Context - // therefore means the peer sent STOP_SENDING. That is how a responder - // observes a requester cancelling after it FINned its own side (§3.3.2: a - // FIN "is not a request cancellation"; §3.3.3: such a requester cancels - // with STOP_SENDING). Once this side has closed its send direction, the - // Context is already done and carries no further signal. - // - // For quic-go (and webtransport-go, which wraps it) this maps directly to - // quic.SendStream.Context(): "canceled as soon as the write-side of the - // stream is closed. This happens when [SendStream.Close] or - // [SendStream.CancelWrite] is called, or when the peer cancels the - // read-side of their stream." In-process test streams match it. + // direction ends: Close (FIN), CancelWrite, or the peer's STOP_SENDING. + // It does not track acknowledgement. While the send direction is open, a + // done Context means STOP_SENDING, which is how a responder sees a + // requester that already FINned cancel the request (§3.3.3). It matches + // quic.SendStream.Context. Context() context.Context } @@ -129,22 +118,14 @@ type ReliableResetStream interface { // DeliveryTrackingSendStream is optionally implemented by [SendStream] // implementations whose transport reports when the peer has acknowledged the -// stream. §8 SUBGROUP_DELIVERY_TIMEOUT needs that signal: once the subgroup is -// closed, a stream that has not reached "all data committed" within the -// timeout MUST be reset. [SendStream.Context] cannot stand in for it, since it -// ends when Close queues the FIN. +// stream, which §8 SUBGROUP_DELIVERY_TIMEOUT needs. // -// None of the bundled adapters implement it — quic-go tracks acknowledgement -// internally but exposes no API for it (quic-go#3291), and webtransport-go -// wraps quic-go — so on them SUBGROUP_DELIVERY_TIMEOUT is not enforced. -// Resetting on the timer alone is not a substitute: it would reset streams the -// peer already holds in full, and a peer that has not read them yet would drop -// that data. +// None of the bundled adapters implement it (quic-go exposes no such API), so +// on them SUBGROUP_DELIVERY_TIMEOUT is not enforced. Resetting on the timer +// alone would drop data the peer already holds. type DeliveryTrackingSendStream interface { - // Finished returns a channel that is closed once the send side is done - // for good: the peer has acknowledged every byte written and the FIN, or - // the stream has been reset. Close alone never closes it. An - // implementation may also close it when the connection ends. + // Finished returns a channel closed once the peer has acknowledged all + // data and the FIN, or the stream was reset (or the connection ended). Finished() <-chan struct{} } diff --git a/pkg/moqt/session/datagram.go b/pkg/moqt/session/datagram.go index 12eb8668..e8dc7262 100644 --- a/pkg/moqt/session/datagram.go +++ b/pkg/moqt/session/datagram.go @@ -21,9 +21,8 @@ const paddingDatagramType uint64 = 0x132B3E29 // unwrapped so the caller can distinguish them from parse failures. // // An Object whose Properties make its track malformed is returned together -// with an error wrapping [ErrMalformedTrack], so the caller knows which -// track (TrackAlias) to cancel; the session stays up and the next call -// reads on. +// with an error wrapping [ErrMalformedTrack], so the caller knows which track +// to cancel; the session stays up. func (s *Session) ReceiveDatagram(ctx context.Context) (*message.ObjectDatagram, error) { for { raw, err := s.conn.ReceiveDatagram(ctx) diff --git a/pkg/moqt/session/datastream_in.go b/pkg/moqt/session/datastream_in.go index 60213748..b3444b50 100644 --- a/pkg/moqt/session/datastream_in.go +++ b/pkg/moqt/session/datastream_in.go @@ -20,15 +20,9 @@ import ( var ErrPaddingStream = errors.New("moqt/session: padding stream received (ignorable)") // ErrMalformedTrack wraps the error a read returns for an Object that makes -// its track malformed (§2.4.2) — so far, Object Properties that fail -// [message.CheckObjectProperties]. §2.4.2: "When a subscriber detects a -// Malformed Track, it MUST cancel any corresponding subscription or fetches -// for that Track from that publisher (see Section 3.3.3), and SHOULD deliver -// an error to the application." The session delivers this error and leaves -// the cancelling to the caller, which holds the subscription or fetch; the -// session stays up. A relay "MUST immediately terminate downstream -// subscriptions with PUBLISH_DONE and reset any fetch streams with Status -// Code MALFORMED_TRACK", and must not cache the Object. +// its track malformed (§2.4.2), such as Object Properties that fail +// [message.CheckObjectProperties]. The session stays up; the caller MUST +// cancel the corresponding subscription or fetch (§2.4.2). var ErrMalformedTrack = errors.New("moqt/session: malformed track") // --------------------------------------------------------------------------- @@ -73,8 +67,7 @@ type IncomingSubgroupStream struct { // SubgroupObject.Parse). rd *wire.StreamReader - // Decoder state, kept by ReadObject: the absolute ID of the last Object - // it read and the stream's resolved Subgroup ID (§11.4.2). + // Decoder state, kept by ReadObject (§11.4.2). decPrevObject uint64 decHavePrev bool decSubgroupID uint64 // resolved per §11.4.2 (zero / first-object / explicit) @@ -100,26 +93,18 @@ func (s *IncomingSubgroupStream) TrackKey() (track.Key, bool) { } // InboundTrack is [IncomingSubgroupStream.TrackKey] plus the rest of what the -// alias is bound to — notably the DEFAULT_PUBLISHER_PRIORITY a header with the -// DEFAULT_PRIORITY bit inherits (§11.4.2). Resolution is live, as for TrackKey. +// alias is bound to. Resolution is live, as for TrackKey. func (s *IncomingSubgroupStream) InboundTrack() (InboundTrack, bool) { return s.sess.LookupInboundTrack(s.Header.TrackAlias) } // AwaitInboundTrack is [IncomingSubgroupStream.InboundTrack] that waits for // the stream's Track Alias to be registered, until ctx ends or the session -// closes. A publisher may open a track's subgroup streams as soon as it -// accepts the SUBSCRIBE, so they can arrive before the SUBSCRIBE_OK that binds -// their alias; §11.4.2 lets the receiver "buffer it for a brief period to -// handle reordering with the control message that establishes the Track -// Alias". The caller bounds the period with ctx. +// closes (§11.4.2 allows buffering "for a brief period"). // -// The stream is left unread meanwhile, so its bytes hold connection flow -// control. §11.4.2 requires endpoints to "allocate connection flow control to -// the control streams before allocating it to any data streams", which the -// bundled transports do not do: enough early data can stall the very -// SUBSCRIBE_OK being waited for, until ctx ends and the caller resets the -// stream. Keep the bound short. +// The unread stream holds connection flow control, and the bundled transports +// do not reserve it for control streams (§11.4.2), so early data can stall +// the SUBSCRIBE_OK being waited for. Keep ctx's bound short. func (s *IncomingSubgroupStream) AwaitInboundTrack(ctx context.Context) (InboundTrack, bool) { return s.sess.awaitInboundTrack(ctx, s.Header.TrackAlias) } @@ -130,9 +115,8 @@ func (s *IncomingSubgroupStream) isDataStream() {} // for correctly-framed object access. func (s *IncomingSubgroupStream) Read(p []byte) (int, error) { return s.br.Read(p) } -// ObjectID returns the absolute Object ID (§11.4.2) of the Object the last -// ReadObject or ReadDecoded call read, including one it returned with -// [ErrMalformedTrack]. +// ObjectID returns the absolute Object ID (§11.4.2) of the Object last read, +// including one returned with [ErrMalformedTrack]. func (s *IncomingSubgroupStream) ObjectID() uint64 { return s.decPrevObject } // Cancel resets the stream with the given application code (§3.3.4). @@ -153,15 +137,11 @@ func (s *IncomingSubgroupStream) ReadObject() (*message.SubgroupObject, error) { if err := obj.Parse(s.rd, s.Header.Properties); err != nil { return nil, s.sess.checkFINMidObject(err) } - // An invalid Object Status (§11.2.1.1, SHOULD) or properties on a - // non-Normal one (§11.2.1.2, MUST) close the session with - // PROTOCOL_VIOLATION. + // §11.2.1.1, §11.2.1.2. if err := obj.Validate(); err != nil { return nil, s.sess.closeProtocolViolation(fmt.Errorf("moqt/session: subgroup object: %w", err)) } - // §11.4.2: the first Object's delta is its ID; later ones encode - // (current - previous - 1). Resolved here, not only in ReadDecoded, so - // the Properties check below has the Object's absolute ID. + // §11.4.2. Resolved here so the Properties check has the absolute ID. objectID := obj.ObjectIDDelta if s.decHavePrev { var err error @@ -170,9 +150,8 @@ func (s *IncomingSubgroupStream) ReadObject() (*message.SubgroupObject, error) { } } s.decPrevObject, s.decHavePrev = objectID, true - // Resolve the §11.4.2 SubgroupID mode once per stream, before anything - // can fail: for SubgroupIDImplicitFirstObject it is the first Object's - // ID even if that Object is malformed. + // Resolve the §11.4.2 SubgroupID once per stream, before the Properties + // check, so a malformed first Object still sets it. if !s.decSubgroupResolved { switch s.Header.SubgroupIDMode { case message.SubgroupIDImplicitZero: @@ -368,8 +347,8 @@ func (d *DecodedFetchObject) IsEndOfRange() bool { // transitions. // // An Object whose Properties make the track malformed returns an error -// wrapping [ErrMalformedTrack]. ReadObject does not check: it has no -// absolute IDs to check a Prior Group / Object ID Gap against. +// wrapping [ErrMalformedTrack]. ReadObject does not check, since it has no +// absolute IDs. func (s *IncomingFetchStream) ReadDecoded() (*DecodedFetchObject, error) { raw, err := s.ReadObject() if err != nil { @@ -400,12 +379,9 @@ func (s *IncomingFetchStream) ReadDecoded() (*DecodedFetchObject, error) { Payload: raw.ObjectPayload, } - // §11.4.4.1 / §11.4.4.2: flags that reference the prior Object's - // Subgroup ID or Priority are a PROTOCOL_VIOLATION until a real object - // has been decoded — the very first object, and any object whose only - // predecessor is an End-of-Range marker, must spell both out. (When - // the Datagram bit is set the subgroup mode bits are ignored, - // §11.4.4.1.) + // §11.4.4.1 / §11.4.4.2: flags referencing the prior Object's Subgroup + // ID or Priority are a PROTOCOL_VIOLATION until a real (non-End-of-Range) + // object has been decoded. if !s.decHaveActual { if !raw.IsDatagram() { if m := raw.SubgroupMode(); m == message.FetchSubgroupIDPrior || @@ -425,32 +401,20 @@ func (s *IncomingFetchStream) ReadDecoded() (*DecodedFetchObject, error) { // Group / Object reconstruction. switch { case !s.decHavePrev: - // §11.4.4.1: the first object MUST include both a Group ID Delta and - // an Object ID Delta (its absolute IDs). If it instead uses a flag - // that references the prior object, that is a PROTOCOL_VIOLATION. - // (An End-of-Range marker counts as a prior for this dimension — - // decHavePrev is already true then.) + // §11.4.4.1: the first object carries absolute Group and Object IDs. + // An End-of-Range marker counts as a prior here. if raw.SerializationFlags&message.FetchFlagGroupIDDelta == 0 || raw.SerializationFlags&message.FetchFlagObjectIDDelta == 0 { return nil, s.sess.closeProtocolViolation(fmt.Errorf( "moqt/session: first fetch object missing Group/Object ID delta (flags 0x%X)", raw.SerializationFlags)) } - // First object: deltas carry absolute IDs (§11.4.4.1). d.GroupID = raw.GroupIDDelta d.ObjectID = raw.ObjectIDDelta default: - // §11.4.4.1: "When the Group ID Delta field is present, the Object ID - // is the value of Object ID Delta if present. When the Group ID Delta - // field is not present, the Object ID is the prior Object's ID plus the - // Object ID Delta if present. If Object ID Delta is not present, the - // Object ID is the prior Object's ID plus one, regardless of which - // group it belongs to." Unlike the §11.4.2 subgroup rule, a present - // delta carries no implicit +1. - // - // Each computation carries §11.4.4.1's bound: a Group ID "less than 0 - // or greater than 2^64-1", or an Object ID "greater than 2^64-1", - // MUST close the session with PROTOCOL_VIOLATION. + // §11.4.4.1: unlike §11.4.2, a present Object ID Delta carries no + // implicit +1; an absent one means the prior ID plus one, in any + // group. An ID outside 0..2^64-1 is a PROTOCOL_VIOLATION. var over uint64 d.GroupID = s.decPrevGroup newGroup := raw.SerializationFlags&message.FetchFlagGroupIDDelta != 0 @@ -539,18 +503,15 @@ func (s *IncomingFetchStream) decGroupOrder() message.GroupOrder { // consume the body. The concrete type is either *IncomingSubgroupStream or // *IncomingFetchStream; callers type-switch to obtain the typed stream. // -// A stream that ends or is reset before its header is complete is abandoned -// and skipped. A reset is §11.4.1 "Early termination of a unidirectional -// stream does not affect the MOQT application state". A FIN mid-header is -// treated the same way: §11.4 asks for PROTOCOL_VIOLATION only on a FIN in the -// middle of an Object, and says nothing of the header. The returned errors are: +// A stream that ends or is reset before its header is complete is skipped +// (§11.4.1). Interpretation: a FIN mid-header is treated the same, since §11.4 +// asks for PROTOCOL_VIOLATION only on a FIN mid-Object. The returned errors +// are: // - ErrPaddingStream when a padding stream (§11.5.1) is received — callers // SHOULD loop and call AcceptDataStream again; -// - *message.UnknownDataStreamTypeError when the leading Type isn't -// recognized (§3.4), or *message.ReservedSubgroupIDModeError when it -// matches the SUBGROUP_HEADER pattern with the reserved SUBGROUP_ID_MODE -// 0b11 (§11.4.2). Both are session-fatal: AcceptDataStream has already -// closed the session with PROTOCOL_VIOLATION; +// - *message.UnknownDataStreamTypeError (§3.4) or +// *message.ReservedSubgroupIDModeError (§11.4.2); AcceptDataStream has +// already closed the session with PROTOCOL_VIOLATION; // - transport-level errors (session closed, ctx cancelled), unwrapped from // the underlying conn. // @@ -565,12 +526,10 @@ func (s *Session) AcceptDataStream(ctx context.Context) (DataStream, error) { } } -// checkFINMidObject closes the session when a data stream ended with a FIN in -// the middle of an object: §11.4 "If a stream ends gracefully (i.e., the stream -// terminates with a FIN) in the middle of a serialized Object, the session -// SHOULD be closed with a PROTOCOL_VIOLATION." The parsers report exactly that -// case as io.ErrUnexpectedEOF; a reset surfaces as a stream error and is left -// alone (§11.4.1). err is returned unchanged. +// checkFINMidObject closes the session with PROTOCOL_VIOLATION when a data +// stream ended with a FIN mid-Object (§11.4), which the parsers report as +// io.ErrUnexpectedEOF; a reset is left alone (§11.4.1). err is returned +// unchanged. func (s *Session) checkFINMidObject(err error) error { if errors.Is(err, io.ErrUnexpectedEOF) { _ = s.closeProtocolViolation(err) @@ -594,10 +553,8 @@ func (s *Session) acceptDataStream(ctx context.Context) (DataStream, error) { src.CancelRead(uint64(moqt.StreamResetCancelled)) }) defer stop() - // aborted abandons a stream whose header read failed. Header fields are - // varints and a fixed byte, so the only failure is the stream ending (FIN) - // or being reset early — a per-stream event (see AcceptDataStream), unless - // ctx caused it. + // aborted abandons a stream whose header read failed; header fields + // cannot be malformed, so the only failure is an early FIN or reset. aborted := func() error { src.CancelRead(uint64(moqt.StreamResetInternalError)) if ctx.Err() != nil { @@ -630,13 +587,11 @@ func (s *Session) acceptDataStream(ctx context.Context) (DataStream, error) { src.CancelRead(uint64(moqt.StreamResetInternalError)) return nil, ErrPaddingStream case message.IsReservedSubgroupHeaderType(typ): - // §11.4.2: SUBGROUP_ID_MODE 0b11 is reserved — "MUST close the - // session with a PROTOCOL_VIOLATION". + // §11.4.2: SUBGROUP_ID_MODE 0b11 is reserved. src.CancelRead(uint64(moqt.StreamResetInternalError)) return nil, s.closeProtocolViolation(&message.ReservedSubgroupIDModeError{Type: typ}) default: - // §3.4: "An endpoint that receives an unknown stream type MUST close - // the session." + // §3.4: unknown stream type. src.CancelRead(uint64(moqt.StreamResetInternalError)) return nil, s.closeProtocolViolation(&message.UnknownDataStreamTypeError{Type: typ}) } diff --git a/pkg/moqt/session/datastream_out.go b/pkg/moqt/session/datastream_out.go index 67fb69b8..2547ada6 100644 --- a/pkg/moqt/session/datastream_out.go +++ b/pkg/moqt/session/datastream_out.go @@ -55,8 +55,7 @@ var writerPool = sync.Pool{ // and ErrDeliveryTimeout is returned. // - SUBGROUP_DELIVERY_TIMEOUT: a timer is started when Close() is called. // If the timer fires before the peer acknowledges all data, the stream is -// reset. This needs a transport that reports acknowledgement (see -// [DeliveryTrackingSendStream]); on one that does not, it is not enforced. +// reset. Enforced only on a [DeliveryTrackingSendStream]. type OutgoingSubgroupStream struct { header message.SubgroupHeader @@ -86,13 +85,10 @@ type OutgoingSubgroupStream struct { encPrevObject uint64 encHavePrev bool - // onObject, when set (by [Publication.OpenSubgroup]), is told the - // absolute Location of each object written, for LARGEST_OBJECT. + // Set by [Publication.OpenSubgroup]: onObject is told each written + // object's Location, and paused reports a Forward State of 0 (§11.4.3). onObject func(group, object uint64) - - // paused, when set (by [Publication.OpenSubgroup]), reports a Forward - // State of 0; a write then resets the stream (§11.4.3). - paused func() bool + paused func() bool } // WithDeliveryTimeouts returns a shallow copy of s configured with the §8 @@ -172,9 +168,7 @@ func (s *OutgoingSubgroupStream) WriteObjectReceivedAt( } s.sawFirstObject = true - // §5.1: no Objects while the Forward State is 0. §11.4.3 lists - // "Omitting a Subgroup Object due to the subscriber's Forward State" - // among the reasons to reset the stream. + // §5.1: no Objects while the Forward State is 0; §11.4.3: reset. if s.paused != nil && s.paused() { s.dst.CancelWrite(uint64(moqt.StreamResetCancelled)) return ErrForwardPaused @@ -190,7 +184,7 @@ func (s *OutgoingSubgroupStream) WriteObjectReceivedAt( if err != nil { return err } - // Track the absolute Object ID (§11.4.2: delta + 1 after the first). + // §11.4.2: delta + 1 after the first. objectID := obj.ObjectIDDelta if s.encHavePrev { objectID = s.encPrevObject + obj.ObjectIDDelta + 1 @@ -240,18 +234,13 @@ func (s *OutgoingSubgroupStream) WriteObjectAt(objectID uint64, obj *message.Sub // begins — bytes handed to Write carry no such boundary. A caller that wants // the timeout enforced should use // [OutgoingSubgroupStream.WriteObjectReceivedAt], which has both facts. -// SUBGROUP_DELIVERY_TIMEOUT still applies where the transport supports it, -// since Close enforces it. +// SUBGROUP_DELIVERY_TIMEOUT still applies, since Close enforces it. func (s *OutgoingSubgroupStream) Write(p []byte) (int, error) { return s.dst.Write(p) } -// checkObjectTimeout enforces OBJECT_DELIVERY_TIMEOUT against one object's -// receipt time, per §8: "For subgroups, the implementation MUST check the time -// elapsed before attempting to pass it to the underlying transport for -// transmission; if the time elapsed exceeds OBJECT_DELIVERY_TIMEOUT, it MUST -// reset the underlying transport stream with the reset stream code -// DELIVERY_TIMEOUT". The clock starts at the object's last header byte. +// checkObjectTimeout enforces OBJECT_DELIVERY_TIMEOUT (§8) against one +// object's receipt time, resetting the stream with DELIVERY_TIMEOUT. func (s *OutgoingSubgroupStream) checkObjectTimeout(receivedAt time.Time) error { if s.objectTimeout <= 0 { return nil @@ -268,10 +257,9 @@ func (s *OutgoingSubgroupStream) checkObjectTimeout(receivedAt time.Time) error // Close FINs the send side cleanly. Callers must have no concurrent Writes // in flight. // -// If SUBGROUP_DELIVERY_TIMEOUT is set and the transport reports -// acknowledgement ([DeliveryTrackingSendStream]), Close starts a background -// goroutine that resets the stream if the peer has not acknowledged all data -// within the timeout (§8: the timer runs until "all data committed"). +// If SUBGROUP_DELIVERY_TIMEOUT is set and the stream is a +// [DeliveryTrackingSendStream], Close starts a goroutine that resets the +// stream if the peer has not acknowledged all data within the timeout (§8). func (s *OutgoingSubgroupStream) Close() error { err := s.dst.Close() tracked, ok := s.dst.(DeliveryTrackingSendStream) diff --git a/pkg/moqt/session/fetch.go b/pkg/moqt/session/fetch.go index 966762ef..25bd0255 100644 --- a/pkg/moqt/session/fetch.go +++ b/pkg/moqt/session/fetch.go @@ -41,8 +41,7 @@ type FetchRequest struct { func (s *Session) Fetch(ctx context.Context, m *message.Fetch) (*FetchRequest, error) { return awaitRequestResponse(ctx, s, m, func(stream Stream, ok *message.FetchOK) (*FetchRequest, error) { - // §2.5.1: reject tracks with unknown mandatory track properties. - // "the subscriber MUST cancel the fetch" (§2.5.1). + // §2.5.1: "the subscriber MUST cancel the fetch". if err := s.validateTrackProperties(ok.TrackProperties, "FETCH_OK"); err != nil { cancelRequest(stream) return nil, err @@ -51,9 +50,8 @@ func (s *Session) Fetch(ctx context.Context, m *message.Fetch) (*FetchRequest, e cancelRequest(stream) return nil, s.closeProtocolViolation(err) } - // The responder may send neither REQUEST_UPDATE (it did not - // send the request) nor PUBLISH_STATE_NOTIFY (not a - // subscription): §10.9, §10.10. + // The responder may send neither REQUEST_UPDATE (§10.9) nor + // PUBLISH_STATE_NOTIFY (§10.10). return &FetchRequest{ Stream: stream, s: s, @@ -63,22 +61,16 @@ func (s *Session) Fetch(ctx context.Context, m *message.Fetch) (*FetchRequest, e }) } -// checkFetchOKEnd enforces §10.14: "If End Location is smaller than the Start -// Location in the corresponding FETCH the receiver MUST close the session with -// a PROTOCOL_VIOLATION." +// checkFetchOKEnd enforces §10.14: an End Location "smaller than the Start +// Location" is a PROTOCOL_VIOLATION. // -// An absolute Start compares directly. A Start relative to the Largest Object -// is not known here, but a FETCH without an End Location ends at the Largest -// Object (§5.1.2), and FETCH_OK's End never goes beyond it (§10.14). The Next -// Object ({Largest.Group, Largest.Object + 1}) and a relative StartGroup of 0 -// ({Largest.Group + 1, 0}) start past it, so any End precedes them, except an -// End of {0, 0}: with no content yet both Starts are {0, 0} as well, and the -// two cases look the same. A relative StartGroup of 1 or more starts at or -// before the Largest Object, which End cannot precede. +// A relative Start is not known here, but FETCH_OK's End never passes the +// Largest Object (§10.14). Next Object and a relative StartGroup of 0 start +// past it, so any End but {0, 0} (no content yet) precedes them; a larger +// relative StartGroup starts at or before it. func checkFetchOKEnd(m *message.Fetch, ok *message.FetchOK) error { - // m is our own FETCH, whose filter parses; without one the range starts - // at {0, 0}, which no End precedes. Parsed into a local value: this runs - // for every FETCH, and a *LocationFilter would be one more allocation. + // Without a filter the range starts at {0, 0}. Parsed into a local value + // to avoid an allocation per FETCH. p, found := m.Parameters.Find(message.ParamLocationFilter) if !found { return nil diff --git a/pkg/moqt/session/handshake.go b/pkg/moqt/session/handshake.go index bb0f8c28..b4f9f163 100644 --- a/pkg/moqt/session/handshake.go +++ b/pkg/moqt/session/handshake.go @@ -13,21 +13,14 @@ import ( "github.com/floatdrop/moq-go/pkg/moqt/wire" ) -// handshake performs the SETUP exchange (§3.3). Each side opens a -// unidirectional control stream and writes SETUP, then accepts the peer's -// stream and reads theirs. The two directions run in parallel under an -// errgroup whose derived context cancels the sibling when either side fails, -// and BOTH the SETUP write and the SETUP read are bridged to that context -// with context.AfterFunc → CancelWrite/CancelRead (the readResponse pattern): -// stream I/O is context-free, so without the bridge a peer that opens the -// control stream but stalls mid-SETUP (or stops granting flow-control -// credit) would block the handshake past ctx cancellation — wedging, for a -// relay, the per-conn handler goroutine that Stop must join. +// handshake performs the SETUP exchange (§3.3): each side writes SETUP on its +// own control stream and reads the peer's, in parallel under an errgroup. Both +// directions are bridged to the context with context.AfterFunc, so a peer +// that stalls mid-SETUP cannot block past cancellation. // -// Per §3.3, until SETUP is exchanged a peer may also open uni-streams for -// Objects or bidi-streams for requests. Data streams that arrive first are -// held for AcceptDataStream (see acceptControlStream); request streams wait in -// the transport until AcceptRequest. +// Data streams that arrive before the control stream are held for +// AcceptDataStream (see acceptControlStream); request streams wait in the +// transport until AcceptRequest. func (s *Session) handshake(ctx context.Context, options []wire.KVPair) error { g, gctx := errgroup.WithContext(ctx) @@ -103,23 +96,16 @@ func (s *Session) handshake(ctx context.Context, options []wire.KVPair) error { return nil } -// maxEarlyDataStreams caps how many data streams the handshake holds for -// [Session.AcceptDataStream] before the peer's control stream arrives; more -// are refused with EXCESSIVE_LOAD. It matches the relay's hold for streams -// that beat their Track Alias. +// maxEarlyDataStreams caps how many data streams the handshake holds before +// the peer's control stream arrives; more are refused with EXCESSIVE_LOAD. const maxEarlyDataStreams = 32 -// acceptControlStream returns the peer's control stream. §3.3: "Unidirectional -// streams containing Objects [...] could arrive prior to the control streams, -// in which case the data SHOULD be buffered until both control streams arrive -// and setup is complete." A uni stream that begins with a data stream type is -// held unread, its type bytes kept to be replayed, and handed out by -// AcceptDataStream once the session is up; up to maxEarlyDataStreams of them. -// A padding stream is discarded, and one reset before its type is skipped, as -// after setup; one FINed before its type fails the handshake. -// The first stream that does not is the control stream, and is returned with -// its leading bytes replayed, for the SETUP parse to judge. (Bidirectional -// request streams need nothing: nothing accepts them before setup completes.) +// acceptControlStream returns the peer's control stream. Data streams that +// arrive first are held, with their type bytes replayed, for AcceptDataStream +// (§3.3: "the data SHOULD be buffered"). A padding stream is discarded and one +// reset before its type is skipped; one FINed before its type fails the +// handshake. The first other stream is returned, with its leading bytes +// replayed, for the SETUP parse to judge. func (s *Session) acceptControlStream(ctx context.Context) (ReceiveStream, error) { for { stream, err := s.conn.AcceptUniStream(ctx) @@ -132,21 +118,17 @@ func (s *Session) acceptControlStream(ctx context.Context) (ReceiveStream, error stop() switch { case errors.Is(err, io.EOF), errors.Is(err, io.ErrUnexpectedEOF): - // FIN before a whole type: no valid stream of any kind; if it - // was the control stream, "Doing so results in the session - // being closed as a PROTOCOL_VIOLATION" (§3.3). + // FIN before a whole type: no valid stream of any kind (§3.3). return nil, fmt.Errorf("read stream type: %w", err) case err != nil: - // Reset before its type arrived — a data stream the peer - // abandoned: skipped, as acceptDataStream skips it after setup. + // Reset before its type: skipped, as after setup (§11.4.1). stream.CancelRead(uint64(moqt.StreamResetInternalError)) if ctx.Err() != nil { return nil, ctx.Err() } continue case typ == message.PaddingStreamType: - // §11.5.1: "The receiver MUST discard all data received on a - // padding stream to prevent exhausting flow control." + // §11.5.1. stream.CancelRead(uint64(moqt.StreamResetInternalError)) continue } diff --git a/pkg/moqt/session/internal/conntest/suite.go b/pkg/moqt/session/internal/conntest/suite.go index 8569b8f0..a881407c 100644 --- a/pkg/moqt/session/internal/conntest/suite.go +++ b/pkg/moqt/session/internal/conntest/suite.go @@ -53,8 +53,7 @@ type Suite struct { // - A send stream's Context ends once it is closed. It does not track // acknowledgement: quic-go cancels it when Close queues the FIN. // - CancelWrite unblocks the peer's Read rather than leaving it parked. -// - A send stream's Context also ends on the peer's STOP_SENDING, which is -// how the relay learns a requester cancelled after FINning (§3.3.2). +// - A send stream's Context also ends on the peer's STOP_SENDING (§3.3.3). // - OpenStream reports an exhausted peer limit as ErrNoStreamCredit. This // one is a documented MUST on the interface, and PUBLISH_SKIPPED (§10.21) // is built on it: the relay reacts to the sentinel instead of blocking. @@ -206,11 +205,8 @@ func RunSuite(t *testing.T, s Suite) { }) } -// runStopSendingSubtests pins that a send stream's Context also ends when the -// peer stops reading it (STOP_SENDING). MoQT cancels a request with -// STOP_SENDING (§3.3.3), and after a requester's FIN (§3.3.2: "not a request -// cancellation") that is the only way the responder learns of a later cancel, -// so the relay waits on exactly this signal. +// runStopSendingSubtests pins that a send stream's Context ends when the peer +// sends STOP_SENDING, the only cancel signal after a requester's FIN (§3.3.3). func runStopSendingSubtests(t *testing.T, s Suite) { t.Helper() for _, tc := range []struct { diff --git a/pkg/moqt/session/namespace.go b/pkg/moqt/session/namespace.go index a335a23f..68b37dbe 100644 --- a/pkg/moqt/session/namespace.go +++ b/pkg/moqt/session/namespace.go @@ -22,8 +22,7 @@ type NamespacePublication struct { OK *message.RequestOK } -// Close withdraws the namespace. §6.2: a PUBLISH_NAMESPACE "is withdrawn by -// cancelling the request" — a FIN alone would not (§3.3.2). +// Close withdraws the namespace by cancelling the request (§6.2, §3.3.3). func (p *NamespacePublication) Close() error { cancelRequest(p.Stream) return nil @@ -31,10 +30,9 @@ func (p *NamespacePublication) Close() error { // NamespaceSubscription is an established SUBSCRIBE_NAMESPACE request (§10.19). // It embeds the still-open request stream and carries the peer's REQUEST_OK; -// NAMESPACE / NAMESPACE_DONE notifications arrive by reading the embedded -// stream. Read it with [Session.NewRequestBroker] and [RequestBroker.Serve], -// which enforce the session-level rules on what arrives (§10, §10.2.1); a -// caller that reads it with message.Parse must apply them itself. +// NAMESPACE / NAMESPACE_DONE notifications arrive on the embedded stream. +// Read it with [RequestBroker.Serve], which enforces the session-level rules +// (§10, §10.2.1); a caller using message.Parse must apply them itself. type NamespaceSubscription struct { // Stream is the SUBSCRIBE_NAMESPACE request stream, still open to receive // NAMESPACE / NAMESPACE_DONE notifications. [NamespaceSubscription.Close] @@ -45,9 +43,7 @@ type NamespaceSubscription struct { OK *message.RequestOK } -// Close ends the subscription. §6.1: "A SUBSCRIBE_NAMESPACE or SUBSCRIBE_TRACKS -// is cancelled as described in Section 3.3.3, by resetting or sending -// STOP_SENDING on the stream"; a FIN alone would not (§3.3.2). +// Close ends the subscription by cancelling the request (§6.1, §3.3.3). func (n *NamespaceSubscription) Close() error { cancelRequest(n.Stream) return nil @@ -69,8 +65,7 @@ type TrackSubscription struct { s *Session } -// Close ends the subscription by cancelling the request (§6.1, §3.3.3); a FIN -// alone would not (§3.3.2). +// Close ends the subscription by cancelling the request (§6.1, §3.3.3). func (t *TrackSubscription) Close() error { cancelRequest(t.Stream) return nil @@ -127,10 +122,9 @@ func (s *Session) SubscribeTracks(ctx context.Context, m *message.SubscribeTrack // IncomingNamespacePublication is an accepted inbound PUBLISH_NAMESPACE (§10.16) // — the receiving side of [Session.PublishNamespace]'s [NamespacePublication], // returned by [Request.AcceptPublishNamespace]. REQUEST_OK has been sent; the -// announcer's follow-ups arrive by reading the embedded stream, best with -// [Session.NewRequestBroker] and [RequestBroker.Serve], which enforce the -// session-level rules on what arrives (§10, §10.2.1). Close it to end the -// publication. +// announcer's follow-ups arrive on the embedded stream; read it with +// [RequestBroker.Serve], which enforces the session-level rules (§10, +// §10.2.1). Close it to end the publication. type IncomingNamespacePublication struct { // Stream is the PUBLISH_NAMESPACE request stream, still open to receive // NAMESPACE / NAMESPACE_DONE notifications. Close it to end the publication. diff --git a/pkg/moqt/session/options.go b/pkg/moqt/session/options.go index fd282b5e..5edb3e92 100644 --- a/pkg/moqt/session/options.go +++ b/pkg/moqt/session/options.go @@ -38,8 +38,7 @@ type config struct { // prohibits Range Filters entirely. maxFilterRanges uint64 - // setupTokens are the tokens WithSetupToken added to SETUP, in order, for - // the checks before the handshake and the purge after it (§10.3.1.4). + // setupTokens are the tokens WithSetupToken added to SETUP, in order. setupTokens []message.Token // tokenVerifier is the optional application policy that turns a resolved @@ -49,17 +48,12 @@ type config struct { tokenVerifier TokenVerifier } -// WithSetupToken adds an AUTHORIZATION TOKEN setup option (§10.3.1.4): a -// token "that the peer can use to authorize MOQT session establishment". -// Repeat it for several tokens; they are sent in order. +// WithSetupToken adds an AUTHORIZATION TOKEN setup option (§10.3.1.4). Repeat +// it for several tokens; they are sent in order. // -// Only REGISTER and USE_VALUE make sense in SETUP: a server receiving DELETE -// or USE_ALIAS "MUST close the session with a PROTOCOL_VIOLATION" (§10.2.2), -// and a repeated REGISTER alias would close it with -// DUPLICATE_AUTH_TOKEN_ALIAS, so opening the session fails on any of those. -// A REGISTER that does not fit the peer's MAX_AUTH_TOKEN_CACHE_SIZE is used -// once by the peer and not held; [Session.SetupTokenAliases] reports the -// aliases the peer does hold. +// Opening the session fails on DELETE, USE_ALIAS or a repeated REGISTER alias +// (§10.2.2). [Session.SetupTokenAliases] reports which REGISTERed aliases fit +// the peer's cache. func WithSetupToken(t message.Token) Option { return func(c *config) { c.setupTokens = append(c.setupTokens, t) @@ -169,15 +163,12 @@ func WithGrease() Option { // Property types (range 0x4000–0x7FFF per §2.5.1) that this endpoint // understands. When the session receives Track Properties (in SUBSCRIBE_OK, // FETCH_OK, or TRACK_STATUS_OK) containing a mandatory property not in this -// set, it returns *ErrUnsupportedMandatoryTrackProperty; an inbound PUBLISH -// carrying one is refused by [Request.AcceptPublish] with REQUEST_ERROR -// UNSUPPORTED_EXTENSION (§2.5.1). +// set, it returns *ErrUnsupportedMandatoryTrackProperty; [Request.AcceptPublish] +// refuses such a PUBLISH with UNSUPPORTED_EXTENSION. // -// If this option is never called, mandatory track property enforcement is -// disabled and all properties pass through without inspection. §2.5.1 says an -// endpoint that does not understand a Mandatory Track Property MUST NOT -// process or forward the track, so leave it unset only when the application -// checks the properties itself. pkg/relay always sets it from its Config. +// If this option is never called, enforcement is disabled and all properties +// pass through. Leave it unset only when the application checks the +// properties itself (§2.5.1). // // End subscribers that interpret track data should call this option to opt // in to enforcement. Pass an empty (non-nil) map to reject all mandatory diff --git a/pkg/moqt/session/publish.go b/pkg/moqt/session/publish.go index 351b8303..d197d026 100644 --- a/pkg/moqt/session/publish.go +++ b/pkg/moqt/session/publish.go @@ -21,18 +21,12 @@ import ( // (answering an inbound SUBSCRIBE) — in both cases this endpoint is the one // sending objects. type Publication struct { - // requestHandle carries the request stream — still open for follow-up - // traffic: PUBLISH_DONE, REQUEST_UPDATE, etc. — and provides Update, - // Broker and Close. [Publication.Done] ends the publication gracefully - // (PUBLISH_DONE, then FIN); Close cancels it. Serving subscriber - // REQUEST_UPDATEs on a long-lived publication is what - // [requestHandle.Broker] + [RequestBroker.Serve] are for. + // requestHandle carries the request stream. [Publication.Done] ends the + // publication gracefully (PUBLISH_DONE, then FIN); Close cancels it. // - // §10.9 permits REQUEST_UPDATE only from the request's sender, plus - // the subscriber of a PUBLISH-established subscription — so Update is - // valid on a Publication from [Session.Publish] (this side sent the - // PUBLISH) but NOT on one from [Request.AcceptSubscribe], where this - // side is the publisher answering the peer's SUBSCRIBE. + // §10.9: Update is valid on a Publication from [Session.Publish] but not + // on one from [Request.AcceptSubscribe], where this side did not send + // the request. requestHandle alias uint64 @@ -41,30 +35,26 @@ type Publication struct { // the §10.12 Stream Count when Done sends PUBLISH_DONE. subgroupCount atomic.Uint64 - // paused is the inverse of the §5.1 Forward State: "The publisher does - // not send Objects if the Forward State is 0". Set from the establishing - // messages and from accepted REQUEST_UPDATEs. + // paused is the inverse of the §5.1 Forward State. paused atomic.Bool - // largest is the largest Location written through this publication's - // subgroup streams, reported as LARGEST_OBJECT in REQUEST_UPDATE_OK - // (§10.9.1). Guarded by largestMu. + // largest is the largest Location written through OpenSubgroup streams, + // reported as LARGEST_OBJECT in REQUEST_UPDATE_OK (§10.9.1). largestMu sync.Mutex largest message.Location hasLargest bool - // ended is latched by the first Done, including the one a declined - // REQUEST_UPDATE triggers: no PUBLISH_DONE twice, no stream after it. + // ended is latched by the first Done, so PUBLISH_DONE is sent once and no + // subgroup opens after it. ended atomic.Bool brokerInit sync.Once } -// ErrForwardPaused is returned while the subscription's Forward State is 0: -// "The publisher does not send Objects if the Forward State is 0" (§5.1). -// [Publication.OpenSubgroup] opens nothing; a write to a subgroup already open -// resets that stream first (§11.4.3: "Omitting a Subgroup Object due to the -// subscriber's Forward State"). A REQUEST_UPDATE with FORWARD=1 resumes. +// ErrForwardPaused is returned while the subscription's Forward State is 0 +// (§5.1): [Publication.OpenSubgroup] opens nothing, and a write to an open +// subgroup resets that stream first (§11.4.3). A REQUEST_UPDATE with FORWARD=1 +// resumes. var ErrForwardPaused = errors.New("moqt/session: Forward State is 0; not sending objects") // ErrPublicationEnded is returned by [Publication.OpenSubgroup] once the @@ -73,13 +63,10 @@ var ErrForwardPaused = errors.New("moqt/session: Forward State is 0; not sending var ErrPublicationEnded = errors.New("moqt/session: publication ended (PUBLISH_DONE sent)") // newPublication builds a Publication whose initial Forward State is the -// establishing message's FORWARD — "The initiator of the subscription sets the -// initial Forward State in either PUBLISH or SUBSCRIBE" (§5.1) — or 1 when it -// is omitted (§10.2.18). +// establishing message's FORWARD (§5.1), or 1 when omitted (§10.2.18). func newPublication(s *Session, stream Stream, requestID, alias uint64, establishing message.Parameters) *Publication { - // The subscriber may send REQUEST_UPDATE — as the SUBSCRIBE's sender, or - // as the subscriber of a PUBLISH (§10.9) — but not PUBLISH_STATE_NOTIFY, - // which "is sent only by the publisher" (§10.10). + // The subscriber may send REQUEST_UPDATE (§10.9) but not + // PUBLISH_STATE_NOTIFY (§10.10). p := &Publication{ Stream: stream, s: s, requestID: requestID, alias: alias, peerUpdate: true, updateScope: message.ScopeUpdateFromSubscriber, @@ -91,11 +78,9 @@ func newPublication(s *Session, stream Stream, requestID, alias uint64, establis } // Broker returns the publication's [RequestBroker] (see [requestHandle.Broker]) -// with its REQUEST_UPDATE handling installed on first call: [Publication.ApplyUpdate] -// decides each update, and a declined one ends the subscription with -// PUBLISH_DONE UPDATE_FAILED, as §10.9.1 requires ("the publisher MUST also -// terminate the subscription"). Replace the handling with -// [RequestBroker.HandleUpdates] to support more parameters. +// with [Publication.ApplyUpdate] installed to decide REQUEST_UPDATEs. A +// declined update ends the subscription with PUBLISH_DONE UPDATE_FAILED +// (§10.9.1). Use [RequestBroker.HandleUpdates] to support more parameters. func (p *Publication) Broker() *RequestBroker { b := p.requestHandle.Broker() p.brokerInit.Do(func() { @@ -108,30 +93,19 @@ func (p *Publication) Broker() *RequestBroker { } // ApplyUpdate is the publication's built-in REQUEST_UPDATE handling (§10.9). -// It accepts an update only when it understands every parameter in it, and -// then applies all of them: -// - FORWARD (§10.2.18) sets the Forward State; [Publication.OpenSubgroup] -// returns [ErrForwardPaused] while it is 0. -// - SUBSCRIBER_PRIORITY (§10.2.7) is accepted; per-stream priority is the -// application's to apply. -// - NEW_GROUP_REQUEST (§10.2.19) is accepted. A publisher that advertises -// DYNAMIC_GROUPS should install its own handler to act on it. -// - AUTHORIZATION_TOKEN (§10.2.2) was already processed by the session. +// FORWARD (§10.2.18) sets the Forward State; SUBSCRIBER_PRIORITY, +// NEW_GROUP_REQUEST and AUTHORIZATION_TOKEN are accepted without action (the +// Serve callback still sees the update). Any other parameter declines the +// whole update with NOT_SUPPORTED. // -// The Serve callback still sees every update, so an application can act on -// SUBSCRIBER_PRIORITY or NEW_GROUP_REQUEST there. -// -// Any other parameter declines the whole update with NOT_SUPPORTED, applying -// none of it. The REQUEST_UPDATE_OK carries LARGEST_OBJECT once objects have -// been published (§10.9.1, §10.2.17) — counting objects written through -// [Publication.OpenSubgroup] streams only; datagrams and subgroups opened on -// the Session directly are not seen. +// The REQUEST_UPDATE_OK carries LARGEST_OBJECT (§10.9.1) once objects have +// been written through [Publication.OpenSubgroup] streams; other objects are +// not seen. func (p *Publication) ApplyUpdate(upd *message.RequestUpdate) (*message.RequestOK, error) { forward, setForward := false, false for _, prm := range upd.Parameters { if prm.Type == message.ParamForward { - // §10.2.18: a value other than 0 or 1 "MUST close the session - // with PROTOCOL_VIOLATION". + // §10.2.18. if prm.Byte > 1 { return nil, p.s.closeProtocolViolation( fmt.Errorf("moqt/session: FORWARD value %d in REQUEST_UPDATE", prm.Byte)) @@ -214,8 +188,7 @@ func (p *Publication) OpenSubgroup(h message.SubgroupHeader) (*OutgoingSubgroupS // [Session.OpenSubgroup] directly are not counted — send PUBLISH_DONE yourself // via message.Marshal if you need a different count). // -// Only the first call sends: a later one, e.g. after a declined REQUEST_UPDATE -// already ended the publication (§10.9.1), returns nil. +// Only the first call sends; later ones return nil. func (p *Publication) Done(code moqt.PublishDoneCode, reason string) error { if !p.ended.CompareAndSwap(false, true) { return nil @@ -272,8 +245,8 @@ func (s *Session) Publish(ctx context.Context, m *message.Publish) (*Publication } return awaitRequestResponse(ctx, s, m, func(stream Stream, _ *message.RequestOK) (*Publication, error) { - // The PUBLISH sets the initial Forward State (§5.1); draft-20 - // carries no subscription parameters in PUBLISH_OK (#1790). + // The PUBLISH sets the initial Forward State (§5.1); PUBLISH_OK + // carries no subscription parameters. return newPublication(s, stream, m.RequestID, m.TrackAlias, m.Parameters), nil }) } @@ -296,11 +269,10 @@ func (s *Session) OpenPublish(m *message.Publish) (Stream, error) { return s.openAllocRequest(m) } -// AwaitPublishOK reads the peer's response to a PUBLISH sent with -// [Session.OpenPublish], applying the checks [Session.Publish] does: a -// PUBLISH_OK carrying Track Properties (§10.5) or out-of-scope parameters -// (§10.2.1) closes the session, and REQUEST_ERROR is returned as a -// *RequestRejectedError. The stream stays open either way. +// AwaitPublishOK reads the response to a PUBLISH sent with +// [Session.OpenPublish], with the checks [Session.Publish] applies (§10.5, +// §10.2.1). REQUEST_ERROR is returned as a *RequestRejectedError. The stream +// stays open either way. func (s *Session) AwaitPublishOK(ctx context.Context, stream Stream) (*message.RequestOK, error) { resp, err := s.readResponse(ctx, stream) if err != nil { diff --git a/pkg/moqt/session/request.go b/pkg/moqt/session/request.go index d472a269..35aef8e7 100644 --- a/pkg/moqt/session/request.go +++ b/pkg/moqt/session/request.go @@ -55,19 +55,16 @@ func (e *ErrDuplicateRequestID) Error() string { } // ErrUnexpectedRequestUpdate is returned by AcceptRequest when a peer opens a -// request stream whose first message is a REQUEST_UPDATE. §10.9 permits -// REQUEST_UPDATE only as a follow-up on an existing request stream (or against -// a PUBLISH-established subscription); a REQUEST_UPDATE in any other position -// is a PROTOCOL_VIOLATION. AcceptRequest has already closed the session with -// SessionProtocolViolation. +// request stream with REQUEST_UPDATE, which §10.9 allows only as a follow-up. +// AcceptRequest has already closed the session with PROTOCOL_VIOLATION. type ErrUnexpectedRequestUpdate struct { RequestID uint64 } // ErrUnexpectedPublishStateNotify is returned by AcceptRequest when a peer -// opens a request stream with PUBLISH_STATE_NOTIFY. §10.10 admits it only as a -// publisher's unilateral notification on a subscription's existing stream, so -// this is a PROTOCOL_VIOLATION; AcceptRequest has already closed the session. +// opens a request stream with PUBLISH_STATE_NOTIFY, which §10.10 allows only on +// an existing subscription's stream. AcceptRequest has already closed the +// session with PROTOCOL_VIOLATION. var ErrUnexpectedPublishStateNotify = errors.New( "moqt/session: PUBLISH_STATE_NOTIFY as the first message of a request stream — PROTOCOL_VIOLATION") @@ -79,12 +76,9 @@ func (e *ErrUnexpectedRequestUpdate) Error() string { } // ErrUnexpectedRequestOpener is returned by AcceptRequest when a peer opens a -// request stream with anything but the seven request messages — a response or -// follow-up such as SUBSCRIBE_OK, PUBLISH_DONE or GOAWAY, or an unknown type. -// §3.3: "Bidirectional streams MUST NOT begin with any other message type -// unless negotiated. If they do, the peer MUST close the Session with a -// PROTOCOL_VIOLATION." AcceptRequest has already closed the session. -// (REQUEST_UPDATE and PUBLISH_STATE_NOTIFY keep their own error values.) +// request stream with a message that is not a request opener (§3.3), including +// an unknown type. AcceptRequest has already closed the session with +// PROTOCOL_VIOLATION. type ErrUnexpectedRequestOpener struct { Type message.Type } @@ -165,16 +159,13 @@ func (l *RequestUpdateLimiter) Responded() { l.outstanding-- } -// RequestRejectedError is returned by Publish / Subscribe when the peer -// answers a request with REQUEST_ERROR (§10.6). Callers can detect it via -// errors.AsType and inspect Code / Reason. +// RequestRejectedError is returned by a request opener when the peer answers +// with REQUEST_ERROR (§10.6). type RequestRejectedError struct { Code moqt.RequestErrorCode Reason string - // RetryInterval is the REQUEST_ERROR's Retry Interval as sent (§10.6.2): - // 0 means the request SHOULD NOT be retried, N means it SHOULD NOT be - // sent again for N-1 milliseconds. [RequestRejectedError.RetryAfter] - // decodes it. + // RetryInterval is the raw Retry Interval (§10.6.2); see + // [RequestRejectedError.RetryAfter]. RetryInterval uint64 } @@ -228,8 +219,8 @@ type Request struct { // helpers to allocate Track Aliases and register inbound aliases. s *Session - // okSent records that a REQUEST_OK has gone out through Reply or an - // Accept helper, so a later one answers a REQUEST_UPDATE (§10.5). + // okSent records that Reply has sent a REQUEST_OK, so a later one answers + // a REQUEST_UPDATE (§10.5). okSent atomic.Bool } @@ -244,15 +235,12 @@ type Request struct { // requests for session-level tracks and namespaces". AcceptRequest loops until // it has an application-visible request to return. // -// A stream opened by anything but the seven request messages (§3.3) — an -// unknown type, a response, REQUEST_UPDATE or PUBLISH_STATE_NOTIFY — makes -// AcceptRequest close the session with PROTOCOL_VIOLATION itself and return -// *ErrUnexpectedRequestOpener, *ErrUnexpectedRequestUpdate or -// ErrUnexpectedPublishStateNotify. A first message whose body does not match -// its Length or fails validation also closes the session with -// PROTOCOL_VIOLATION (§10; the error wraps [message.ErrMalformedMessage]). -// A stream that ends or is reset before its first message is complete only -// resets that stream. +// A stream not opened by a request message (§3.3), or whose first message is +// malformed (§10, wrapping [message.ErrMalformedMessage]), closes the session +// with PROTOCOL_VIOLATION; the error is *ErrUnexpectedRequestOpener, +// *ErrUnexpectedRequestUpdate, ErrUnexpectedPublishStateNotify or the parse +// error. A stream that ends before its first message is complete only resets +// that stream. func (s *Session) AcceptRequest(ctx context.Context) (*Request, error) { for { stream, err := s.conn.AcceptStream(ctx) @@ -268,39 +256,29 @@ func (s *Session) AcceptRequest(ctx context.Context) (*Request, error) { if ctx.Err() != nil { return nil, ctx.Err() } - // §3.3: an unknown type cannot be one of the seven openers. - // readResponse has already closed the session (§10); this only - // shapes the error returned. + // §3.3: readResponse already closed the session; this only shapes + // the error. if typ, ok := errors.AsType[message.ErrUnknownType](err); ok { return nil, s.closeProtocolViolation(&ErrUnexpectedRequestOpener{Type: message.Type(typ)}) } return nil, fmt.Errorf("moqt/session: parse request first message: %w", err) } - // §10.9: REQUEST_UPDATE is valid only as a follow-up on an existing - // request stream (or against a PUBLISH-established subscription), never - // as the message that opens a stream. Receiving one here is a - // PROTOCOL_VIOLATION (§3.3), and the session is closed with it. + // §10.9, §3.3: REQUEST_UPDATE never opens a stream. if upd, ok := msg.(*message.RequestUpdate); ok { resetStream(stream) return nil, s.closeProtocolViolation(&ErrUnexpectedRequestUpdate{RequestID: upd.RequestID}) } - // §10.10: PUBLISH_STATE_NOTIFY is a unilateral publisher-to-subscriber - // notification on an existing subscription's stream. "An endpoint that - // receives a PUBLISH_STATE_NOTIFY for any other request type, or from the - // subscriber, MUST close the session with a PROTOCOL_VIOLATION" — opening - // a stream with one is both. It carries no Request ID, so the §10.1 - // accounting below would not catch it either. + // §10.10: PUBLISH_STATE_NOTIFY never opens a stream. It carries no + // Request ID, so the §10.1 check below would not catch it. if _, ok := msg.(*message.PublishStateNotify); ok { resetStream(stream) return nil, s.closeProtocolViolation(ErrUnexpectedPublishStateNotify) } - // §3.3: a request stream begins with one of seven message types; - // "Bidirectional streams MUST NOT begin with any other message type - // unless negotiated. If they do, the peer MUST close the Session with - // a PROTOCOL_VIOLATION." + // §3.3: "Bidirectional streams MUST NOT begin with any other message + // type unless negotiated." if !isRequestOpener(msg) { resetStream(stream) return nil, s.closeProtocolViolation(&ErrUnexpectedRequestOpener{Type: msg.Type()}) @@ -313,8 +291,7 @@ func (s *Session) AcceptRequest(ctx context.Context) (*Request, error) { return nil, err } - // §10.1 parity + duplicate enforcement, shared with the follow-up - // REQUEST_UPDATE path — see [Session.CheckPeerRequestID]. + // §10.1 parity and duplicate check. if m, ok := msg.(message.WithRequestID); ok { if err := s.CheckPeerRequestID(m.GetRequestID()); err != nil { resetStream(stream) @@ -322,23 +299,18 @@ func (s *Session) AcceptRequest(ctx context.Context) (*Request, error) { } } - // §10.2.2: process any AUTHORIZATION_TOKEN parameters now, before the - // request is dispatched/validated/authorized. REGISTER tokens are - // committed to the inbound cache here so the alias persists even if the - // request is later rejected for an unrelated reason (a §10.2.2 MUST). - // A cache-layer failure is a session-level fault carried by - // *TokenCacheError; the caller MUST close the session with its Code. + // §10.2.2: REGISTER tokens commit before any rejection, so the alias + // persists even if the request fails. A *TokenCacheError is + // session-fatal; the caller closes the session with its Code. tokens, err := s.processRequestTokens(msg) if err != nil { resetStream(stream) return nil, err } - // §3.2.1 / §3.2.2: a request for a reserved namespace the - // implementation owns is rejected with DOES_NOT_EXIST here, after - // token processing (so REGISTER tokens still commit), without ever - // surfacing to the application. Other reserved ("."-prefixed) - // namespaces fall through to the application per §3.2.1. + // §3.2.1 / §3.2.2: reserved namespaces the implementation owns are + // rejected here, after token processing; other "."-prefixed ones reach + // the application. if reason, reject := reservedNamespaceRejection(msg); reject { rejectStreamWithError(stream, moqt.RequestDoesNotExist, reason) continue @@ -466,16 +438,12 @@ func rejectStreamWithError(stream Stream, code moqt.RequestErrorCode, reason str _ = (&Request{Stream: stream}).RejectError(code, reason) } -// requestHandle is the state every requester-side typed handle embeds: the -// still-open bidi request stream (close it to end the request), the owning -// session, and the §10.1 Request ID of the request the stream carries (used -// where a follow-on message must reference the original request, e.g. the -// FETCH_HEADER a FetchResponder opens). Embedding it provides the shared -// Update and Broker methods. +// requestHandle is the state every typed request handle embeds: the open +// request stream, the owning session and the request's §10.1 Request ID. It +// provides the shared Close, Update and Broker methods. type requestHandle struct { - // Stream is the request stream, still open for follow-up traffic. - // [requestHandle.Close] ends the request; Stream.Close only FINs this - // side, which does not cancel it (§3.3.2). + // Stream is the request stream. [requestHandle.Close] cancels the + // request; Stream.Close only FINs this side (§3.3.2). Stream s *Session @@ -484,29 +452,22 @@ type requestHandle struct { brokerOnce sync.Once broker atomic.Pointer[RequestBroker] - // finished records that writeThenClose delivered this side's final - // message and FIN, so Close must not reset what it sent. + // finished records that writeThenClose sent this side's final message + // and FIN, so Close must not reset it. finished atomic.Bool - // peerUpdate / peerNotify are the follow-ups the peer may send on this - // stream (§10.9 / §10.10), and updateScope the §10.2.1 scope of the - // peer's REQUEST_UPDATEs, applied to the broker on creation. + // Follow-ups the peer may send (§10.9 / §10.10) and the §10.2.1 scope of + // its REQUEST_UPDATEs; applied to the broker on creation. peerUpdate, peerNotify bool updateScope message.ParamScope } -// Close ends the request by cancelling it (§3.3.3): "abruptly terminating any -// directions of the stream that are still open, using RESET_STREAM for a -// direction they are sending and STOP_SENDING for a direction they are -// receiving". A FIN alone would not do — "it is not a request cancellation" -// (§3.3.2). If this side already finished sending (e.g. [Publication.Done] -// wrote PUBLISH_DONE and FIN), only reading is stopped, so the final message -// is not lost. +// Close cancels the request by resetting both stream directions (§3.3.3). If +// this side already sent its final message and FIN (e.g. [Publication.Done]), +// only reading is stopped, so that message is not lost. // -// Close does not track whether the peer already completed the request. A -// Close after that (e.g. a deferred one after PUBLISH_DONE arrived) resets -// where §3.3.2 says the requester SHOULD FIN; the request is over either way. -// Use Stream.Close to FIN instead. +// Close does not know whether the peer already completed the request; after +// that, §3.3.2 says the requester SHOULD FIN, so use Stream.Close instead. func (h *requestHandle) Close() error { if h.finished.Load() { h.Stream.CancelRead(uint64(moqt.StreamResetCancelled)) @@ -523,11 +484,9 @@ func cancelRequest(s Stream) { } // Broker returns this request's [RequestBroker], creating it on first call. -// Use it when the request outlives its initial response and follow-up -// traffic must coexist with updates: run [RequestBroker.Serve] to own the -// stream's reads, and route writes through the broker. Once created, the -// handle's own Update (and terminal writes like [Publication.Done]) go -// through the broker automatically, so they stay safe alongside Serve. +// Run [RequestBroker.Serve] to own the stream's reads when follow-up traffic +// must coexist with updates. Once created, the handle's Update and terminal +// writes like [Publication.Done] go through the broker. func (h *requestHandle) Broker() *RequestBroker { h.brokerOnce.Do(func() { b := h.s.NewRequestBroker(h.Stream) @@ -538,15 +497,11 @@ func (h *requestHandle) Broker() *RequestBroker { return h.broker.Load() } -// Update sends a REQUEST_UPDATE (§10.9) on the request stream and awaits the -// single REQUEST_OK / REQUEST_ERROR the spec mandates. params carries only -// the fields to change; any parameter omitted keeps its prior value on the -// peer. +// Update sends a REQUEST_UPDATE (§10.9) and awaits its REQUEST_OK or +// REQUEST_ERROR. params carries only the fields to change. // -// With no [requestHandle.Broker] attached this is [Session.UpdateRequest] — -// it reads the response directly, so it must be the stream's only reader. -// With a broker attached it delegates to [RequestBroker.Update], whose -// response arrives via the broker's Serve loop. +// Without a [requestHandle.Broker] this is [Session.UpdateRequest] and must be +// the stream's only reader; with one it delegates to [RequestBroker.Update]. func (h *requestHandle) Update(ctx context.Context, params message.Parameters) (*message.RequestOK, error) { if b := h.broker.Load(); b != nil { return b.Update(ctx, params) @@ -554,9 +509,8 @@ func (h *requestHandle) Update(ctx context.Context, params message.Parameters) ( return h.s.UpdateRequest(ctx, h.Stream, params) } -// writeThenClose writes msg and FINs the send side, routing through the -// attached broker's write lock when one exists — the shared backend of -// terminal handle methods like [Publication.Done]. +// writeThenClose writes msg and FINs the send side, through the broker's +// write lock when one exists. func (h *requestHandle) writeThenClose(msg message.Message) error { if b := h.broker.Load(); b != nil { if err := b.writeThenClose(msg); err != nil { @@ -575,13 +529,8 @@ func (h *requestHandle) writeThenClose(msg message.Message) error { return nil } -// openRequest opens a new outbound bidirectional stream and writes first as -// its initial message. The returned Stream can be used to read responses -// (typically a single REQUEST_OK / REQUEST_ERROR / SUBSCRIBE_OK first, then -// optionally more) and to send follow-up messages such as REQUEST_UPDATE. -// -// On any error before the stream is established and the first message -// written, the stream (if any) is reset and the error is returned. +// openRequest opens a bidi stream and writes first as its initial message. On +// a write error the stream is reset. func (s *Session) openRequest(first message.Message) (Stream, error) { stream, err := s.conn.OpenStream() if err != nil { @@ -590,14 +539,9 @@ func (s *Session) openRequest(first message.Message) (Stream, error) { return writeFirst(stream, first) } -// openAllocRequest opens a request stream for m and assigns m a freshly -// allocated Request ID (§10.1) only after the open succeeds — so a failed open -// (e.g. ErrNoStreamCredit) consumes no ID and the §10.1 sequence stays -// untouched — then writes it as the stream's first message. It does NOT await -// the peer's response — the caller owns the read side. It is the single -// primitive beneath every typed request opener (Publish, Subscribe, Fetch, -// TrackStatus, the namespace requests) and the non-blocking -// [Session.OpenPublish] used for relay fan-out. +// openAllocRequest opens a request stream and writes m as its first message. +// m's Request ID (§10.1) is allocated only after the open succeeds, so a +// failed open consumes no ID. It does not await the response. func (s *Session) openAllocRequest(m message.WithRequestID) (Stream, error) { stream, err := s.conn.OpenStream() if err != nil { @@ -617,24 +561,13 @@ func writeFirst(stream Stream, first message.Message) (Stream, error) { return stream, nil } -// readResponse parses one message from stream, honoring ctx. message.Parse -// reads from a context-free io.Reader, so cancellation is bridged by resetting -// the stream's read side with StreamResetCancelled (§3.3.4), which unblocks the -// in-flight Parse. -// -// The bridge is a context.AfterFunc hook rather than a watcher goroutine: it -// fires (in its own goroutine) only if ctx is actually cancelled, so the common -// case — the response arrives first — runs no extra goroutine at all, and the -// deferred stop() removes the hook. When ctx fired, ctx.Err() is returned in -// place of the resulting wire error so the caller sees context.Canceled / -// context.DeadlineExceeded. +// readResponse parses one message from stream, honoring ctx by resetting the +// read side with StreamResetCancelled when ctx is done; it then returns +// ctx.Err(). A malformed message closes the session with PROTOCOL_VIOLATION. // -// Known teardown-only race: a cancellation landing after a successful Parse -// but before stop() detaches the hook fires a stale CancelRead — the caller -// receives (msg, nil) on a stream whose read side was just reset. Every -// caller's ctx is a session/relay-lifetime context, so the poisoned handle -// only occurs mid-shutdown, where the very next read surfacing a reset is -// acceptable. +// A cancellation landing between a successful Parse and stop() still resets +// the read side; callers' contexts are session-lifetime, so this only happens +// during shutdown. func (s *Session) readResponse(ctx context.Context, stream Stream) (message.Message, error) { stop := context.AfterFunc(ctx, func() { stream.CancelRead(uint64(moqt.StreamResetCancelled)) @@ -644,31 +577,17 @@ func (s *Session) readResponse(ctx context.Context, stream Stream) (message.Mess if err != nil && ctx.Err() != nil { return nil, ctx.Err() } - // §10: an unknown type or a body that does not match its Length closes - // the session; so does an unknown parameter (§10.2), which fails the body. + // §10, §10.2: unknown type, bad Length or unknown parameter. if errors.Is(err, message.ErrMalformedMessage) { return nil, s.closeProtocolViolation(err) } return msg, err } -// awaitRequestResponse opens a request stream for m (allocating its Request ID -// only after the open succeeds — see [Session.openAllocRequest]), awaits the -// peer's initial response, and dispatches it: -// -// - the expected success type OK is handed to onOK, which owns the still-open -// stream from that point: it wraps the stream in the typed handle, or closes -// it and returns an error (e.g. on Track Property validation failure); -// - REQUEST_ERROR (§10.6) is surfaced as a *RequestRejectedError and the -// stream is closed; -// - any other message is an unexpected-response error and the stream is closed. -// -// Error messages name the operation via m.Type() (e.g. "SUBSCRIBE"). It is the -// single primitive beneath [Session.Publish], [Session.Subscribe], -// [Session.Fetch], [Session.TrackStatus], and the three namespace request -// openers, which share this §10.1 open / await-OK skeleton and differ only in -// OK type and success handling (Publish additionally pre-allocates its Track -// Alias before the open). +// awaitRequestResponse opens a request stream for m and awaits the initial +// response. An OK is handed to onOK, which then owns the stream; REQUEST_ERROR +// (§10.6) becomes a *RequestRejectedError; anything else is an error. On +// either failure the stream is closed. func awaitRequestResponse[OK message.Message, R any]( ctx context.Context, s *Session, @@ -695,9 +614,7 @@ func awaitRequestResponse[OK message.Message, R any]( return r, err } // §10.2.1, checked after onOK: for a SUBSCRIBE it registers the Track - // Alias, and the publisher may already be sending on it, so nothing - // may delay that (#85). A violation closes the session, handle and - // all. + // Alias the publisher may already be sending on, so nothing may delay it. if err := s.CheckPeerParams(message.ScopeOfResponse(m.Type()), resp); err != nil { return zero, err } @@ -714,27 +631,14 @@ func awaitRequestResponse[OK message.Message, R any]( return zero, fmt.Errorf("moqt/session: unexpected %s in %s response", resp.Type(), m.Type()) } -// UpdateRequest sends a REQUEST_UPDATE (§10.9) on an already-established -// request stream and awaits the single REQUEST_OK / REQUEST_ERROR the spec -// mandates in response. The update rides the original bidi stream — the -// stream, not the ID, names the request being modified — but per §10.1 the -// REQUEST_UPDATE itself consumes a fresh Request ID from this endpoint's -// space, which the session allocates here (a reused ID is a duplicate the -// peer must treat as session-fatal). params carries only the fields the -// caller wants to change; any parameter omitted keeps its prior value on the -// peer (§10.9). -// -// On REQUEST_OK the parsed message is returned and the stream is left open -// for further traffic. REQUEST_ERROR is surfaced as a *RequestRejectedError; -// the stream is left open so the caller can decide how to tear down (a failed -// subscription update is followed by PUBLISH_DONE from the publisher, §10.9). +// UpdateRequest sends a REQUEST_UPDATE (§10.9) with a fresh Request ID (§10.1) +// on an established request stream and awaits its REQUEST_OK or REQUEST_ERROR +// (*RequestRejectedError). params carries only the fields to change. The +// stream is left open either way. // -// UpdateRequest reads the response directly off the stream, so it MUST NOT -// run concurrently with any other reader of the same stream ([DrainAndWait], -// a PUBLISH_DONE-draining loop, another UpdateRequest) — a concurrent reader -// races it for the response and can swallow it, blocking this call until ctx -// expires. When the stream needs a standing reader, use [RequestBroker.Serve] -// and [RequestBroker.Update] instead. +// UpdateRequest reads the response directly, so it MUST NOT run concurrently +// with another reader of the stream; use [RequestBroker.Update] when the +// stream needs a standing reader. func (s *Session) UpdateRequest( ctx context.Context, stream Stream, @@ -753,16 +657,12 @@ func (s *Session) UpdateRequest( return s.mapUpdateResponse(resp) } -// CheckPeerParams checks the Message Parameters of m, received from the peer -// as a message of the given scope, against §10.2.1 ("If it appears in some -// other type of message, the receiving endpoint MUST close the connection with -// a PROTOCOL_VIOLATION") and §10.2's duplicate rule. On a violation it closes -// the session with PROTOCOL_VIOLATION and returns the error. A message without -// parameters passes. +// CheckPeerParams checks the Message Parameters of a peer message m against +// scope (§10.2.1) and §10.2's duplicate rule. On a violation it closes the +// session with PROTOCOL_VIOLATION and returns the error. // -// The session checks every message it reads itself. Callers that read a -// request stream with [message.Parse] — REQUEST_UPDATEs on a request they -// answer, for instance — call it for what they read. +// The session checks the messages it reads itself; callers that read a +// request stream with [message.Parse] call it for what they read. func (s *Session) CheckPeerParams(scope message.ParamScope, m message.Message) error { params, ok := message.ParamsOf(m) if !ok { @@ -775,15 +675,11 @@ func (s *Session) CheckPeerParams(scope message.ParamScope, m message.Message) e } // emptyPropertiesOK names the REQUEST_OK answering req when §10.5 says its -// Track Properties are empty: "Track Properties are populated in -// TRACK_STATUS_OK; they are empty in PUBLISH_OK, REQUEST_UPDATE_OK, -// SUBSCRIBE_NAMESPACE_OK and PUBLISH_NAMESPACE_OK." A nil req means the -// REQUEST_OK answers a REQUEST_UPDATE; so does any REQUEST_OK on a SUBSCRIBE -// or FETCH stream, which are first answered SUBSCRIBE_OK and FETCH_OK. -// Responses the list does not name (TRACK_STATUS_OK, and the SUBSCRIBE_TRACKS -// OK) report false — as does a REQUEST_UPDATE_OK on a SUBSCRIBE_TRACKS stream, -// which req alone cannot tell from that stream's first OK: callers that can -// pass nil for it. +// Track Properties are empty ("they are empty in PUBLISH_OK, +// REQUEST_UPDATE_OK, SUBSCRIBE_NAMESPACE_OK and PUBLISH_NAMESPACE_OK"). A nil +// req, a SUBSCRIBE or a FETCH means a REQUEST_UPDATE_OK. SUBSCRIBE_TRACKS +// reports false: req alone cannot tell its first OK from an update's, so +// callers that can pass nil for the latter. func emptyPropertiesOK(req message.Message) (string, bool) { switch req.(type) { case nil, *message.Subscribe, *message.Fetch: @@ -798,10 +694,9 @@ func emptyPropertiesOK(req message.Message) (string, bool) { return "", false } -// checkRequestOKTrackProperties enforces §10.5 on a received REQUEST_OK -// answering req (nil for a REQUEST_UPDATE). An endpoint that receives Track -// Properties in one of the OKs [emptyPropertiesOK] names "MUST close the -// session with a PROTOCOL_VIOLATION", so it does. +// checkRequestOKTrackProperties closes the session with PROTOCOL_VIOLATION +// when a received REQUEST_OK answering req (nil for a REQUEST_UPDATE) carries +// Track Properties §10.5 says are empty. func (s *Session) checkRequestOKTrackProperties(req, resp message.Message) error { ok, isOK := resp.(*message.RequestOK) if !isOK || len(ok.TrackProperties) == 0 { @@ -814,17 +709,13 @@ func (s *Session) checkRequestOKTrackProperties(req, resp message.Message) error return s.closeProtocolViolation(fmt.Errorf("moqt/session: Track Properties in %s", name)) } -// Reply marshals a response message onto the request's bidi stream. The -// stream is left open so further messages can be written. Use RejectError or -// Stream.Close to terminate the send direction. +// Reply marshals a response message onto the request's stream and leaves it +// open. Use RejectError or Stream.Close to end the send direction. // -// A REQUEST_OK carrying Track Properties where §10.5 says they are empty — -// answering a PUBLISH, PUBLISH_NAMESPACE or SUBSCRIBE_NAMESPACE, or a -// REQUEST_UPDATE on a SUBSCRIBE, FETCH or SUBSCRIBE_TRACKS — is refused with -// [ErrTrackPropertiesNotAllowed] and nothing is written: the peer would have -// to close the session. On a SUBSCRIBE_TRACKS stream every REQUEST_OK after -// the first answers a REQUEST_UPDATE; a first one written to Stream directly -// rather than through Reply goes uncounted. +// A REQUEST_OK carrying Track Properties where §10.5 says they are empty is +// refused with [ErrTrackPropertiesNotAllowed] and nothing is written. On a +// SUBSCRIBE_TRACKS stream, every REQUEST_OK after the first sent through Reply +// counts as a REQUEST_UPDATE_OK. func (r *Request) Reply(msg message.Message) error { ok, isOK := msg.(*message.RequestOK) if isOK && len(ok.TrackProperties) > 0 { @@ -845,35 +736,17 @@ func (r *Request) Reply(msg message.Message) error { return nil } -// RejectError writes a REQUEST_ERROR with the given code and reason, then -// cancels the read side and FINs the send direction of the bidi stream -// (§3.3.3: "When an endpoint rejects a request without performing any -// application processing, it SHOULD send a REQUEST_ERROR and FIN the stream."). -// CancelRead ensures that any further data the peer sends after the rejection -// does not queue in the transport buffer indefinitely. -// -// When the REQUEST_ERROR itself cannot be written, the stream is reset instead -// and the write error returned. §3.3.3 gives a responder both exits — -// REQUEST_ERROR plus FIN, or "Receivers cancel requests if they are unable to -// or choose not to respond" — and a failed write has taken neither until the -// reset lands. Returning early without it leaves the requester waiting on a -// response that can never arrive, for as long as the session lives. -// -// RejectError sends Retry Interval 0: the request "SHOULD NOT be retried" -// (§10.6.2). Use [Request.Reject] to invite a retry. +// RejectError writes a REQUEST_ERROR with Retry Interval 0 (§10.6.2), stops +// reading and FINs the stream (§3.3.3). If the REQUEST_ERROR cannot be +// written, the stream is reset instead so the requester is not left waiting. +// Use [Request.Reject] to invite a retry. func (r *Request) RejectError(code moqt.RequestErrorCode, reason string) error { return r.Reject(&RequestRejectedError{Code: code, Reason: reason}) } // Reject is [Request.RejectError] with rej's Code, Reason and RetryInterval -// (§10.6.2: "If a request is retryable with the same parameters at a later -// time, the sender of REQUEST_ERROR includes a non-zero Retry Interval in the -// message"). It is the send-side counterpart of the *[RequestRejectedError] a -// requester gets back. -// -// REDIRECT is refused with an error and nothing is written: its Redirect -// structure is "Present only when Error Code is REDIRECT" (§10.6.2), and -// Reject has none to send. +// (§10.6.2). REDIRECT is refused and nothing is written, since Reject has no +// Redirect structure to send. func (r *Request) Reject(rej *RequestRejectedError) error { if rej.Code == moqt.RequestRedirect { return errors.New("moqt/session: Reject cannot send REDIRECT: it has no Redirect structure (§10.6.2)") @@ -890,24 +763,19 @@ func (r *Request) Reject(rej *RequestRejectedError) error { return r.Stream.Close() } -// AcceptSubscribe accepts an inbound SUBSCRIBE (§10.7) and returns a -// [Publication] for pushing objects back to the subscriber — the accept-side -// counterpart of [Session.Publish]. r.First MUST be a *message.Subscribe. +// AcceptSubscribe accepts an inbound SUBSCRIBE (§10.7): it writes +// SUBSCRIBE_OK and returns a [Publication] bound to its Track Alias. r.First +// MUST be a *message.Subscribe. // -// ok carries the SUBSCRIBE_OK fields the caller wants to set (negotiated -// Parameters, TrackProperties); its TrackAlias is filled in automatically when -// zero, via [Session.AllocOutboundTrackAlias] — set it non-zero to assign a -// specific alias (e.g. to mirror an upstream). ok may be nil for the all-default -// reply. AcceptSubscribe writes SUBSCRIBE_OK and returns a Publication whose -// [Publication.OpenSubgroup] is pre-bound to the alias and whose -// [Publication.Done] ends the subscription with PUBLISH_DONE. +// ok may be nil for the all-default reply; a zero TrackAlias is allocated with +// [Session.AllocOutboundTrackAlias]. A FORWARD value above 1 closes the +// session with PROTOCOL_VIOLATION (§10.2.18). func (r *Request) AcceptSubscribe(ok *message.SubscribeOK) (*Publication, error) { sub, isSub := r.First.(*message.Subscribe) if !isSub { return nil, fmt.Errorf("moqt/session: AcceptSubscribe on a %s request", r.First.Type()) } - // §10.2.18: FORWARD other than 0 or 1 "MUST close the session with - // PROTOCOL_VIOLATION". + // §10.2.18. if f, found := sub.Parameters.Find(message.ParamForward); found && f.Byte > 1 { resetStream(r.Stream) return nil, r.s.closeProtocolViolation( @@ -925,23 +793,15 @@ func (r *Request) AcceptSubscribe(ok *message.SubscribeOK) (*Publication, error) return newPublication(r.s, r.Stream, sub.RequestID, ok.TrackAlias, sub.Parameters), nil } -// AcceptPublish accepts an inbound PUBLISH (§10.11): it registers the -// publisher-assigned Track Alias (§11.1, so inbound subgroup/datagram streams -// resolve to this track and a reused alias is caught as DUPLICATE_TRACK_ALIAS), -// replies REQUEST_OK, and returns an [IncomingPublication] for the receiving -// side — the accept-side counterpart of [Session.Publish]. r.First MUST be a -// *message.Publish. The objects arrive on subgroup uni-streams via -// [Session.AcceptDataStream]. -// -// When the session enforces Mandatory Track Properties (see -// [WithKnownMandatoryTrackProperties]), a PUBLISH carrying one it does not -// understand is refused with REQUEST_ERROR UNSUPPORTED_EXTENSION (§2.5.1) and -// Track Properties that do not parse with MALFORMED_TRACK; the validation -// error is returned. +// AcceptPublish accepts an inbound PUBLISH (§10.11): it registers the Track +// Alias (§11.1), replies REQUEST_OK and returns an [IncomingPublication]. +// r.First MUST be a *message.Publish. // -// If the alias collides with a different already-registered track, -// *ErrDuplicateTrackAlias is returned WITHOUT replying OK; the caller MUST close -// the session with [moqt.SessionDuplicateTrackAlias] (§11.1). +// Track Properties that fail validation (see +// [WithKnownMandatoryTrackProperties]) are rejected with REQUEST_ERROR and the +// error returned. On an alias collision *ErrDuplicateTrackAlias is returned +// without replying; the caller MUST close the session with +// [moqt.SessionDuplicateTrackAlias] (§11.1). func (r *Request) AcceptPublish() (*IncomingPublication, error) { pub, isPub := r.First.(*message.Publish) if !isPub { @@ -958,8 +818,8 @@ func (r *Request) AcceptPublish() (*IncomingPublication, error) { if err := message.Marshal(r.Stream, &message.RequestOK{}); err != nil { return nil, fmt.Errorf("moqt/session: write PUBLISH REQUEST_OK: %w", err) } - // The publisher sent the PUBLISH, so it may send REQUEST_UPDATE - // (§10.9) as well as PUBLISH_STATE_NOTIFY (§10.10). + // The publisher may send REQUEST_UPDATE (§10.9) and PUBLISH_STATE_NOTIFY + // (§10.10). return &IncomingPublication{ Stream: r.Stream, s: r.s, diff --git a/pkg/moqt/session/session.go b/pkg/moqt/session/session.go index 28bc4fc7..76d88b3c 100644 --- a/pkg/moqt/session/session.go +++ b/pkg/moqt/session/session.go @@ -44,17 +44,14 @@ type Session struct { peerOptions []wire.KVPair // earlyData holds data streams that arrived before the peer's control - // stream (§3.3), for AcceptDataStream; see acceptControlStream. + // stream (§3.3); see acceptControlStream. earlyMu sync.Mutex earlyData []ReceiveStream - // setupTokenAliases are the aliases this endpoint REGISTERed in its SETUP - // that the peer holds; see [Session.SetupTokenAliases]. + // See [Session.SetupTokenAliases] and [Session.SetupTokens]. Written once + // during open. setupTokenAliases []uint64 - - // setupTokens are the tokens resolved from the peer's SETUP; see - // [Session.SetupTokens]. Written once during open. - setupTokens []ResolvedToken + setupTokens []ResolvedToken // Outgoing Request ID allocator: client starts at 0 (even), server at 1 // (odd); each AllocRequestID advances by 2 (§10.1). @@ -103,13 +100,12 @@ type Session struct { // a DUPLICATE_TRACK_ALIAS session error. inboundAliases map[uint64]InboundTrack - // inboundAliasRefs counts the registrations of each alias in - // inboundAliases; see [Session.RegisterInboundTrack]. Protected by mu. + // inboundAliasRefs counts the registrations of each alias. Protected by + // mu. inboundAliasRefs map[uint64]int - // aliasRegistered is closed, and replaced, each time RegisterInboundTrack - // binds a new alias, waking [IncomingSubgroupStream.AwaitInboundTrack]. - // Protected by mu. + // aliasRegistered is closed and replaced each time a new alias is bound, + // waking awaitInboundTrack. Protected by mu. aliasRegistered chan struct{} // knownMandatoryTrackProperties is the set of Mandatory Track Property @@ -282,8 +278,7 @@ func checkOutboundSetupOptions(r role, conn Conn, opts []wire.KVPair) error { "HTTP/3 carries the path and authority in the CONNECT request", name) } } - // A server closes the session over one that is not RFC 3986; refuse to - // send it. uri.Parse goes through net/url, which lets some through. + // The server would close the session over a non-RFC 3986 value. if _, err := checkPathAndAuthoritySyntax(opts); err != nil { return fmt.Errorf("moqt/session: %w", err) } @@ -339,12 +334,9 @@ func (s *Session) checkPeerSetupOptions() (moqt.SessionErrorCode, error) { return moqt.SessionNoError, nil } -// checkPathAndAuthoritySyntax enforces the syntax rule of PATH (§10.3.1.2) -// and AUTHORITY (§10.3.1.1) — on receipt by a server over native QUIC, where -// receiving them is legal, and on a client before it sends them: each -// "follows the URI formatting rules [RFC3986]", and "If an AUTHORITY option -// does not conform to these rules, the session MUST be closed with -// MALFORMED_AUTHORITY" — likewise PATH with MALFORMED_PATH. +// checkPathAndAuthoritySyntax checks PATH (§10.3.1.2) and AUTHORITY +// (§10.3.1.1) against RFC 3986, returning MALFORMED_PATH or +// MALFORMED_AUTHORITY as the close code. func checkPathAndAuthoritySyntax(opts []wire.KVPair) (moqt.SessionErrorCode, error) { for _, opt := range opts { switch message.SetupOption(opt.Type) { diff --git a/pkg/moqt/session/sessiontest/sessiontest.go b/pkg/moqt/session/sessiontest/sessiontest.go index 895c7315..187a0c87 100644 --- a/pkg/moqt/session/sessiontest/sessiontest.go +++ b/pkg/moqt/session/sessiontest/sessiontest.go @@ -161,22 +161,19 @@ func (s *uniStream) CancelWrite(uint64) { } func (s *uniStream) Read(p []byte) (int, error) { return s.r.Read(p) } -// CancelRead is the acceptor's STOP_SENDING: it also ends the opener's send -// side, whose Context is cancelled as on a real transport. +// CancelRead is the acceptor's STOP_SENDING; it also cancels Context. func (s *uniStream) CancelRead(uint64) { _ = s.r.CloseWithError(errCancelled) s.ctxCancel() } // Context is cancelled when Close() or CancelWrite() has been called, or the -// peer called CancelRead — the send side is done (cleanly, via reset, or -// because the peer stopped reading). +// peer called CancelRead. func (s *uniStream) Context() context.Context { return s.ctx } // bidiStream is two io.Pipes wired so each end reads what the other writes. // ctx / ctxCancel implement Context() on the send side; peerCtxCancel cancels -// the other end's, which is how this end's CancelRead (STOP_SENDING) reaches -// the writer. +// the other end's on this end's CancelRead (STOP_SENDING). type bidiStream struct { r pipeReadCloser w pipeWriteCloser diff --git a/pkg/moqt/session/subscribe.go b/pkg/moqt/session/subscribe.go index 1cfc837d..45b2282a 100644 --- a/pkg/moqt/session/subscribe.go +++ b/pkg/moqt/session/subscribe.go @@ -40,8 +40,7 @@ func (sub *Subscription) TrackAlias() uint64 { return sub.OK.TrackAlias } func (s *Session) Subscribe(ctx context.Context, m *message.Subscribe) (*Subscription, error) { return awaitRequestResponse(ctx, s, m, func(stream Stream, ok *message.SubscribeOK) (*Subscription, error) { - // §2.5.1: reject tracks with unknown mandatory track properties. - // "the subscriber MUST cancel the subscription" (§2.5.1). + // §2.5.1: "the subscriber MUST cancel the subscription". if err := s.validateTrackProperties(ok.TrackProperties, "SUBSCRIBE_OK"); err != nil { cancelRequest(stream) return nil, err @@ -54,7 +53,7 @@ func (s *Session) Subscribe(ctx context.Context, m *message.Subscribe) (*Subscri return nil, err } // The publisher may send PUBLISH_STATE_NOTIFY (§10.10) but not - // REQUEST_UPDATE: it did not send the SUBSCRIBE (§10.9). + // REQUEST_UPDATE (§10.9). return &Subscription{ Stream: stream, s: s, diff --git a/pkg/moqt/session/token_verify.go b/pkg/moqt/session/token_verify.go index 6454c247..8d775c59 100644 --- a/pkg/moqt/session/token_verify.go +++ b/pkg/moqt/session/token_verify.go @@ -137,15 +137,12 @@ func (s *Session) TokenCache() *TokenCache { return s.tokenCache } // applies each token to the inbound cache per §10.2.2, returning the resolved // (Type, Value) tokens for any REGISTER / USE_ALIAS / USE_VALUE entries. // -// Processing order matters: a REGISTER is committed to the cache immediately, -// honouring the §10.2.2 MUST that the receiver "MUST register the Token Alias -// in the token cache, even if the message fails for other reasons". Because -// the cache mutation happens here — before the request is validated or -// authorized — a later rejection of the request does not roll the alias back. +// A REGISTER is committed immediately, before the request is validated, so a +// later rejection does not roll it back (§10.2.2: "even if the message fails +// for other reasons"). // -// A cache-layer failure (malformed token, duplicate alias, overflow, unknown -// alias) is returned as a [*TokenCacheError] carrying the session-level -// SESSION_ERROR code the caller must close the session with. +// A cache-layer failure is returned as a [*TokenCacheError] carrying the code +// the caller must close the session with. func (s *Session) processRequestTokens(msg message.Message) ([]ResolvedToken, error) { ps, ok := message.ParamsOf(msg) if !ok { @@ -153,12 +150,9 @@ func (s *Session) processRequestTokens(msg message.Message) ([]ResolvedToken, er } tokens, err := message.TokensFromParam(ps) if err != nil { - // §10.2.2: "If the Token structure cannot be decoded, the receiver - // MUST close the Session with KEY_VALUE_FORMATTING_ERROR." That - // includes an unknown Alias Type, which leaves the fields that - // follow undefined. (§3.5 describes MALFORMED_AUTH_TOKEN as - // "Invalid Auth Token serialization during registration"; the - // specific MUST above is followed.) + // §10.2.2: an undecodable Token, including an unknown Alias Type, + // closes with KEY_VALUE_FORMATTING_ERROR. This follows that MUST + // over §3.5's MALFORMED_AUTH_TOKEN description. return nil, &TokenCacheError{Code: moqt.SessionKeyValueFormattingError, Err: err} } if len(tokens) == 0 { @@ -178,14 +172,13 @@ func (s *Session) processRequestTokens(msg message.Message) ([]ResolvedToken, er return resolved, nil } -// applyToken applies one parsed token to the inbound cache per §10.2.2 and -// returns what it resolves to; ok is false for a DELETE, which carries no -// value. A cache failure is a [*TokenCacheError]. +// applyToken applies one token to the inbound cache (§10.2.2) and returns what +// it resolves to; ok is false for a DELETE. A cache failure is a +// [*TokenCacheError]. func (s *Session) applyToken(t *message.Token) (tok ResolvedToken, ok bool, err error) { switch t.AliasType { case message.AliasTypeRegister: - // §10.2.2: register before any further validation so the alias - // persists even if the request is later rejected. + // §10.2.2: register before any further validation. if err := s.tokenCache.Register(t.TokenAlias, t.TokenType, t.TokenValue); err != nil { return ResolvedToken{}, false, &TokenCacheError{Code: sessionCodeForCacheErr(err), Err: err} } @@ -250,28 +243,19 @@ func sessionCodeForCacheErr(err error) moqt.SessionErrorCode { } // ProcessFollowupTokens resolves the AUTHORIZATION_TOKEN parameters (§10.2.2) -// of a follow-up message read off an established request stream — §10.2.2 -// explicitly allows tokens on REQUEST_UPDATE, and the receiver "MUST register -// the Token Alias in the token cache, even if the message fails for other -// reasons". -// AcceptRequest performs the same processing for a stream's FIRST message; -// any code that reads follow-ups directly (message.Parse on the stream) MUST -// route messages carrying parameters through here, or the peer's view of the -// token cache silently diverges and its next USE_ALIAS kills the session -// with UNKNOWN_AUTH_TOKEN_ALIAS. +// of a follow-up message, such as a REQUEST_UPDATE, read off an established +// request stream. Code that reads follow-ups with message.Parse MUST route +// them through here, or the token cache diverges from the peer's. // -// The error contract matches AcceptRequest: a *TokenCacheError carries the -// SESSION_ERROR code the caller must close the session with. Messages -// without parameters (or without token parameters) return (nil, nil). +// A *TokenCacheError carries the code the caller must close the session with. +// Messages without token parameters return (nil, nil). func (s *Session) ProcessFollowupTokens(msg message.Message) ([]ResolvedToken, error) { return s.processRequestTokens(msg) } -// SetupTokens returns the tokens the peer sent in AUTHORIZATION TOKEN options -// of its SETUP (§10.3.1.4: tokens "that the peer can use to authorize MOQT -// session establishment"), resolved as for a request (§10.2.2). The session -// does not verify them; authorizing the session is the application's call. -// Each call returns fresh copies. +// SetupTokens returns copies of the resolved tokens the peer sent in +// AUTHORIZATION TOKEN options of its SETUP (§10.3.1.4). The session does not +// verify them. func (s *Session) SetupTokens() []ResolvedToken { out := make([]ResolvedToken, len(s.setupTokens)) for i, t := range s.setupTokens { @@ -281,19 +265,13 @@ func (s *Session) SetupTokens() []ResolvedToken { } // processSetupTokens applies the AUTHORIZATION TOKEN options in the peer's -// SETUP (§10.3.1.4, "functionally equivalent to the AUTHORIZATION TOKEN -// message parameter") and keeps the resolved tokens for [Session.SetupTokens]. -// Two rules differ from a request's: -// - §10.2.2: "If a server receives Alias Type DELETE (0x0) or USE_ALIAS -// (0x2) in a SETUP message, it MUST close the session with a -// PROTOCOL_VIOLATION." -// - §10.3.1.4: a REGISTER "that exceeds its MAX_AUTH_TOKEN_CACHE_SIZE [...] -// MUST NOT fail the session with AUTH_TOKEN_CACHE_OVERFLOW. Instead, it -// MUST treat the option as Alias Type USE_VALUE." +// SETUP (§10.3.1.4) and keeps the resolved tokens for [Session.SetupTokens]. +// Unlike a request, a server closes with PROTOCOL_VIOLATION on DELETE or +// USE_ALIAS (§10.2.2), and a REGISTER that overflows the cache is treated as +// USE_VALUE (§10.3.1.4). // -// A REGISTER that both repeats an alias and would overflow the cache closes -// with DUPLICATE_AUTH_TOKEN_ALIAS: the cache checks the alias first, and the -// draft does not say which rule wins (an assumption). +// Assumption: a REGISTER that both repeats an alias and overflows closes with +// DUPLICATE_AUTH_TOKEN_ALIAS; the draft does not say which rule wins. // // Every error is a [*TokenCacheError] carrying the code to close with. func (s *Session) processSetupTokens() error { @@ -303,9 +281,7 @@ func (s *Session) processSetupTokens() error { } var t message.Token if err := t.Parse(opt.ByteVal); err != nil { - // §10.2.2: "If the Token structure cannot be decoded, the - // receiver MUST close the Session with - // KEY_VALUE_FORMATTING_ERROR." + // §10.2.2. return &TokenCacheError{Code: moqt.SessionKeyValueFormattingError, Err: fmt.Errorf("moqt/session: AUTHORIZATION TOKEN setup option: %w", err)} } @@ -330,20 +306,13 @@ func (s *Session) processSetupTokens() error { } // SetupTokenAliases returns the aliases of the REGISTER tokens this endpoint -// sent in SETUP ([WithSetupToken]) that the peer holds, in the order sent. -// §10.3.1.4: a REGISTER exceeding the peer's MAX_AUTH_TOKEN_CACHE_SIZE is -// treated by it as USE_VALUE, and "the sender MUST handle registration -// failures of this kind by purging any Token Aliases that failed to register -// based on the peer's MAX_AUTH_TOKEN_CACHE_SIZE option in SETUP (or the -// default value of 0)". Only the aliases returned here may be referenced with -// USE_ALIAS. +// sent in SETUP ([WithSetupToken]) that fit the peer's +// MAX_AUTH_TOKEN_CACHE_SIZE, in the order sent (§10.3.1.4). Only these may be +// referenced with USE_ALIAS. func (s *Session) SetupTokenAliases() []uint64 { return slices.Clone(s.setupTokenAliases) } -// checkOutboundSetupTokens refuses setup tokens the peer would have to close -// the session over: DELETE or USE_ALIAS (§10.2.2: "If a server receives Alias -// Type DELETE (0x0) or USE_ALIAS (0x2) in a SETUP message, it MUST close the -// session with a PROTOCOL_VIOLATION"; a client has nothing registered for -// either to name), and an alias REGISTERed twice (DUPLICATE_AUTH_TOKEN_ALIAS). +// checkOutboundSetupTokens refuses setup tokens the peer would close the +// session over (§10.2.2): DELETE, USE_ALIAS, or an alias REGISTERed twice. func checkOutboundSetupTokens(toks []message.Token) error { var registered []uint64 for _, t := range toks { @@ -364,11 +333,9 @@ func checkOutboundSetupTokens(toks []message.Token) error { return nil } -// heldSetupAliases replays, against the peer's MAX_AUTH_TOKEN_CACHE_SIZE -// (§10.3.1.3; 0 when the peer sent none), the cache accounting the peer -// applies to toks in order ([TokenCache.Register]: 16 bytes plus the value -// each), and returns the aliases of the REGISTERs that fit. The peer treats -// the others as USE_VALUE (§10.3.1.4). +// heldSetupAliases replays the peer's cache accounting ([TokenCache.Register]) +// against its MAX_AUTH_TOKEN_CACHE_SIZE (§10.3.1.3; default 0) and returns +// the aliases of the REGISTERs that fit (§10.3.1.4). func heldSetupAliases(toks []message.Token, peerOptions []wire.KVPair) []uint64 { var limit uint64 for _, opt := range peerOptions { diff --git a/pkg/moqt/session/track_properties.go b/pkg/moqt/session/track_properties.go index 02f6a9a8..0ba9eab9 100644 --- a/pkg/moqt/session/track_properties.go +++ b/pkg/moqt/session/track_properties.go @@ -16,9 +16,8 @@ import ( // process or forward the track. // // For outbound requests (Subscribe, Fetch, TrackStatus) the session layer -// returns this error directly, and [Request.AcceptPublish] replies -// REQUEST_ERROR UNSUPPORTED_EXTENSION before returning it. A caller that -// handles an inbound PUBLISH itself checks with [Session.CheckTrackProperties]. +// returns this error directly; [Request.AcceptPublish] replies REQUEST_ERROR +// UNSUPPORTED_EXTENSION before returning it. type ErrUnsupportedMandatoryTrackProperty struct { // PropertyType is the first unrecognised mandatory property type found. PropertyType message.PropertyType @@ -35,19 +34,13 @@ func (e *ErrUnsupportedMandatoryTrackProperty) Error() string { } // ErrMalformedTrackProperties is wrapped by the error [ValidateTrackProperties] -// returns when raw Track Properties do not parse as a sequence of Properties -// (§2.5). A receiver that cannot parse them cannot rule out an unknown -// Mandatory Track Property either, so it treats the track as malformed: -// [Request.AcceptPublish] refuses such a PUBLISH with MALFORMED_TRACK. The -// draft does not cover unparseable Track Properties, and §10.6 defines -// MALFORMED_TRACK only for FETCH, so that code is this package's choice. +// returns when raw Track Properties do not parse (§2.5). Assumption: the draft +// does not cover this, and rejecting with MALFORMED_TRACK (§10.6 defines it +// only for FETCH) is this package's choice. var ErrMalformedTrackProperties = errors.New("moqt/session: malformed track properties") -// ErrTrackPropertiesNotAllowed is returned when an endpoint asks to send a -// REQUEST_OK with Track Properties where §10.5 says they are empty: in -// PUBLISH_OK, REQUEST_UPDATE_OK, SUBSCRIBE_NAMESPACE_OK and -// PUBLISH_NAMESPACE_OK. Nothing is sent, since the peer "MUST close the -// session with a PROTOCOL_VIOLATION" on receiving one. +// ErrTrackPropertiesNotAllowed is returned, and nothing sent, when asked to +// send a REQUEST_OK with Track Properties where §10.5 says they are empty. var ErrTrackPropertiesNotAllowed = errors.New("moqt/session: track properties not allowed in this REQUEST_OK") // ValidateTrackProperties parses raw Track Properties bytes and checks for @@ -70,8 +63,7 @@ func ValidateTrackProperties( if err != nil { return nil, fmt.Errorf("%w in %s: %w", ErrMalformedTrackProperties, context, err) } - // §12.7: a Mandatory Track Property inside Immutable Properties counts - // too, and contents that do not parse make the track malformed. + // §12.7: Mandatory Track Properties inside Immutable Properties count. all, err := message.ExpandImmutable(pairs) if err != nil { return nil, fmt.Errorf("%w in %s: %w", ErrMalformedTrackProperties, context, err) @@ -85,19 +77,12 @@ func ValidateTrackProperties( return pairs, nil } -// CheckTrackProperties reports whether raw Track Properties (from a PUBLISH, -// SUBSCRIBE_OK, FETCH_OK, or TRACK_STATUS_OK) carry a Mandatory Track Property -// this session was not configured to understand via -// [WithKnownMandatoryTrackProperties], returning -// *ErrUnsupportedMandatoryTrackProperty if so, or an error wrapping -// [ErrMalformedTrackProperties] if they do not parse; [TrackPropertiesRejectCode] -// maps either to its REQUEST_ERROR code. §2.5.1: such a track MUST NOT be -// processed or forwarded. It is for callers that handle a request themselves -// rather than through [Request.AcceptPublish] or the outbound openers, which -// already check. -// -// If WithKnownMandatoryTrackProperties was never called (the map is nil), the -// check is skipped and nil is returned. +// CheckTrackProperties validates raw Track Properties against the types +// configured with [WithKnownMandatoryTrackProperties] (§2.5.1), returning +// *ErrUnsupportedMandatoryTrackProperty or an error wrapping +// [ErrMalformedTrackProperties]; see [TrackPropertiesRejectCode]. It is for +// callers that bypass [Request.AcceptPublish] and the outbound openers, which +// already check. Without that option it returns nil. func (s *Session) CheckTrackProperties(raw []byte, context string) error { return s.validateTrackProperties(raw, context) } @@ -106,9 +91,7 @@ func (s *Session) CheckTrackProperties(raw []byte, context string) error { // session's configured set of known mandatory track property types. // // If WithKnownMandatoryTrackProperties was never called (the map is nil), -// the check is skipped entirely, for endpoints that pass Track Properties -// through without acting on them. Pass an empty (non-nil) map to opt in to -// enforcement with no types known. +// the check is skipped, for endpoints that pass Track Properties through. func (s *Session) validateTrackProperties(raw []byte, context string) error { if s.knownMandatoryTrackProperties == nil { return nil // not configured — skip enforcement @@ -118,9 +101,7 @@ func (s *Session) validateTrackProperties(raw []byte, context string) error { } // TrackPropertiesRejectCode is the REQUEST_ERROR code for a Track Properties -// validation error: UNSUPPORTED_EXTENSION for an unknown Mandatory Track -// Property (§2.5.1), MALFORMED_TRACK for Track Properties that do not parse -// (see [ErrMalformedTrackProperties]). +// validation error: UNSUPPORTED_EXTENSION (§2.5.1) or MALFORMED_TRACK. func TrackPropertiesRejectCode(err error) moqt.RequestErrorCode { if _, ok := errors.AsType[*ErrUnsupportedMandatoryTrackProperty](err); ok { return moqt.RequestUnsupportedExtension diff --git a/pkg/moqt/session/track_status.go b/pkg/moqt/session/track_status.go index 91ee93a3..fb02e26c 100644 --- a/pkg/moqt/session/track_status.go +++ b/pkg/moqt/session/track_status.go @@ -11,10 +11,8 @@ import ( ) // TrackStatusRequest is a completed TRACK_STATUS request (§10.15), returned by -// [Session.TrackStatus] with the peer's TRACK_STATUS_OK. TRACK_STATUS cannot be -// updated — "the subscriber cannot send REQUEST_UPDATE" (§10.15) — so the -// requester has already FINned its side of the stream, and nothing but the -// responder's FIN follows the OK. Close stops reading the stream. +// [Session.TrackStatus] with the peer's TRACK_STATUS_OK. It cannot be updated, +// so this side of the stream is already FINned. Close stops reading. type TrackStatusRequest struct { Stream @@ -22,8 +20,7 @@ type TrackStatusRequest struct { OK *message.TrackStatusOK } -// Close releases the stream's receive side. Its send side was FINned when the -// response arrived. +// Close releases the stream's receive side. func (t *TrackStatusRequest) Close() error { t.Stream.CancelRead(uint64(moqt.StreamResetCancelled)) return nil @@ -35,15 +32,13 @@ func (t *TrackStatusRequest) Close() error { // // ok carries the TRACK_STATUS_OK fields (status, largest location, Track // Properties — [message.TrackStatusOK] is an alias of [message.RequestOK]); it -// may be nil for the all-default reply. TRACK_STATUS is a one-shot status -// query: the stream "is closed with a FIN after TRACK_STATUS_OK or -// REQUEST_ERROR are sent" (§10.15), so no handle is returned. +// may be nil for the all-default reply. The stream is FINned after the reply +// (§10.15), so no handle is returned. // -// The requester "sends TRACK_STATUS as the first and only message" (§10.15). -// A REQUEST_UPDATE after it MUST close the session with PROTOCOL_VIOLATION -// (§10.9), as must an unknown or malformed message (§10). Closing on any other -// well-formed message is this implementation's reading of "only message"; the -// draft names no consequence for it. +// Any later message from the requester closes the session with +// PROTOCOL_VIOLATION (§10.9, §10). Interpretation: for a well-formed message +// other than REQUEST_UPDATE this reads §10.15's "first and only message"; the +// draft names no consequence. func (r *Request) AcceptTrackStatus(ok *message.TrackStatusOK) error { if _, isTS := r.First.(*message.TrackStatus); !isTS { return fmt.Errorf("moqt/session: AcceptTrackStatus on a %s request", r.First.Type()) @@ -52,8 +47,7 @@ func (r *Request) AcceptTrackStatus(ok *message.TrackStatusOK) error { ok = &message.TrackStatusOK{} } if err := message.Marshal(r.Stream, ok); err != nil { - // As in RejectError: a response that cannot be written must not - // leave the requester waiting on a stream that looks healthy. + // As in RejectError: do not leave the requester waiting. resetStream(r.Stream) return fmt.Errorf("moqt/session: write TRACK_STATUS_OK: %w", err) } @@ -64,11 +58,9 @@ func (r *Request) AcceptTrackStatus(ok *message.TrackStatusOK) error { return nil } -// rejectTrackStatusFollowups reads the requester's side of an answered -// TRACK_STATUS stream until it ends. Anything arriving there closes the session -// (see [Request.AcceptTrackStatus]): a message, or bytes that do not parse. It -// returns quietly on the requester's FIN, a stream reset, or session close — so -// a requester that never FINs holds it only until the session ends. +// rejectTrackStatusFollowups closes the session if anything arrives on an +// answered TRACK_STATUS stream (see [Request.AcceptTrackStatus]). It returns +// quietly on the requester's FIN, a reset or session close. func (r *Request) rejectTrackStatusFollowups() { src := &readErrRecorder{r: r.Stream} msg, err := message.Parse(src) @@ -103,10 +95,8 @@ func (e *readErrRecorder) Read(p []byte) (int, error) { // REQUEST_OK (TRACK_STATUS_OK) or REQUEST_ERROR. The session assigns // m.RequestID; the caller supplies Namespace, Name, and optional Parameters. // -// On success a [TrackStatusRequest] is returned whose OK holds the parsed -// TRACK_STATUS_OK. The request cannot be updated, so this side of the stream is -// FINned once the response arrives (§3.3.2: a requester "MAY FIN immediately -// after sending a message if it will not send a REQUEST_UPDATE"). On +// On success this side of the stream is FINned (§3.3.2) and a +// [TrackStatusRequest] holding the TRACK_STATUS_OK is returned. On // REQUEST_ERROR the stream is closed and a *RequestRejectedError is returned. func (s *Session) TrackStatus(ctx context.Context, m *message.TrackStatus) (*TrackStatusRequest, error) { return awaitRequestResponse(ctx, s, m, diff --git a/pkg/moqt/session/trackalias.go b/pkg/moqt/session/trackalias.go index d2d3d6a1..1084e002 100644 --- a/pkg/moqt/session/trackalias.go +++ b/pkg/moqt/session/trackalias.go @@ -13,19 +13,14 @@ import ( // advertises a new track to the peer (§11.1). Aliases are independent across // sessions, so callers must remap when forwarding between two sessions. // -// Allocation starts at 1, never 0: [Session.Publish] and the SUBSCRIBE_OK -// reply path treat a zero TrackAlias as "unset, allocate one for me". -// ([Session.OpenPublish] does not: its caller must allocate.) If this allocator returned 0, a caller that did the natural -// "alias := AllocOutboundTrackAlias(); Publish(&Publish{TrackAlias: alias})" -// would have its 0 silently re-allocated to a different value — and any data -// stream the caller then opened under the original 0 would carry an alias the -// peer never bound to the track (the relay drops it as an unknown alias). So 0 -// is reserved as the sentinel and never handed out. +// Allocation starts at 1: [Session.Publish] and [Request.AcceptSubscribe] +// treat a zero TrackAlias as "allocate one for me", so an allocated 0 would be +// silently replaced. func (s *Session) AllocOutboundTrackAlias() uint64 { return s.nextOutboundTrackAlias.Add(1) } -// ErrDuplicateTrackAlias is returned by RegisterInboundTrackAlias when the +// ErrDuplicateTrackAlias is returned by [Session.RegisterInboundTrack] when the // peer assigns a Track Alias that is already in use for a different track // (§11.1). The caller MUST close the session with SessionDuplicateTrackAlias. type ErrDuplicateTrackAlias struct { @@ -45,19 +40,13 @@ func (e *ErrDuplicateTrackAlias) Error() string { type InboundTrack struct { Key track.Key - // DefaultPublisherPriority is the DEFAULT_PUBLISHER_PRIORITY (§12.4) in the - // Track Properties of the SUBSCRIBE_OK or PUBLISH that bound the alias, or - // 128 when omitted. Subgroups and datagrams sent with the DEFAULT_PRIORITY - // bit inherit it (§11.4.2, §11.3.1). It is captured together with the - // alias, so a data stream that resolves the alias always sees the value of - // the control message that established it. + // DefaultPublisherPriority is the DEFAULT_PUBLISHER_PRIORITY (§12.4) of + // the message that bound the alias, or 128 when omitted. Subgroups and + // datagrams with the DEFAULT_PRIORITY bit inherit it (§11.4.2, §11.3.1). DefaultPublisherPriority uint8 - // MaxCacheDuration is the MAX_CACHE_DURATION (§12.3) in the same Track - // Properties, and HasMaxCacheDuration whether there was one. §12.3 limits - // "any individual Object received through this subscription or fetch", - // so it belongs with the alias Objects arrive on, like - // DefaultPublisherPriority. + // MaxCacheDuration is the MAX_CACHE_DURATION (§12.3) of the same message, + // if HasMaxCacheDuration. MaxCacheDuration time.Duration HasMaxCacheDuration bool } @@ -68,17 +57,12 @@ type InboundTrack struct { // (whose TrackAlias field is the alias) and by the server when it receives a // PUBLISH (whose TrackAlias field is the alias). // -// If alias is already registered for the same track, the registration is -// counted and nil returned. §5.1: "An endpoint MAY have multiple concurrent -// subscriptions to the same Track [...]. A publisher MAY assign the same or -// different Track Aliases to these subscriptions." The alias stays registered -// until each registration is released by [Session.UnregisterInboundTrackAlias]. -// The latest registration's Track Properties replace the earlier ones (§2.5: -// "the most recent set SHOULD replace any cached values"); the draft does not -// say which a shared alias should carry, and the session cannot tell which -// registration a release ends. If alias is already registered for a different -// track, *ErrDuplicateTrackAlias is returned and the caller MUST close the -// session with SessionDuplicateTrackAlias (§11.1). +// Registering an alias again for the same track counts one more registration +// (§5.1 allows subscriptions to share an alias); it stays registered until +// each is released by [Session.UnregisterInboundTrackAlias]. The latest Track +// Properties replace earlier ones (§2.5). If alias is registered for a +// different track, *ErrDuplicateTrackAlias is returned and the caller MUST +// close the session with SessionDuplicateTrackAlias (§11.1). func (s *Session) RegisterInboundTrack(alias uint64, key track.Key, trackProperties []byte) error { in := InboundTrack{ Key: key, @@ -109,12 +93,8 @@ func (s *Session) RegisterInboundTrackAlias(alias uint64, key track.Key) error { } // UnregisterInboundTrackAlias releases one registration of alias (see -// [Session.RegisterInboundTrack]); the alias is removed, and free for reuse, -// with the last. Callers should invoke this when the subscription or -// publication associated with alias has been fully torn down (e.g. after -// PUBLISH_DONE or subscription cancellation and a suitable grace period per -// §11.1: "Subscribers SHOULD retain sufficient state to quickly discard -// these unwanted Objects"). +// [Session.RegisterInboundTrack]); the last release removes it. Call it once +// the subscription or publication is torn down, after a grace period (§11.1). // // Unregistering an alias that was never registered is a no-op. func (s *Session) UnregisterInboundTrackAlias(alias uint64) { @@ -131,11 +111,6 @@ func (s *Session) UnregisterInboundTrackAlias(alias uint64) { // LookupInboundTrack returns what alias was bound to by an earlier // [Session.RegisterInboundTrack] call, or (zero, false) if the alias is not // currently registered. -// -// Inbound data streams (SUBGROUP_HEADER, ObjectDatagram, FETCH_HEADER objects) -// identify their track by the alias the publisher chose; consumers — most -// notably the relay's fanout and end-subscriber applications — use this -// method to recover the canonical track identity for routing or rendering. func (s *Session) LookupInboundTrack(alias uint64) (InboundTrack, bool) { s.mu.Lock() defer s.mu.Unlock() @@ -144,8 +119,7 @@ func (s *Session) LookupInboundTrack(alias uint64) (InboundTrack, bool) { } // awaitInboundTrack is [Session.LookupInboundTrack] that waits for alias to be -// registered, until ctx ends or the session closes. See -// [IncomingSubgroupStream.AwaitInboundTrack]. +// registered, until ctx ends or the session closes. func (s *Session) awaitInboundTrack(ctx context.Context, alias uint64) (InboundTrack, bool) { for { s.mu.Lock() From 2b6805d76d0a0fafd552fd1413523506e5062dbc Mon Sep 17 00:00:00 2001 From: Vsevolod Strukchinsky Date: Sat, 26 Sep 2026 09:56:53 +0500 Subject: [PATCH 03/12] docs(relay): trim comments to contract, citation and why MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comment-only change in pkg/relay non-test files. It drops history narratives, restatements of the code and long or repeated spec quotations. What stays: exported contracts (including DiscoveryStore), one-line § citations, lock and ordering invariants, and marked spec interpretations. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/relay/cache/cache.go | 28 +- pkg/relay/cachettl.go | 13 +- pkg/relay/discovery/discovery.go | 46 +- pkg/relay/discovery/memory.go | 71 +- pkg/relay/handler_datagram.go | 73 +- pkg/relay/handler_fanout.go | 671 +++++------------- pkg/relay/handler_fetch.go | 74 +- pkg/relay/handler_fill.go | 57 +- pkg/relay/handler_forward.go | 61 +- pkg/relay/handler_malformed.go | 40 +- pkg/relay/handler_namespace.go | 224 ++---- pkg/relay/handler_publish.go | 100 +-- pkg/relay/handler_subscribe.go | 458 +++--------- pkg/relay/handler_track_status.go | 6 +- pkg/relay/internal/registry/namespace.go | 84 +-- .../internal/registry/namespace_state.go | 117 +-- pkg/relay/internal/registry/subscription.go | 247 +++---- pkg/relay/internal/registry/track_entry.go | 9 +- pkg/relay/internal/registry/track_registry.go | 23 +- pkg/relay/relay.go | 16 +- pkg/relay/relay_namespace_watch.go | 46 +- pkg/relay/relay_upstream_pool.go | 5 +- pkg/relay/session_handler.go | 168 ++--- 23 files changed, 772 insertions(+), 1865 deletions(-) diff --git a/pkg/relay/cache/cache.go b/pkg/relay/cache/cache.go index 4b4069c0..231343d6 100644 --- a/pkg/relay/cache/cache.go +++ b/pkg/relay/cache/cache.go @@ -65,12 +65,9 @@ type CachedObject struct { ReceivedAt time.Time // MaxCacheDuration is the MAX_CACHE_DURATION (§12.3) of the upstream the - // Object arrived through, and HasMaxCacheDuration whether it sent one: - // §12.3 limits "any individual Object received through this subscription - // or fetch", so two upstreams of a track can differ. The Object is not - // served once that long has passed since ReceivedAt; a present 0 means it - // is never served from the cache (this implementation's reading; live - // forwarding is unaffected). + // Object arrived through (per upstream, so it can differ within a track), + // and HasMaxCacheDuration whether it sent one. A present 0 means never + // serve from the cache (this implementation's reading). MaxCacheDuration time.Duration HasMaxCacheDuration bool @@ -201,8 +198,7 @@ func (c *ObjectCache) Put(obj *CachedObject) { // into a CachedObject and stores it. Datagrams have no subgroup, so // SubgroupID is 0; ForwardingPref records the wire shape so a FETCH // response can replay it as a datagram even if the subscriber's transport -// supports both. maxAge / hasMaxAge are the MAX_CACHE_DURATION of the -// upstream it arrived through (see CachedObject.MaxCacheDuration). +// supports both. maxAge / hasMaxAge are CachedObject.MaxCacheDuration. func (c *ObjectCache) PutDatagram(d *message.ObjectDatagram, maxAge time.Duration, hasMaxAge bool) { if d == nil { return @@ -263,11 +259,8 @@ func (c *ObjectCache) notExpiredLocked(obj *CachedObject) bool { } // Expired reports whether obj, taken from this cache, may no longer be -// served — §12.3: "the relay MUST NOT start forwarding any individual Object -// [...] after the specified number of milliseconds has elapsed since the -// beginning of the Object was received". A FETCH writer asks just before each -// write. Elements the cache did not store (range markers, Objects stitched -// from upstream) are never expired. +// served (§12.3: "MUST NOT start forwarding"). Elements the cache did not +// store (range markers, Objects stitched from upstream) never expire. func (c *ObjectCache) Expired(obj *CachedObject) bool { if obj.ReceivedAt.IsZero() { return false @@ -357,12 +350,9 @@ func (c *ObjectCache) GetRange(start, end message.Location, order message.GroupO continue } if !c.notExpiredLocked(obj) { - // §12.3: "Once Objects have expired from cache, their state - // becomes unknown". Below the floor the whole span is the - // caller's to account for (see OldestRetained); above it, an - // Object that expired out of arrival order would otherwise - // leave a plain gap, which a FETCH response asserts as - // non-existence (§11.4.4). + // §12.3: expired state "becomes unknown". Above the floor a + // plain gap would assert non-existence (§11.4.4), so mark it; + // below it the caller accounts for the span (OldestRetained). if hasFloor && floor.Less(loc) { out = append(out, &CachedObject{GroupID: obj.GroupID, ObjectID: obj.ObjectID, EndOfUnknownRange: true}) } diff --git a/pkg/relay/cachettl.go b/pkg/relay/cachettl.go index cbc430df..3f91a23d 100644 --- a/pkg/relay/cachettl.go +++ b/pkg/relay/cachettl.go @@ -45,16 +45,9 @@ const CacheTTLInfinite = time.Duration(-1) // same retention. That fits the MSF per-broadcaster catalog model, where each // participant owns a namespace but they all share one catalog Name. // -// This lives here, rather than in the binary that wants it, because it is the -// rule two binaries need and only one of them had. A relay serving MSF must -// retain catalogs longer than media: a catalog is published once on join and -// republished only when tracks change, so under the default 30-second -// retention it is evicted from the cache within the first minute of a call. -// After that a participant who joins later gets nothing from the fill fetch -// stream that backfills it — and since the live SUBSCRIBE starts at the -// largest object, they never learn that participant's nickname, version or -// tracks at all. The bug is invisible from the publisher's side, because the -// people already in the room are unaffected. +// An MSF relay needs it for catalogs: published once on join, they would +// otherwise expire under the default retention and a late joiner's fill fetch +// stream would find nothing. // // The choice of which Name and how long still belongs to the binary; only the // shape of the predicate is shared. diff --git a/pkg/relay/discovery/discovery.go b/pkg/relay/discovery/discovery.go index 8c12713a..c1b2dcfb 100644 --- a/pkg/relay/discovery/discovery.go +++ b/pkg/relay/discovery/discovery.go @@ -125,10 +125,8 @@ type NamespaceEvent struct { // // Implementations MUST honor ctx cancellation and deadlines on every // call: the relay's registries invoke Publish/Unpublish while holding -// their internal locks (that is what keeps the store's record order -// consistent with registry state), bounding each call with a short -// deadline. A backend that ignores ctx and blocks on a dead network -// connection would stall the whole registry, not just the call. +// their internal locks (keeping store order consistent with registry +// state), so a backend that ignores ctx stalls the whole registry. // // Close releases backend resources (network connections, goroutines). // Watch channels MUST be drained or their owning context cancelled @@ -168,10 +166,7 @@ type DiscoveryStore interface { // it returns every advertisement whose Prefix extends (is at or below) // prefix. A query for ["a"] matches advertised prefixes ["a"], ["a","b"], // and ["a","b","c"]; ["x"] does NOT match. A zero-length prefix matches - // every advertisement. It answers "which namespaces advertised across the - // deployment fall under this SUBSCRIBE_NAMESPACE prefix?". The relay no - // longer calls it (its namespace registry is seeded from WatchNamespaces); - // it stays for other consumers of the interface. + // every advertisement. The relay itself seeds from WatchNamespaces instead. FindNamespacesUnder(ctx context.Context, prefix wire.TrackNamespace) ([]NamespaceInfo, error) // WatchTracks returns a channel that first delivers the current set of @@ -180,38 +175,27 @@ type DiscoveryStore interface { // backend observes (local + remote), until ctx is cancelled or the store // is closed. The channel is closed when the watch ends. // - // The snapshot→follow handoff is gapless: across it no event is missed or - // duplicated. A consumer that wants "current state plus every change from - // here on" therefore needs only this call, never a separate Find followed - // by a Watch (which would race any event landing between the two). + // The snapshot→follow handoff is gapless: no event is missed or + // duplicated across it, so no separate Find is needed. // - // The initial snapshot is delivered in full — a consumer interested only in - // deltas can skip everything up to OpSnapshotDone, and one that restarts a - // watch can reconcile what it knew against the new snapshot. For events - // after the snapshot a slow consumer must not block other watchers, and - // must not silently miss one either: a backend that cannot deliver a live - // event to a watcher MUST end that watch (close its channel) instead of - // dropping the event, so the consumer notices and re-watches. + // The snapshot is delivered in full. After it, a slow consumer must not + // block other watchers, and a backend that cannot deliver a live event + // MUST end that watch (close its channel) rather than drop the event, so + // the consumer notices and re-watches. WatchTracks(ctx context.Context) (<-chan TrackEvent, error) // WatchNamespaces streams namespace events. Same snapshot-then-follow // contract as WatchTracks. WatchNamespaces(ctx context.Context) (<-chan NamespaceEvent, error) - // Withdraw removes every advertisement this store published for relayAddr. - // It is the graceful-shutdown counterpart of the Publish calls: a relay - // calls it before it stops accepting connections so peers stop resolving it - // as an upstream while it drains (§3.6) rather than dialing an endpoint that - // is about to close. Peers observe the removals as OpUnpublish events on - // their watches, exactly as they would individual Unpublish calls. + // Withdraw removes every advertisement published for relayAddr, so peers + // stop resolving it as an upstream while it drains (§3.6). Peers observe + // the removals as OpUnpublish events. // // Withdraw is terminal for that address's advertising side: afterwards - // PublishTrack / PublishNamespace for relayAddr MUST NOT restore an - // advertisement, and MUST return [ErrWithdrawn] — a publisher arriving while - // the relay drains must not put it back into the fabric. Everything else - // stays usable: the relay keeps resolving *other* relays' advertisements - // through Find / Watch for the rest of its drain, and the per-track - // Unpublish calls that session teardown issues degrade to no-ops. + // PublishTrack / PublishNamespace for relayAddr MUST return [ErrWithdrawn] + // without restoring anything. Find / Watch stay usable, and Unpublish + // calls for relayAddr become no-ops. // // Withdrawing an address that advertised nothing, or withdrawing twice, is a // silent no-op. Unlike Close, Withdraw releases no backend resources. diff --git a/pkg/relay/discovery/memory.go b/pkg/relay/discovery/memory.go index e94b8899..698b8eb1 100644 --- a/pkg/relay/discovery/memory.go +++ b/pkg/relay/discovery/memory.go @@ -26,13 +26,8 @@ var ErrClosed = errors.New("discovery: store closed") // relay is shutting down, and re-advertising it would undo the withdrawal. var ErrWithdrawn = errors.New("discovery: relay withdrawn") -// defaultWatchBufferSize bounds the per-watcher event channel: how many live -// events a watcher may fall behind before its watch is ended (see -// [DiscoveryStore.WatchTracks]). The size is a compromise between burst tolerance -// and memory pressure under a misbehaving subscriber; 32 is large enough -// to absorb typical bursty publish patterns and small enough that a -// stalled consumer is noticed within a few seconds at typical event -// rates. +// defaultWatchBufferSize is how many live events a watcher may fall behind +// before its watch is ended (see [DiscoveryStore.WatchTracks]). const defaultWatchBufferSize = 32 // MemoryStore is the in-process [DiscoveryStore] for single-relay @@ -41,12 +36,8 @@ const defaultWatchBufferSize = 32 // behaves identically to one with no discovery at all. Distributed // backends (NATS / Redis) replace this without touching relay internals. // -// Concurrency: the store is safe for concurrent use. Internally a -// single sync.RWMutex guards the maps and watcher lists — readers -// (Find*, Watch*) take the RLock; writers (Publish/Unpublish/Close) -// take the Lock. Watch delivery itself is non-blocking: the publish -// path sends on the watcher channel with a default case so a slow -// consumer cannot stall the publisher. +// The store is safe for concurrent use. Watch delivery is non-blocking, so +// a slow consumer cannot stall a publisher. type MemoryStore struct { mu sync.RWMutex tracks map[trackEntryKey]TrackInfo @@ -76,9 +67,9 @@ type namespaceEntryKey struct { addr string } -// NewMemoryStore constructs an empty in-memory store. The optional -// logger is used for warn-level reports when a slow watcher causes -// events to be dropped. A nil logger uses [slog.Default]. +// NewMemoryStore constructs an empty in-memory store. It logs a warning +// when a slow watcher's watch is ended, to [slog.Default] unless a logger +// is set. func NewMemoryStore(opts ...MemoryStoreOption) *MemoryStore { s := &MemoryStore{ tracks: make(map[trackEntryKey]TrackInfo), @@ -127,10 +118,8 @@ func (s *MemoryStore) PublishTrack(_ context.Context, info TrackInfo) error { info.PublishedAt = nowFunc() } s.tracks[trackEntryKey{key: info.Key, addr: info.RelayAddr}] = info - // Send under the lock (non-blocking) so a send can't race a watcher - // channel close (lifecycle / Close, which close under the same lock). - // Count the drops and log AFTER unlocking — a slow logger must not stall - // other store operations while s.mu is held. + // Send under the lock so it cannot race a watcher's close; log after + // unlocking so a slow logger cannot stall the store. dropped := fanout(&s.trackWatch, TrackEvent{Op: OpPublish, Info: info}) s.mu.Unlock() s.warnDropped(dropped, OpPublish, "key", info.Key) @@ -253,13 +242,9 @@ func (s *MemoryStore) FindNamespacesUnder(_ context.Context, prefix wire.TrackNa return out, nil } -// WatchTracks delivers the current tracks as an OpPublish snapshot, then every -// subsequent track event, until ctx is cancelled or the store is closed (see -// [DiscoveryStore.WatchTracks]). Snapshotting and registering happen under the -// same lock, so the handoff is gapless: a publish concurrent with this call -// either lands in the snapshot or fans out to the channel afterwards, never -// both and never neither. The channel is sized to hold the whole snapshot plus -// the usual live headroom (see [WithWatchBufferSize]), so seeding never drops. +// WatchTracks implements [DiscoveryStore.WatchTracks]. Snapshotting and +// registering happen under one lock, which makes the handoff gapless; the +// channel holds the whole snapshot plus the live headroom. func (s *MemoryStore) WatchTracks(ctx context.Context) (<-chan TrackEvent, error) { s.mu.Lock() if s.closed { @@ -297,11 +282,7 @@ func (s *MemoryStore) WatchNamespaces(ctx context.Context) (<-chan NamespaceEven return w.ch, nil } -// Withdraw drops every track and namespace advertisement whose RelayAddr is -// relayAddr, emitting an OpUnpublish for each so watchers converge exactly as -// they would on individual Unpublish calls, and records the address so a later -// Publish returns [ErrWithdrawn] instead of re-advertising it. See -// [DiscoveryStore.Withdraw]. +// Withdraw implements [DiscoveryStore.Withdraw]. func (s *MemoryStore) Withdraw(_ context.Context, relayAddr string) error { s.mu.Lock() if s.closed { @@ -309,9 +290,7 @@ func (s *MemoryStore) Withdraw(_ context.Context, relayAddr string) error { return ErrClosed } s.withdrawn[relayAddr] = struct{}{} - // Deleting the current key while ranging is defined behaviour in Go, and - // the events go out under the lock for the same reason the Publish paths - // do — see [MemoryStore.PublishTrack]. + // Events go out under the lock — see [MemoryStore.PublishTrack]. dropped := 0 for idx, info := range s.tracks { if idx.addr != relayAddr { @@ -354,9 +333,8 @@ func (s *MemoryStore) Close() error { return nil } -// watcher is one watch: the channel its events go to, and done, closed -// together with it so the watch's lifecycle goroutine is released however the -// watch ends — ctx, Close, or overflow. +// watcher is one watch. done closes with ch, releasing the lifecycle +// goroutine however the watch ends: ctx, Close, or overflow. type watcher[T any] struct { ch chan T done chan struct{} @@ -390,14 +368,9 @@ func watchLifecycle[T any](ctx context.Context, mu sync.Locker, watchers *[]*wat w.end() } -// fanout delivers ev to each watcher with a non-blocking send. A watcher whose -// buffer is full is removed and ended rather than skipped: a dropped event -// would leave it silently out of date, while an ended watch is noticed and -// re-watched (see [DiscoveryStore.WatchTracks]). It returns how many watchers -// were ended. It MUST be called with s.mu held: the sends and closes are then -// mutually exclusive with the other ends. The publish path still never blocks -// on a slow watcher; the caller logs the count AFTER releasing s.mu so a slow -// log sink cannot stall the store. +// fanout delivers ev to each watcher with a non-blocking send, ending (not +// skipping) any whose buffer is full, and returns how many it ended. It MUST +// be called with s.mu held, so its sends and closes exclude the other ends. func fanout[T any](watchers *[]*watcher[T], ev T) int { ended := 0 *watchers = slices.DeleteFunc(*watchers, func(w *watcher[T]) bool { @@ -413,10 +386,8 @@ func fanout[T any](watchers *[]*watcher[T], ev T) int { return ended } -// warnDropped logs that n slow watchers had their watch ended, if any. Called -// after s.mu is released so the (potentially blocking) log sink never contends -// the store lock. keyAttr/keyVal carry the identifying field of the event that -// overflowed (e.g. "key"/track.Key or "prefix"/wire.TrackNamespace). +// warnDropped logs that n slow watchers had their watch ended, if any. Call +// it after releasing s.mu. func (s *MemoryStore) warnDropped(n int, op Op, keyAttr string, keyVal any) { if n == 0 { return diff --git a/pkg/relay/handler_datagram.go b/pkg/relay/handler_datagram.go index 7b5e46c5..c27e79c2 100644 --- a/pkg/relay/handler_datagram.go +++ b/pkg/relay/handler_datagram.go @@ -10,28 +10,10 @@ import ( "github.com/floatdrop/moq-go/pkg/relay/internal/registry" ) -// runDatagramLoop is the datagram fanout entry point. It pulls -// [message.ObjectDatagram]s off the session and forwards each to every -// downstream subscriber whose §5.1.2 filter passes, with the Track Alias -// remapped to that subscriber's per-session outbound alias. -// -// Per §11.3 a datagram is a fire-and-forget delivery — the underlying -// transport drops oversized or unschedulable datagrams without notification. -// The loop therefore swallows per-send failures: there is no slow-reader -// escalation analogous to the subgroup path, and there is no -// stream-lifecycle propagation — each datagram is its own self-contained -// §11.4.3-style "stream". -// -// Termination: -// -// - Transport-level errors from [session.Session.ReceiveDatagram] -// (session closed, ctx cancelled, PROTOCOL_VIOLATION on parse) end -// the loop and propagate to [sessionHandler.run]'s aggregator. -// - Per-datagram lookup misses (unknown Track Alias, evicted track entry) -// drop the datagram silently — §11.3 explicitly permits this. -// - A datagram that makes its track malformed ends that track (§2.4.2, -// see [sessionHandler.endMalformedTrack]) and is not forwarded or cached; -// the loop reads on. +// runDatagramLoop forwards each received [message.ObjectDatagram] to the +// downstream subscribers, until a session-level receive error, which it +// returns. Send failures and lookup misses drop the datagram (§11.3); a +// malformed track is ended (§2.4.2) and the loop reads on. func (h *sessionHandler) runDatagramLoop(ctx context.Context) error { for { d, err := h.sess.ReceiveDatagram(ctx) @@ -54,15 +36,12 @@ func (h *sessionHandler) runDatagramLoop(ctx context.Context) error { } } -// handleDatagram is the per-datagram fanout. It mirrors [runFanout]'s -// per-object body but with a flat structure: no per-subscriber writer -// goroutine, no §11.4.3 stream-lifecycle bookkeeping, no ObjectIDDelta -// re-encoding (datagrams carry an absolute Object ID, §11.3.1). +// handleDatagram is the per-datagram counterpart of [runFanout]'s +// per-object body. func (h *sessionHandler) handleDatagram(ctx context.Context, d *message.ObjectDatagram) { in, ok := h.sess.LookupInboundTrack(d.TrackAlias) if !ok { - // §11.3: an unknown Track Alias MAY be dropped or briefly buffered for - // reordering against the establishing control message. We drop. + // §11.3: an unknown Track Alias MAY be dropped. h.log.LogAttrs(ctx, slog.LevelDebug, "datagram: unknown inbound Track Alias", slog.Uint64("alias", d.TrackAlias)) return @@ -75,52 +54,34 @@ func (h *sessionHandler) handleDatagram(ctx context.Context, d *message.ObjectDa return } - // §11.3.1: a DEFAULT_PRIORITY datagram inherits the DEFAULT_PUBLISHER_PRIORITY - // (§12.4) of the message that bound its alias; resolve it before the cache - // and the PRIORITY_FILTER read it. The Type keeps the bit, so the forwarded copy - // still omits the byte. + // §11.3.1: resolve the inherited DEFAULT_PUBLISHER_PRIORITY (§12.4) for + // the cache and PRIORITY_FILTER; the forwarded Type still omits the byte. if d.HasDefaultPriority() { d.PublisherPriority = in.DefaultPublisherPriority } - // §2.1 dedup across redundant upstream publishers, same ledger as the - // subgroup path (handler_fanout): the first copy of {GroupID, ObjectID} - // wins; later copies from peer upstreams are dropped so each subscriber - // sees the object exactly once — and the loser neither re-caches nor - // re-bumps the watermark. + // §2.1: the first copy of {GroupID, ObjectID} wins. if !entry.ClaimDelivered(d.GroupID, d.ObjectID) { return } - // §10.2.17: a forwarded datagram counts towards the track's - // LARGEST_OBJECT watermark just like a subgroup object does. + // §10.2.17 entry.UpdateLargest(message.Location{Group: d.GroupID, Object: d.ObjectID}) - // Cache via the per-track ObjectCache. The cache retains the payload + - // properties BY REFERENCE (see cache.PutDatagram); ReceiveDatagram - // hands out caller-owned buffers, so nothing here mutates them after - // the Put. + // The cache keeps the buffers by reference; nothing mutates them after. entry.Cache.PutDatagram(d, in.MaxCacheDuration, in.HasMaxCacheDuration) downstream := entry.CopyDownstream() for _, sub := range downstream { - // The same decision as the subgroup fanout: a paused subscription - // (Forward State 0) receives no datagrams. With no stream to end, - // only whether to send matters; the skip kinds do not. - // Datagrams have no subgroup; §5.1.4 SUBGROUP_FILTER treats them as - // subgroup 0. Object ID / Priority / Properties feed the other filters. + // §5.1.4: a datagram counts as subgroup 0. if sub.ForwardDecision(d.GroupID, d.ObjectID, 0, d.PublisherPriority, d.Properties) != registry.Forward { continue } - // Re-encode the datagram with the subscriber's outbound - // Track Alias. Per §9.7 the relay does not modify any other - // object fields — Group ID, Object ID, Priority, Properties, - // Status, Payload all forward verbatim, and so does Type, but - // for the one exception below. + // §9.7: only the Track Alias changes, bar the exception below. out := *d out.TrackAlias = sub.TrackAlias // A subscriber without Track Properties (§10.2.21) cannot inherit - // the DEFAULT_PUBLISHER_PRIORITY resolved above, so it is written out. + // DEFAULT_PUBLISHER_PRIORITY, so the priority is written out. if !sub.IncludesProperties() { out.Type &^= message.DatagramDefaultPriorityBit } @@ -132,9 +93,7 @@ func (h *sessionHandler) handleDatagram(ctx context.Context, d *message.ObjectDa err := sub.Session.SendDatagram(&out) sub.EndDatagram() if err != nil { - // Per §11.3 datagrams may be dropped silently when - // the transport can't deliver them; treat send errors - // the same way and log at Debug for postmortem. + // §11.3: datagrams may be dropped. h.log.LogAttrs(ctx, slog.LevelDebug, "datagram: SendDatagram failed", slog.Uint64("sub_id", sub.ID), slog.String("err", err.Error())) diff --git a/pkg/relay/handler_fanout.go b/pkg/relay/handler_fanout.go index b8250cac..15eb1255 100644 --- a/pkg/relay/handler_fanout.go +++ b/pkg/relay/handler_fanout.go @@ -17,71 +17,53 @@ import ( "github.com/floatdrop/moq-go/pkg/relay/internal/registry" ) -// fwdObject pairs a SubgroupObject with its absolute Object ID on the -// inbound stream. The writer goroutine needs both: the ObjectIDDelta on the -// wire is *relative to the previous object on its own stream*, so once -// filtering or §11.4.3 gap-driven stream resets can punch holes in the -// forwarded object sequence the relay MUST re-encode the delta on the -// outbound side or the subscriber's decoded absolute IDs will drift. +// fwdObject pairs a SubgroupObject with its absolute Object ID: filtering +// punches holes in the forwarded sequence, so the writer re-encodes the +// §11.4.2 ObjectIDDelta against its own outbound stream. type fwdObject struct { obj *message.SubgroupObject absID uint64 enqueuedAt time.Time // stamped in publish; used for the §8 lag window - // maxCacheAge is the MAX_CACHE_DURATION (§12.3) of the upstream the - // Object arrived through, zero when it sent none or 0: the Object is not - // forwarded once it is older than that. + // maxCacheAge is the upstream's MAX_CACHE_DURATION (§12.3), zero for no + // limit: the Object is not forwarded once older than that. maxCacheAge time.Duration - // first marks the subgroup's true first object: the first object read - // off an inbound stream whose header had the §11.4.2 FIRST_OBJECT bit - // set. A writer whose outbound stream begins with this object — and - // only such a stream — sets FIRST_OBJECT on its own header. + // first marks the subgroup's true first object (§11.4.2 FIRST_OBJECT); + // only an outbound stream beginning with it sets the bit. first bool } -// subgroupWriterSet is the parent-managed payload of a -// [registry.SharedSubgroup]: the one outbound writer per downstream subscriber -// for a single (GroupID, SubgroupID), shared across every inbound runFanout -// goroutine producing that Subgroup (including redundant upstream publishers). -// All access is serialised by the [registry.SharedSubgroup.Mu] the registry -// hands back, so two contributors never double-open a writer or race the -// joiner scan. +// subgroupWriterSet is the payload of a [registry.SharedSubgroup]: one +// outbound writer per downstream subscriber for a (GroupID, SubgroupID), +// shared by every inbound stream contributing that Subgroup. All access holds +// [registry.SharedSubgroup.Mu]. // -// The map key is the *registry.DownstreamSub pointer: the sub's identity is -// exactly what the writer serves, with no ID indirection (IDs are globally -// unique since allocSubID went process-wide, but the pointer needs no lookup). -// A nil value records "sub wasn't Established when scanned" so we don't retry. +// A nil writer records a sub that was not Established when scanned, so it is +// not retried. type subgroupWriterSet struct { writers map[*registry.DownstreamSub]*subgroupWriter - // hdr is the canonical SUBGROUP_HEADER (the first contributor's), reused for - // every writer open so joiners added by a redundant contributor get the same - // Group/Subgroup framing. TrackAlias is overwritten per subscriber. + // hdr is the first contributor's SUBGROUP_HEADER, reused for every writer; + // TrackAlias is overwritten per subscriber. hdr message.SubgroupHeader - // gen is the entry.downstreamGen observed on the last joiner scan, so the - // O(len(Downstream)) scan is skipped while membership is unchanged. + // gen is the downstream generation at the last joiner scan; the scan is + // skipped while it is unchanged. gen uint64 - // sawClean records that at least one contributor ended its inbound stream - // cleanly (io.EOF). With redundant upstreams a clean completion of the - // Subgroup is authoritative: the merged outbound stream then FINs even if a - // peer upstream reset. resetCode is the §3.3.4 code used only when NO - // contributor ended cleanly (every upstream reset). These are written by each - // contributor at release under the SharedSubgroup mutex. + // sawClean records that some contributor ended cleanly, so the merged + // stream FINs even if a peer reset; resetCode is used only when every + // contributor reset. sawClean bool resetCode moqt.StreamResetCode } -// resolveImplicitSubgroupID handles §11.4.2 SUBGROUP_ID_MODE 0b01, where the -// Subgroup ID equals the stream's first Object ID: it reads the first object -// and rewrites hdr in place to the explicit form. The returned pending -// object must be processed as the stream's first (its delta is the absolute -// ID). Headers in any other mode pass through untouched with (nil, true). +// resolveImplicitSubgroupID handles §11.4.2 SUBGROUP_ID_MODE 0b01 (Subgroup ID +// = first Object ID): it reads the first object and rewrites hdr to the +// explicit form. The returned pending object must be processed as the +// stream's first. Other modes return (nil, true). // -// ok=false means the stream ended before its identity resolved — an empty -// 0b01 stream (clean EOF) has nothing to forward, and a read error means the -// stream died; there is no subgroup state to join or tear down yet, so the -// caller just returns. A malformed first Object ends the track (§2.4.2). +// ok=false means the stream ended first; the caller just returns. A malformed +// first Object ends the track (§2.4.2). func (h *sessionHandler) resolveImplicitSubgroupID( ctx context.Context, entry *registry.TrackEntry, @@ -92,11 +74,8 @@ func (h *sessionHandler) resolveImplicitSubgroupID( return nil, true } if hdr.ReplayingSubgroup { - // §11.4.2's receiver rule is mechanical (Subgroup ID = first - // object on the stream), but on a replay the first object is not - // necessarily the subgroup's first — the implied ID is only as - // reliable as the sender. Worth a trace when it leads to - // mis-keyed subgroups. + // On a replay the first object need not be the subgroup's first, + // so the implied ID is only as reliable as the sender. h.log.LogAttrs(ctx, slog.LevelDebug, "fanout: implicit-first-object Subgroup ID on a replay stream", slog.Uint64("group", hdr.GroupID)) @@ -112,9 +91,7 @@ func (h *sessionHandler) resolveImplicitSubgroupID( h.log.LogAttrs(ctx, slog.LevelDebug, "fanout: inbound stream ended before first-object Subgroup ID resolved", slog.String("err", err.Error())) - // Stop a publisher still writing into a stream nobody reads - // (a STOP_SENDING on an already-reset stream is a transport - // no-op). + // Stop a publisher still writing into a stream nobody reads. stream.Cancel(moqt.StreamResetInternalError) } return nil, false @@ -124,22 +101,15 @@ func (h *sessionHandler) resolveImplicitSubgroupID( return obj, true } -// A subgroup stream can reach the relay before the SUBSCRIBE_OK that binds its -// Track Alias: the publisher may start sending as soon as it accepts the -// SUBSCRIBE (§11.1: "Objects can be sent before the Subscriber knows the Track -// Alias"). §11.4.2 lets the receiver "abandon the stream, or choose to buffer it -// for a brief period to handle reordering with the control message that -// establishes the Track Alias". The relay leaves such a stream unread for up -// to earlyAliasWait, then abandons it. +// A subgroup stream can arrive before the SUBSCRIBE_OK binding its Track Alias +// (§11.1). §11.4.2 lets the receiver "abandon the stream, or choose to buffer +// it for a brief period"; the relay leaves it unread for up to earlyAliasWait, +// then abandons it. // -// The wait is also what breaks a flow-control deadlock. §11.4.2 requires -// endpoints to "allocate connection flow control to the control streams before -// allocating it to any data streams", which the bundled transports do not do, -// so enough unread early data can hold back the SUBSCRIBE_OK itself; resetting -// the streams at the deadline releases it. maxEarlyStreams caps how many -// streams per session wait at once; past it they are abandoned at once, so a -// peer's aliases that never resolve hold at most that many streams, each for -// at most earlyAliasWait. +// The deadline also breaks a flow-control deadlock: the bundled transports do +// not reserve connection credit for control streams (§11.4.2), so unread +// early data can hold back the SUBSCRIBE_OK itself. maxEarlyStreams caps the +// waiting streams per session; past it they are abandoned at once. const ( earlyAliasWait = time.Second maxEarlyStreams = 32 @@ -151,11 +121,8 @@ const ( var testHookEarlyStreamWaiting atomic.Pointer[func(alias uint64)] // resolveInboundTrack returns what stream's Track Alias is bound to, waiting -// for the binding within the bounds above when it is not registered yet. When -// the alias stays unknown it abandons the stream and reports false: with -// EXCESSIVE_LOAD when maxEarlyStreams streams were already waiting (§3.3.4: -// "The endpoint is overloaded and is resetting this stream"), otherwise with -// INTERNAL_ERROR. +// within the bounds above. An unresolved alias abandons the stream and reports +// false: EXCESSIVE_LOAD (§3.3.4) past maxEarlyStreams, else INTERNAL_ERROR. func (h *sessionHandler) resolveInboundTrack( ctx context.Context, stream *session.IncomingSubgroupStream, @@ -184,24 +151,15 @@ func (h *sessionHandler) resolveInboundTrack( return in, ok } -// runFanout is the subgroup-stream fanout entry point. One inbound -// SUBGROUP_HEADER stream produces one or more outbound SUBGROUP_HEADER -// streams per downstream subscriber, with the publisher's Track Alias -// remapped to the subscriber's per-session outbound alias. +// runFanout forwards one inbound subgroup stream to every downstream +// subscriber, remapping the Track Alias per subscriber. // -// §9.5 multiple publishers: many inbound streams may carry the same -// (GroupID, SubgroupID) — independent publishers, a switchover overlap, or -// redundant origins. They share ONE outbound writer per subscriber (§2.2 forbids -// splitting a Subgroup across streams) via the entry's [registry.SharedSubgroup], -// and the §2.1 dedup ledger ([registry.TrackEntry.ClaimDelivered]) drops the -// second and later copy of each {GroupID, ObjectID} so the subscriber sees each -// object exactly once. A single publisher is just the one-contributor case. -// -// The per-subscriber forward path runs in a dedicated [subgroupWriter] -// goroutine fed by a bounded send queue: §5.1.2 filters are evaluated -// pre-enqueue and ObjectIDDelta re-encoded outbound so drops don't shift the -// subscriber's absolute IDs. The inbound FIN-vs-reset distinction propagates to -// the outbound streams only when the LAST contributor leaves. +// §9.5: inbound streams carrying the same (GroupID, SubgroupID) share one +// outbound writer per subscriber (§2.2: a Subgroup is not split across +// streams), and [registry.TrackEntry.ClaimDelivered] drops duplicate objects +// (§2.1). Each writer is a [subgroupWriter] goroutine behind a bounded queue. +// The inbound FIN-vs-reset reaches the outbound streams only when the last +// contributor leaves. func (h *sessionHandler) runFanout(ctx context.Context, stream *session.IncomingSubgroupStream) { hdr := stream.Header @@ -213,53 +171,36 @@ func (h *sessionHandler) runFanout(ctx context.Context, stream *session.Incoming entry, ok := h.tracks.Get(key) if !ok { - // Alias was registered but the entry has since been removed — - // the subscription terminated between alias registration and - // the first object arriving. + // The subscription ended after the alias was registered. h.log.LogAttrs(ctx, slog.LevelDebug, "fanout: track entry gone, dropping stream", slog.Uint64("alias", hdr.TrackAlias)) stream.Cancel(moqt.StreamResetInternalError) return } - // §11.4.2: a DEFAULT_PRIORITY header omits the Priority byte and inherits - // the DEFAULT_PUBLISHER_PRIORITY (§12.4) of the SUBSCRIBE_OK or PUBLISH - // that bound this alias — captured by the session with the alias, so it is - // right even inside the #85 window and per upstream when several publish - // the track. Resolve it here so the cache (and thus FETCH, which must spell - // it out), the PRIORITY_FILTER and §7.2 scheduling all see the inherited - // value, not a zero byte. The outbound header keeps InlinePriority false, - // so nothing changes on the wire, except for a subscriber that asked for - // no Track Properties (see openWriterForSub). + // §11.4.2: a header without a Priority byte inherits the alias's + // DEFAULT_PUBLISHER_PRIORITY (§12.4). Resolve it once so the cache, FETCH, + // PRIORITY_FILTER and §7.2 scheduling see it; the outbound header still + // omits the byte (see openWriterForSub for the exception). if !hdr.InlinePriority { hdr.PublisherPriority = in.DefaultPublisherPriority } - // One TrackRef for the whole stream: it allocates, and everything below - // that reports it does so per object. + // One TrackRef per stream: it allocates, and is reported per object. ref := h.trackRef(entry.FullName) - // §12.3 bounds live forwarding by the MAX_CACHE_DURATION of the upstream - // this stream came from; a present 0 limits only serving from the cache. + // §12.3: a MAX_CACHE_DURATION of 0 limits only serving from the cache. var liveMaxAge time.Duration if in.HasMaxCacheDuration { liveMaxAge = in.MaxCacheDuration } - // §11.4.2 mode 0b01: the Subgroup ID is implied by the stream's FIRST - // object's ID. Everything from here on keys on hdr.SubgroupID — the - // shared-subgroup key, the cache (and thus FETCH responses), and the - // outbound header template — so resolve it before touching any of that. - // The pre-read object is fed through the normal loop below. + // Everything below keys on hdr.SubgroupID, so resolve it first. pending, ok := h.resolveImplicitSubgroupID(ctx, entry, stream, &hdr) if !ok { return } - // Join (or create) the shared fan-out state for this (group, subgroup). The - // first contributor opens writers for the current Downstream snapshot; - // redundant contributors reuse the existing set and only add joiners / - // deliver deduped objects. sgKey := registry.SubgroupKey{Group: hdr.GroupID, Subgroup: hdr.SubgroupID} sg, created := entry.AcquireSubgroup(sgKey, func() any { return &subgroupWriterSet{ @@ -270,10 +211,8 @@ func (h *sessionHandler) runFanout(ctx context.Context, stream *session.Incoming set, _ := sg.Set.(*subgroupWriterSet) if created { - // Open initial writers from the current Downstream snapshot, under - // sg.Mu so a concurrent contributor's joiner scan can't double-open. - // Per §9.7 we drain even with zero subscribers (publisher flow control); - // the per-object joiner scan picks up subs that join mid-stream. + // Under sg.Mu so a concurrent contributor's joiner scan can't + // double-open. The stream is drained even with no subscribers (§9.7). initialSubs, gen := entry.CopyDownstreamWithGen() pubTimeouts := entry.DeliveryTimeouts() sg.Mu.Lock() @@ -284,21 +223,15 @@ func (h *sessionHandler) runFanout(ctx context.Context, stream *session.Incoming sg.Mu.Unlock() } - // inboundReset records THIS contributor's termination mode: false on clean - // io.EOF, true on any other read error. inboundResetCode is the §3.3.4 code. - // They are applied to the outbound streams only when this is the LAST - // contributor to leave the Subgroup — a single publisher dropping out (clean - // or reset) while others still feed the Subgroup must not disturb the - // subscribers' streams (§9.5 fault tolerance). + // This contributor's termination, applied outbound only if it is the last + // to leave the Subgroup (§9.5). var ( inboundReset bool inboundResetCode = moqt.StreamResetCancelled ) defer func() { - // Record this contributor's outcome into the shared set before we drop - // our reference, so the last contributor can decide FIN vs reset over ALL - // contributors (§11.4.3 redundancy: a clean completion by any upstream - // FINs the merged stream even if a peer reset). + // Record the outcome before releasing, so the last contributor decides + // FIN vs reset over all of them. sg.Mu.Lock() if inboundReset { set.resetCode = inboundResetCode @@ -310,8 +243,6 @@ func (h *sessionHandler) runFanout(ctx context.Context, stream *session.Incoming sg.Mu.Unlock() return // other upstreams still feed this Subgroup — leave writers up. } - // Last contributor: close and drain every downstream writer. FIN if any - // upstream completed cleanly; otherwise reset with the recorded code. reset := !set.sawClean code := set.resetCode ws := make([]*subgroupWriter, 0, len(set.writers)) @@ -320,10 +251,8 @@ func (h *sessionHandler) runFanout(ctx context.Context, stream *session.Incoming continue } wReset, wCode := reset, code - // §11.4.3: a Subgroup whose group has fallen outside the - // subscription's range (e.g. a REQUEST_UPDATE narrowed it) MUST - // be reset, not FIN'd, even on a clean inbound EOF — a FIN would - // falsely signal the group was fully delivered. + // §11.4.3: a group now outside the subscription's range is + // reset, not FIN'd. if !wReset && registry.GroupOutOfRange(hdr.GroupID, w.sub.GetFilter()) { wReset, wCode = true, moqt.StreamResetCancelled } @@ -336,17 +265,13 @@ func (h *sessionHandler) runFanout(ctx context.Context, stream *session.Incoming var ( firstObj = true - // terminalSeen records that a terminal-status object (EndOfGroup / - // EndOfTrack) has been seen on this Subgroup stream. Per §11.4.3 - // no further objects may follow it; one that does makes the track - // malformed (§2.4.2). Tracked per inbound stream so a redundant - // upstream's own terminal accounting is independent. + // terminalSeen: an EndOfGroup/EndOfTrack was read on this inbound + // stream; any later object makes the track malformed (§11.4.3, + // §2.4.2). terminalSeen bool ) for { - // The first object of a mode-0b01 stream was already read during - // Subgroup ID resolution above. obj, err := pending, error(nil) pending = nil if obj == nil { @@ -357,9 +282,7 @@ func (h *sessionHandler) runFanout(ctx context.Context, stream *session.Incoming return // clean end of stream — last contributor will FIN. } if errors.Is(err, context.Canceled) { - // ctx cancellation is treated as a reset — the - // session is going away and we can't safely - // FIN the outbound streams. + // The session is going away: reset, never FIN. inboundReset = true return } @@ -372,18 +295,12 @@ func (h *sessionHandler) runFanout(ctx context.Context, stream *session.Incoming } h.log.LogAttrs(ctx, slog.LevelDebug, "fanout: inbound ReadObject failed", slog.String("err", err.Error())) - // An unparseable object (not a transport reset) leaves the - // publisher still writing; stop it. On an already-reset - // stream the STOP_SENDING is a transport no-op. + // An unparseable object leaves the publisher writing; stop it. stream.Cancel(moqt.StreamResetInternalError) inboundReset = true return } - // §11.4.3 / §2.4.2: an object after a terminal-status object on the - // same Subgroup stream makes the track malformed. Reset the inbound and - // (if last) outbound streams with MALFORMED_TRACK rather than - // forwarding, and end the track. if terminalSeen { h.log.LogAttrs(ctx, slog.LevelDebug, "fanout: object after EndOfGroup/EndOfTrack — malformed track", @@ -396,27 +313,15 @@ func (h *sessionHandler) runFanout(ctx context.Context, stream *session.Incoming return } - // §11.4.2: the subgroup's true first object is the first object on - // an inbound stream whose header carried the FIRST_OBJECT bit - // (ReplayingSubgroup false). Writers use this to set the bit on - // their own outbound headers only when their stream really begins - // with it. isTrueFirst := firstObj && !hdr.ReplayingSubgroup firstObj = false objectID := stream.ObjectID() // resolved by ReadObject (§11.4.2) - // §11.4.3: terminal status is tracked per inbound stream regardless of - // whether this copy wins the dedup claim below, so a post-terminal object - // on THIS stream is still caught at the top of the next iteration. + // Tracked whether or not this copy wins the dedup claim below. terminal := obj.IsTerminal() - // §2.1 dedup across redundant upstreams: claim {GroupID, ObjectID} on the - // entry's persistent, group-windowed ledger. The first upstream to reach an - // object forwards it; a later copy from a peer — even one that is lagging, - // or that arrives on a fresh stream after the first upstream's stream has - // already FIN'd — is dropped here so the subscriber sees each object once. - // Done outside sg.Mu (its own lock) so dedup losers never touch the writer - // set. + // §2.1: the first upstream to deliver {GroupID, ObjectID} forwards it. + // Outside sg.Mu, so dedup losers never touch the writer set. if !entry.ClaimDelivered(hdr.GroupID, objectID) { if terminal { terminalSeen = true @@ -424,19 +329,15 @@ func (h *sessionHandler) runFanout(ctx context.Context, stream *session.Incoming continue // redundant copy already forwarded by a peer upstream. } - // Counted after the dedup claim, so this is objects the relay is - // actually responsible for delivering — not raw wire arrivals, which - // on a redundantly-fed track would double-count. + // Counted after the dedup claim, so redundant copies don't count. h.metrics.ObjectReceived(ref, hdr.SubgroupID) - // Deliver to the shared writer set under sg.Mu so joiner detection, writer - // open, and the publish loop are atomic against a concurrent contributor - // and the last-contributor teardown. + // Under sg.Mu: joiner detection, writer open and publish are atomic + // against other contributors and the last-contributor teardown. sg.Mu.Lock() - // Cache the object (for FETCH and fill fetch streams) before bumping LARGEST_OBJECT so - // a concurrent handleSubscribe-then-FETCH that snapshots the new watermark - // always finds it cached. + // Cache before bumping LARGEST_OBJECT, so a FETCH that snapshots the + // new watermark finds the object cached. entry.Cache.Put(&cache.CachedObject{ GroupID: hdr.GroupID, ObjectID: objectID, @@ -451,12 +352,9 @@ func (h *sessionHandler) runFanout(ctx context.Context, stream *session.Incoming HasMaxCacheDuration: in.HasMaxCacheDuration, }) - // Atomically bump §10.2.17 LARGEST_OBJECT and snapshot any Downstream - // subs that joined since the last scan. The entry.mu acquisition inside - // serialises with handleSubscribe's AddDownstreamSnapshotLargest: a new - // sub either snapshots the pre-update Largest AND appears in newSubs - // (delivered live below), or snapshots the post-update Largest (its - // fill fetch stream covers this object — already cached above). + // Serialised with AddDownstreamSnapshotLargest: a new sub either saw + // the old Largest and appears in newSubs (delivered live), or saw the + // new one (its fill fetch stream covers this object). loc := message.Location{Group: hdr.GroupID, Object: objectID} var newSubs []*registry.DownstreamSub newSubs, set.gen = entry.UpdateLargestAndDetectNew(loc, @@ -465,9 +363,7 @@ func (h *sessionHandler) runFanout(ctx context.Context, stream *session.Incoming h.openWriterForSub(ctx, set.hdr, sub, set.writers, entry.DeliveryTimeouts(), ref) } - // §5.1.2 filter evaluation per-subscriber, pre-enqueue. A filter miss - // means we don't take a queue slot. Per §9.7 the relay does not modify - // the object; it is purely a forwarding gate. + // §5.1.2 filters run before enqueue, so a miss takes no queue slot. for _, w := range set.writers { if w == nil { continue @@ -484,21 +380,12 @@ func (h *sessionHandler) runFanout(ctx context.Context, stream *session.Incoming } } -// openWriterForSub builds a subgroupWriter for sub and records it in writers -// keyed by the *registry.DownstreamSub pointer. If sub is not Established, -// writers[sub] is set to nil so we don't retry. The §11.4.2 FIRST_OBJECT bit -// is not decided here: the writer computes it per outbound stream, from -// whether the first object it actually writes is the subgroup's true first -// (see [fwdObject.first]) — a joiner, a filter that drops the head of the -// subgroup, and a §11.4.3 gap-reopen all end up with the bit clear. +// openWriterForSub starts a subgroupWriter for sub and records it in writers +// (nil when sub is not Established, so it is not retried). // -// Deliberately NO transport I/O happens here: both call sites run under -// sg.Mu — the lock every contributor takes per forwarded object — and -// writing the SUBGROUP_HEADER can block on ONE subscriber's flow control, -// which would stall the entire subgroup's fanout (plus the inbound read -// loop) on one slow peer. The writer goroutine opens the stream and writes -// the header lazily, before the first object it forwards; a subscriber whose -// filter drops every object never gets an empty header-only stream at all. +// No transport I/O here: callers hold sg.Mu, and a header write blocked on +// one subscriber's flow control would stall the whole subgroup. The writer +// opens its stream lazily, before the first object it forwards. func (h *sessionHandler) openWriterForSub( ctx context.Context, hdr message.SubgroupHeader, @@ -516,15 +403,13 @@ func (h *sessionHandler) openWriterForSub( } subHdr := hdr subHdr.TrackAlias = sub.TrackAlias - // A subscriber without Track Properties (§10.2.21) cannot inherit the - // DEFAULT_PUBLISHER_PRIORITY (§12.4) hdr resolved, so it is written out. + // A subscriber without Track Properties (§10.2.21) cannot inherit + // DEFAULT_PUBLISHER_PRIORITY (§12.4), so the priority is written out. if !sub.IncludesProperties() { subHdr.InlinePriority = true } - // ioCtx bounds every blocking stream operation the writer performs - // (open, header write, object writes): cancelIO unblocks a writer - // wedged on a subscriber that stopped reading, so the teardown join - // cannot be held hostage (see [subgroupWriter.join]). + // cancelIO unblocks a writer wedged on a subscriber that stopped + // reading (see [subgroupWriter.join]). ioCtx, cancelIO := context.WithCancel(ctx) w := &subgroupWriter{ sub: sub, @@ -538,11 +423,8 @@ func (h *sessionHandler) openWriterForSub( ref: ref, maxDropsBeforeReset: h.maxDropsBeforeReset, maxLag: h.maxFanoutLag, - // §8: the two halves stay apart. The §12.1 / §12.2 first-object - // override belongs to the publisher's half alone, and - // OutgoingSubgroupStream applies it — being the only party that sees - // the object carrying it — so handing over a pre-merged pair would let - // an override outrank a shorter subscriber timeout. + // §8: kept apart, since the §12.1/§12.2 first-object override + // applies to the publisher's half alone. pubTimeouts: pubTimeouts, subTimeouts: sub.GetDeliveryTimeouts(), } @@ -550,44 +432,21 @@ func (h *sessionHandler) openWriterForSub( h.spawn(w.run) } -// subgroupWriter is the per-subscriber writer goroutine. It consumes -// objects from an inbox channel and writes them to outbound -// SUBGROUP_HEADER streams on the subscriber's session. -// -// §11.4.3 lifecycle: +// subgroupWriter is the per-subscriber writer goroutine: it drains an inbox +// onto outbound subgroup streams on the subscriber's session. // -// - When the next forwarded Object ID is not (prevWrittenID + 1) — i.e. -// the inbound or filter punched a hole — the current outbound stream -// is reset and a fresh one opened. Per §11.4.3 the relay MUST NOT -// forward a non-consecutive Object on an existing subgroup stream. -// - On clean inbound EOF the outbound stream is FIN'd; on inbound error -// (or ctx-cancel) it is reset. -// - When the inbox overflows (publisher fills it faster than the QUIC -// send window drains), the publish path drops the object. Each object -// records its enqueue time; if the writer later dequeues one that waited -// longer than maxLag, the subscriber has fallen too far behind the live -// edge (§8 Delivery Timeouts) and the writer resets its outbound stream -// with TOO_FAR_BEHIND (§3.3.4), transitions the [registry.DownstreamSub] to -// [registry.SubTerminated], and exits. The optional maxDropsBeforeReset cap is a -// coarse backstop on cumulative drops, reset with EXCESSIVE_LOAD instead. -// - When the §8 delivery timeouts elapse, [session.OutgoingSubgroupStream] -// resets this one stream with DELIVERY_TIMEOUT and the writer stops -// forwarding — WITHOUT terminating the subscription. The two escalations -// are not interchangeable, and §3.3.4 is explicit about which is which: -// TOO_FAR_BEHIND says "the corresponding subscription ... is being -// terminated", whereas DELIVERY_TIMEOUT says only "A delivery timeout -// (Section 8) was exceeded for this stream". So a subgroup the publisher marked as -// short-lived expires on its own without costing the subscriber the track, -// which is what lets a publisher stripe disposable data (an enhancement -// layer, say) across subgroups the relay may shed under load. +// - A gap in Object IDs resets the stream and opens a fresh one (§11.4.3). +// - A clean inbound EOF FINs the stream; an inbound error resets it. +// - A full inbox drops the object. An object that waited longer than +// maxLag resets with TOO_FAR_BEHIND and terminates the subscription +// (§3.3.4); the optional maxDropsBeforeReset cap does so with +// EXCESSIVE_LOAD. +// - An elapsed §8 delivery timeout resets only that stream with +// DELIVERY_TIMEOUT; the subscription survives (§3.3.4). type subgroupWriter struct { sub *registry.DownstreamSub - // ctx is writer-scoped: it bounds every blocking stream operation - // (open, header write, object writes). cancelIO cancels it, resetting - // the in-flight stream via the per-stream bridge in reopen — the - // escape hatch for a writer wedged on a subscriber that stopped - // reading (close only closes the inbox, and the §8 lag check runs - // only between dequeues). + // ctx bounds every blocking stream operation; cancelIO resets the + // in-flight stream, unwedging a writer blocked on a stalled subscriber. ctx context.Context cancelIO context.CancelFunc hdr message.SubgroupHeader // template; TrackAlias already remapped @@ -597,16 +456,12 @@ type subgroupWriter struct { done chan struct{} log *slog.Logger metrics Metrics - // ref labels every Metrics call this writer makes. Built once by - // openWriterForSub because constructing it allocates (see - // [sessionHandler.trackRef]) and publish runs per object. + // ref labels every Metrics call; built once, since it allocates. ref TrackRef maxDropsBeforeReset int maxLag time.Duration - // pubTimeouts and subTimeouts are the §8 delivery-timeout halves for this - // (subgroup, subscriber), handed to every outbound stream the writer opens - // and resolved there once the first object's properties are known. Zero - // values disable the corresponding dimension. + // pubTimeouts and subTimeouts are the §8 delivery-timeout halves, resolved + // per outbound stream; zero disables a dimension. pubTimeouts message.DeliveryTimeouts subTimeouts message.DeliveryTimeouts @@ -616,24 +471,18 @@ type subgroupWriter struct { closed bool // set under dropsMu inside close inboundReset bool // set under dropsMu inside close inboundResetCode moqt.StreamResetCode // §3.3.4 reset code when inboundReset; set inside close - // incomplete records that this subscription skipped an Object of the - // Subgroup other than one before its Start Location — a filter, Forward - // State 0, an inbox overflow or an expiry. No stream of this writer can - // then carry the whole Subgroup, so each ends with a reset, not a FIN - // (§11.4.3), with incompleteCode: EXCESSIVE_LOAD once any overflow drop - // happened, CANCELLED otherwise (§3.3.4). Set under dropsMu. + // incomplete records that this subscription skipped an Object after its + // Start Location (filter, Forward State 0, overflow or expiry), so its + // streams end with a reset, not a FIN (§11.4.3), with incompleteCode: + // EXCESSIVE_LOAD after any overflow, else CANCELLED. Set under dropsMu. // - // Objects published before a subscription joined never reach its writer, - // so they do not count: the user chose to treat them like Objects before - // its Start Location, and a joiner's partial stream (FIRST_OBJECT clear) - // may still end with a FIN. + // Interpretation: Objects published before the subscription joined count + // as before its Start Location, so a joiner's stream may still FIN. incomplete bool incompleteCode moqt.StreamResetCode - // lastAdmitted is the Object ID admit last let through (hasAdmitted: - // any), so a SkipBeforeStart above it can only mean the Start was raised - // past Objects already sent. One below it is a straggler from another - // upstream (Object IDs rise per inbound stream, not across contributors) - // and stays exempt. Only touched by admit, under sg.Mu. + // lastAdmitted is the last Object ID admit let through: a SkipBeforeStart + // above it means the Start was raised past sent Objects; one below is a + // straggler from another upstream. Only touched by admit, under sg.Mu. lastAdmitted uint64 hasAdmitted bool } @@ -646,25 +495,16 @@ func (w *subgroupWriter) admit(hdr message.SubgroupHeader, objectID uint64, prop w.lastAdmitted, w.hasAdmitted = objectID, true return true case registry.SkipObject, registry.SkipPaused: - // The Object takes no queue slot (a paused subscription's control - // messages still flow), and the stream stays open: a later Object - // may pass, or Forward State return to 1. But the Subgroup is now - // incomplete for this subscription (§11.4.3). + // The stream stays open for later Objects, but the Subgroup is now + // incomplete (§11.4.3). w.markIncomplete(moqt.StreamResetCancelled) case registry.SkipGroup, registry.SkipEnded: - // The stream will never carry another Object: the subscription has - // narrowed so this whole group is out of range, or it has ended - // (§10.12). Reset it (§11.4.3: "A publisher's decision to end the - // subscription early" among them) once what is queued is written, - // so an ended subscription's PUBLISH_DONE, which waits for its - // streams, can follow. close is idempotent; the teardown still waits - // on w.done. + // No further Object will pass: reset once the queue is written + // (§11.4.3), so a PUBLISH_DONE waiting on the streams can follow. w.close(true, moqt.StreamResetCancelled) case registry.SkipBeforeStart: - // §11.4.3 allows a FIN after omitting these — but one above an - // Object already admitted means a REQUEST_UPDATE raised the Start - // past it: "A REQUEST_UPDATE moving [...] the Start Location to a - // larger Location" MUST reset. + // §11.4.3 allows a FIN after omitting these, unless a REQUEST_UPDATE + // raised the Start past an admitted Object. if w.hasAdmitted && objectID > w.lastAdmitted { w.markIncomplete(moqt.StreamResetCancelled) } @@ -672,9 +512,8 @@ func (w *subgroupWriter) admit(hdr message.SubgroupHeader, objectID uint64, prop return false } -// markIncomplete records that this subscription will not receive the whole -// Subgroup, and the reset code to end its stream with; see -// subgroupWriter.incomplete. The first code recorded stands. +// markIncomplete sets subgroupWriter.incomplete; the first code recorded +// stands, except that EXCESSIVE_LOAD overrides. func (w *subgroupWriter) markIncomplete(code moqt.StreamResetCode) { w.dropsMu.Lock() w.markIncompleteLocked(code) @@ -682,17 +521,15 @@ func (w *subgroupWriter) markIncomplete(code moqt.StreamResetCode) { } func (w *subgroupWriter) markIncompleteLocked(code moqt.StreamResetCode) { - // EXCESSIVE_LOAD overrides: the subscriber expects the omissions its own - // filter or pause makes, but not one the relay's load made (§3.3.4 - // "SHOULD use a relevant error code"). + // The subscriber expects its own filter's omissions, not the relay's + // load (§3.3.4). if !w.incomplete || code == moqt.StreamResetExcessiveLoad { w.incomplete = true w.incompleteCode = code } } -// resetCode is the code to reset the current stream with: the omission's -// (see subgroupWriter.incomplete) when there was one, else CANCELLED. +// resetCode is incompleteCode when incomplete, else CANCELLED. func (w *subgroupWriter) resetCode() moqt.StreamResetCode { w.dropsMu.Lock() defer w.dropsMu.Unlock() @@ -702,16 +539,9 @@ func (w *subgroupWriter) resetCode() moqt.StreamResetCode { return moqt.StreamResetCancelled } -// publish does a non-blocking send onto the inbox, stamping the enqueue time -// so the writer goroutine can measure how long the object waited before it was -// written (the §8 lag window — see [subgroupWriter.run]). On overflow the -// object is dropped; if the optional MaxDropsBeforeReset cap is enabled and -// exceeded, the writer is closed in reset mode so its goroutine terminates the -// subscription. -// -// After close has been called the writer no longer accepts objects; publish -// returns silently in that case to avoid sending on a closed channel -// (which would panic). +// publish enqueues fwd without blocking, stamping its enqueue time for the +// lag check. On overflow the object is dropped, and past maxDropsBeforeReset +// the writer is closed in reset mode. It is a no-op after close. func (w *subgroupWriter) publish(fwd fwdObject) { w.dropsMu.Lock() if w.closed { @@ -728,8 +558,6 @@ func (w *subgroupWriter) publish(fwd fwdObject) { w.metrics.ObjectDropped(w.ref, w.hdr.SubgroupID) w.dropsMu.Lock() w.drops++ - // §11.4.3: the dropped Object is missing downstream; §3.3.4 - // EXCESSIVE_LOAD is why. w.markIncompleteLocked(moqt.StreamResetExcessiveLoad) drops := w.drops capped := w.maxDropsBeforeReset > 0 && w.drops > w.maxDropsBeforeReset @@ -740,41 +568,27 @@ func (w *subgroupWriter) publish(fwd fwdObject) { if capped { w.log.Warn("fanout: subscriber hit MaxDropsBeforeReset cap, terminating", "sub_id", w.sub.ID, "drops", drops) - // Close the inbox; the writer goroutine's post-drain path resets - // the outbound stream with EXCESSIVE_LOAD and terminates the sub. w.close(true, moqt.StreamResetExcessiveLoad) } } } -// run is the writer goroutine. It drains the inbox and writes objects to the -// outbound stream until the inbox is closed, then decides the stream's fate -// (FIN / reset) from the flags close recorded — see the post-drain block below. -// -// If WriteObject fails mid-stream (QUIC-level error) the writer cancels the -// outbound stream, marks writeFailed, and keeps draining until close is called, -// keeping publish non-blocking without a second drain goroutine. // lagging reports whether fwd waited in the queue longer than the §8 lag // window allows. func (w *subgroupWriter) lagging(fwd fwdObject) bool { return w.maxLag > 0 && time.Since(fwd.enqueuedAt) > w.maxLag } -// expired reports whether fwd is older than its MAX_CACHE_DURATION (§12.3), -// past which it must not start being forwarded. Like §8's timeouts, the age -// runs from when the relay finished reading the Object, not from "the -// beginning of the Object" — lenient by the Object's inbound transfer time. +// expired reports whether fwd is older than its MAX_CACHE_DURATION (§12.3). +// Deviation: the age runs from when the relay finished reading the Object, +// not from "the beginning of the Object". func expired(fwd fwdObject) bool { return fwd.maxCacheAge > 0 && time.Since(fwd.enqueuedAt) > fwd.maxCacheAge } -// dropExpired handles an Object skipped by [subgroupWriter.expired]. If the -// current stream has carried nothing yet, its header may claim FIRST_OBJECT for -// an Object that will now never arrive on it — asserting the skipped Objects do -// not exist, where §12.3 makes their state unknown. Reset that header-only -// stream; the next Object opens a fresh one as a replay (§11.4.2). -// -// Either way the Subgroup is now incomplete for this subscription (§11.4.3). +// dropExpired handles an Object skipped by [subgroupWriter.expired]. A +// header-only stream may claim FIRST_OBJECT for it, so it is reset and the +// next Object opens a replay stream (§11.4.2). func (w *subgroupWriter) dropExpired(hasWritten bool) { w.markIncomplete(moqt.StreamResetCancelled) if hasWritten || w.out == nil { @@ -787,9 +601,8 @@ func (w *subgroupWriter) dropExpired(hasWritten bool) { w.closeOut(false, moqt.StreamResetCancelled) } -// closeOut ends the writer's current outbound stream — a FIN when fin, else a -// reset with code — and reports it closed to the subscription, whose -// PUBLISH_DONE waits until every stream it opened is closed (§10.12). +// closeOut FINs or resets the current outbound stream and reports it closed +// to the subscription, whose PUBLISH_DONE waits on its streams (§10.12). func (w *subgroupWriter) closeOut(fin bool, code moqt.StreamResetCode) { if w.out == nil { return @@ -811,6 +624,9 @@ func (w *subgroupWriter) dropOut() { w.sub.StreamClosed() } +// run drains the inbox onto outbound streams until close, then FINs or +// resets the stream from what close recorded. After a write failure it keeps +// draining, so publish never blocks. func (w *subgroupWriter) run() { defer close(w.done) @@ -820,39 +636,20 @@ func (w *subgroupWriter) run() { writeFailed bool ) - // reopen cancels the current outbound stream (if any) and opens a fresh - // one, writing its SUBGROUP_HEADER. Used for the lazy first open and - // when a §11.4.3 gap is detected — the current stream is no longer - // eligible to carry the next forwarded object. The effective §7.2 - // priority is reapplied on the new stream. - // - // first is whether the object about to go out is the subgroup's true - // first object: only then does the header carry the §11.4.2 - // FIRST_OBJECT bit ("the first object in this subgroup stream is the - // first object published in the subgroup by the original publisher"). - // A gap-reopen or a filtered head therefore clears it, and a header - // whose Subgroup ID was implied by its first object (mode 0b01) is - // rewritten to the explicit form — the replayed stream's first object - // would imply the wrong ID. - // - // All blocking I/O is bounded by w.ctx: the open itself via - // OpenSubgroupContext, and the stream's later writes via a - // context.AfterFunc bridge to Cancel. + // reopen resets the current outbound stream (if any) and opens a fresh + // one, for the lazy first open and after a §11.4.3 gap. first sets the + // §11.4.2 FIRST_OBJECT bit; otherwise the stream is a replay. All its + // blocking I/O is bounded by w.ctx. reopen := func(first bool) bool { if w.unbridge != nil { w.unbridge() w.unbridge = nil } - // A gap reopen leaves Objects missing on the stream it resets, so it - // carries the omission's code. w.closeOut(false, w.resetCode()) hdr := w.hdr hdr.ReplayingSubgroup = !first if !first && hdr.SubgroupIDMode == message.SubgroupIDImplicitFirstObject { - // A replay stream's first object would imply the wrong ID, so - // spell the Subgroup ID out. (Defensive: runFanout resolves - // 0b01 headers to the explicit form at ingest, so the template - // should never carry this mode here.) + // A replay stream's first object would imply the wrong ID. hdr.SubgroupIDMode = message.SubgroupIDExplicit } fresh, err := w.openCounted(hdr) @@ -861,12 +658,7 @@ func (w *subgroupWriter) run() { "sub_id", w.sub.ID, "err", err.Error()) return false } - // §8: enforce the delivery timeouts on this stream. - // WithDeliveryTimeouts returns a copy, so the bridge below must cancel - // the copy — both wrap the same SendStream, but only the copy is the - // one this writer goes on to use. Two zero pairs disable both - // dimensions, which is the no-timeout behaviour every existing caller - // had. + // §8: WithDeliveryTimeouts returns a copy; the bridge must cancel it. fresh = fresh.WithDeliveryTimeouts(w.pubTimeouts, w.subTimeouts) w.out = fresh w.unbridge = context.AfterFunc(w.ctx, func() { @@ -882,17 +674,12 @@ func (w *subgroupWriter) run() { if w.unbridge != nil { w.unbridge() } - // Every exit below already closes the stream, so this does nothing - // today. It guards against a future exit that forgets to: a stream - // left unreported would hold the subscription's PUBLISH_DONE for - // good (§10.12). + // Guard: an unreported stream would hold PUBLISH_DONE forever (§10.12). w.closeOut(false, moqt.StreamResetCancelled) }() - // failWrites latches this writer broken: no further stream writes will - // be attempted, and — via w.closed — contributors stop enqueueing (and - // stop counting ObjectForwarded for objects that would be discarded). - // The inbox channel itself is only ever closed by close() under sg.Mu. + // failWrites latches this writer broken and stops contributors + // enqueueing. Only close, under sg.Mu, closes the inbox. var writeFailedLatched bool failWrites := func() { writeFailed = true @@ -906,10 +693,6 @@ func (w *subgroupWriter) run() { var lagExceeded bool for fwd := range w.inbox { - // §8 lag window: how long this object waited in the queue is how far - // behind the live edge the subscriber is. Once that exceeds maxLag the - // subscriber has been unable to keep up for too long — stop draining - // and escalate to a reset below. if w.lagging(fwd) { w.log.Warn("fanout: subscriber exceeded MaxFanoutLag, terminating", "sub_id", w.sub.ID, "lag", time.Since(fwd.enqueuedAt).String()) @@ -921,10 +704,7 @@ func (w *subgroupWriter) run() { continue } - // Lazy first open: openWriterForSub runs under sg.Mu and must not - // perform transport I/O, so the stream (and its SUBGROUP_HEADER - // write, which can block on this subscriber's flow control) happens - // here, on this subscriber's own goroutine. + // Lazy first open, off sg.Mu (see openWriterForSub). if w.out == nil { if !reopen(fwd.first) { failWrites() @@ -932,11 +712,7 @@ func (w *subgroupWriter) run() { } } - // §11.4.3: the relay MUST NOT forward a non-consecutive - // Object on an existing subgroup stream. When the next - // forwarded Object ID isn't prevID + 1 — gap from a filter - // drop, REQUEST_UPDATE end-shift, or out-of-order inbound — - // reset the current outbound stream and open a new one. + // §11.4.3: no non-consecutive Object on an existing stream. if hasWritten && fwd.absID != prevID+1 { w.metrics.SubgroupStreamReset(w.ref, w.hdr.SubgroupID, ResetCauseGap) if !reopen(fwd.first) { @@ -945,49 +721,26 @@ func (w *subgroupWriter) run() { } } - // §12.3: "the relay MUST NOT start forwarding any individual Object - // ... after the specified number of milliseconds has elapsed since - // the beginning of the Object was received". Checked here, right - // before the write, because opening the stream above can block on a - // slow subscriber. Skipping leaves a gap the next forwarded Object - // handles like a filter drop (§11.4.3). + // §12.3: "MUST NOT start forwarding" an expired Object. Checked after + // the open above, which can block. if expired(fwd) { w.dropExpired(hasWritten) continue } - // Re-encode ObjectIDDelta against the previous *forwarded* - // Object ID on this outbound stream. After a fresh stream - // open hasWritten is false and the first object carries its - // absolute ID as the delta. + // Re-encode ObjectIDDelta against this outbound stream (§11.4.2). out := *fwd.obj if !hasWritten { out.ObjectIDDelta = fwd.absID } else { out.ObjectIDDelta = fwd.absID - prevID - 1 } - // §8 measures OBJECT_DELIVERY_TIMEOUT from when this object was - // received, not from when this stream opened. Passing enqueuedAt means - // a subscriber that keeps up is never reset however long it stays - // subscribed, while one whose queue is ageing is cut off on the first - // stale object — which is the distinction the timeout exists to draw. - // - // enqueuedAt approximates §8's instant from above: the clause names the - // FIRST payload byte, and this is stamped once the object has been read - // whole, deduped and cached. The gap is the object's inbound transfer - // time, so the error is always lenient and grows with object size — - // widest on exactly the congested upstream the timeout is there for. - // Closing it means recording the instant in the inbound read, which - // enqueuedAt cannot do alone: it is also the MaxFanoutLag measurement - // below, and that window means time spent queued, not object age. + // §8 measures OBJECT_DELIVERY_TIMEOUT from when the object was + // received. Deviation: enqueuedAt is stamped after the whole object + // was read, not at its first byte, so the timeout is lenient. if err := w.out.WriteObjectReceivedAt(fwd.enqueuedAt, &out); err != nil { - // §8 OBJECT_DELIVERY_TIMEOUT: WriteObject has already reset this - // stream with DELIVERY_TIMEOUT (§3.3.4) and that code is - // stream-scoped — the subgroup is abandoned, the subscription is - // not. Resetting again (with INTERNAL_ERROR) would overwrite a - // reason the subscriber acts on, so this returns before the - // generic branch. The subscription-scoped escalation stays where - // it was: the maxLag / TOO_FAR_BEHIND path after the loop. + // The stream is already reset with DELIVERY_TIMEOUT (§3.3.4); + // resetting again would overwrite that code. if errors.Is(err, session.ErrDeliveryTimeout) { w.log.Debug("fanout: delivery timeout, abandoning subgroup stream", "sub_id", w.sub.ID, "group", w.hdr.GroupID, @@ -1006,9 +759,7 @@ func (w *subgroupWriter) run() { } prevID = fwd.absID hasWritten = true - // §11.4.3: extend the reliable boundary to include this object so a - // later reset (gap-reopen, inbound-reset propagation) still delivers - // the Objects already forwarded on this stream. + // §11.4.3: a later reset still delivers what was written. w.out.MarkReliable() } @@ -1020,16 +771,8 @@ func (w *subgroupWriter) run() { w.dropsMu.Unlock() if lagExceeded || dropCapped { - // §8 slow-reader escalation: the subscriber fell too far behind the - // live edge (lag window) or hit the optional drop cap. Reset the - // outbound subgroup stream and terminate the subscription; the - // subscriber must re-subscribe (likely with a more selective filter or - // lower priority) to resume forwarding. - // - // §3.3.4 reset code: a lag-window breach is precisely TOO_FAR_BEHIND - // (the subscriber can't keep up with the live edge). The cumulative - // drop-cap backstop is server-side resource pressure, so it uses - // EXCESSIVE_LOAD. A lag breach wins if both fired. + // Slow reader: reset and terminate the subscription. §3.3.4: + // TOO_FAR_BEHIND for the lag window, EXCESSIVE_LOAD for the drop cap. resetCode := moqt.StreamResetTooFarBehind cause := ResetCauseTooFarBehind if dropCapped && !lagExceeded { @@ -1037,27 +780,15 @@ func (w *subgroupWriter) run() { cause = ResetCauseExcessiveLoad } w.metrics.SubscriptionResetSlowReader(w.ref, cause) - // Refuse further enqueues so contributors stop stamping objects into - // an inbox nobody drains. The channel itself stays open (publish and - // close serialize under sg.Mu; the writer must not close it from - // here). Objects already queued stay pinned until the whole writer - // becomes unreachable after the last contributor's teardown joins - // this goroutine — bounded by the queue size. + // Refuse further enqueues; only close may close the inbox. w.dropsMu.Lock() w.closed = true w.dropsMu.Unlock() w.closeOut(false, resetCode) - // Also cancel the subscriber's request stream so the - // handleSubscribe goroutine's readSubscribeUpdates loop returns and - // its defer removes this registry.DownstreamSub from the registry.TrackRegistry. - // Without this the sub would linger in registry.SubTerminated state in - // entry.Downstream until the subscriber's session itself - // dies — runFanout would skip it (because !IsEstablished()), - // but the registry entry would stay around. Only when this writer - // ended the subscription: one already terminated has its - // PUBLISH_DONE under way (closeOut may just have released it), and - // resetting the stream now could discard it. + // Cancel the request stream so handleSubscribe unregisters the sub. + // Only if this writer ended it: otherwise a PUBLISH_DONE may be under + // way, and the reset could discard it. if w.sub.Terminate() && w.sub.Stream != nil { w.sub.Stream.CancelRead(uint64(resetCode)) w.sub.Stream.CancelWrite(uint64(resetCode)) @@ -1066,46 +797,32 @@ func (w *subgroupWriter) run() { } if writeFailed { - // Outbound stream is already cancelled (or never opened). return } if w.out == nil { - // Either every object was filtered before the lazy first open (no - // outbound stream ever existed) or a reopen failed; nothing to close. return } if inboundReset { - // Inbound reset/error propagation per §11.4.3 ("Processing a - // reset means that there might be other objects in the - // Subgroup beyond the last one received. A relay might - // immediately reset the corresponding downstream stream..."). - // inboundResetCode carries the §3.3.4 reason (CANCELLED for an - // upstream reset / ctx-cancel, MALFORMED_TRACK for a §11.4.3 - // post-terminal-object violation). + // §11.4.3: "A relay might immediately reset the corresponding + // downstream stream". w.metrics.SubgroupStreamReset(w.ref, w.hdr.SubgroupID, ResetCauseInboundReset) w.closeOut(false, inboundResetCode) return } if incomplete { - // §11.4.3: FIN only after "all objects in a Subgroup" (bar those - // before the Start Location) went out on the stream; otherwise - // "it MUST reset the stream". + // §11.4.3: FIN only after "all objects in a Subgroup". w.closeOut(false, incompleteCode) return } - // Clean inbound FIN propagation: every forwarded object that this - // subscription wanted was delivered, so we FIN the outbound stream - // per §11.4.3. w.closeOut(true, 0) } -// openCounted opens a subgroup stream for w's subscription, counting it for -// the §10.12 PUBLISH_DONE Stream Count and refusing once the subscription has -// terminated, since no stream may follow PUBLISH_DONE. +// openCounted opens a subgroup stream, counting it for the §10.12 Stream +// Count; it fails once the subscription has terminated. func (w *subgroupWriter) openCounted(hdr message.SubgroupHeader) (*session.OutgoingSubgroupStream, error) { if !w.sub.BeginStream() { return nil, errSubscriptionTerminated @@ -1115,12 +832,9 @@ func (w *subgroupWriter) openCounted(hdr message.SubgroupHeader) (*session.Outgo return out, err } -// applyPriority pushes the §7.2 effective priority for this writer's current -// outbound stream into the transport. It is called on stream open and §11.4.3 -// reopen, so a mid-stream SUBSCRIBER_PRIORITY change takes effect on the next -// (re)open rather than in-flight. The key combines the publisher-priority, -// Group ID and Subgroup ID from the inbound header with the subscriber-priority -// and group-order from the subscription (§7.2 rules 1–4). +// applyPriority sets the §7.2 effective priority on the current outbound +// stream. It runs on each (re)open, so a SUBSCRIBER_PRIORITY change applies +// from the next stream. func (w *subgroupWriter) applyPriority() { if w.out == nil { return @@ -1130,18 +844,9 @@ func (w *subgroupWriter) applyPriority() { )) } -// close is idempotent. The reset argument is recorded so the writer -// goroutine's post-drain path can decide between FIN and Cancel on its -// current outbound stream; code is the §3.3.4 reason used when reset is true. -// Multiple callers may race to close; the first to enter the sync.Once wins, -// which matches the §11.4.3 intent: once an outbound stream's fate is decided, -// later changes don't apply. -// -// close never interrupts in-flight stream I/O — even in reset mode the -// writer first drains the objects already queued (they arrived before the -// inbound stream's fate was known and the subscriber is entitled to them). -// A writer that cannot finish because a write is wedged on the subscriber's -// flow control is bounded by [subgroupWriter.join]. +// close closes the inbox, recording whether the writer ends its stream with +// a reset (and code) or a FIN. The first call wins. Queued objects are still +// written; a wedged writer is bounded by [joinWriters]. func (w *subgroupWriter) close(reset bool, code moqt.StreamResetCode) { w.closeOnce.Do(func() { w.dropsMu.Lock() @@ -1154,14 +859,11 @@ func (w *subgroupWriter) close(reset bool, code moqt.StreamResetCode) { } // defaultWriterJoinTimeout bounds [joinWriters] when no MaxFanoutLag is -// configured. It only matters for a writer wedged in a blocking stream -// write (subscriber alive but not reading), so it can be generous. +// configured. const defaultWriterJoinTimeout = 5 * time.Second -// joinTimeout is the escalation deadline for [joinWriters]: a healthy -// writer either finishes its drain within the §8 lag window or terminates -// itself via the lag check, so MaxFanoutLag (when configured) also bounds -// how long a drain can legitimately take. +// joinTimeout is the deadline for [joinWriters]: a healthy writer drains +// within MaxFanoutLag or terminates itself. func (w *subgroupWriter) joinTimeout() time.Duration { if w.maxLag > 0 { return w.maxLag @@ -1169,15 +871,10 @@ func (w *subgroupWriter) joinTimeout() time.Duration { return defaultWriterJoinTimeout } -// joinWriters waits for every writer goroutine to finish after close. A -// writer wedged inside a blocking stream write (open, header, or object — -// the subscriber is alive but not reading) never dequeues again, so neither -// the closed inbox nor the §8 lag check can end it; without a bound the -// caller (the subgroup's last inbound contributor) would be held hostage -// until the subscriber's session dies. All writers share ONE escalation -// deadline: when it expires, every still-running writer's stream I/O is -// cancelled at once — unblocking the wedged writes — so N stalled -// subscribers cost one timeout, not N. +// joinWriters waits for every writer to finish after close. A writer wedged +// in a stream write never dequeues again, so at one shared deadline every +// still-running writer's I/O is cancelled: N stalled subscribers cost one +// timeout, not N. func joinWriters(ws []*subgroupWriter) { if len(ws) == 0 { return diff --git a/pkg/relay/handler_fetch.go b/pkg/relay/handler_fetch.go index 84e945f9..f0ff9d18 100644 --- a/pkg/relay/handler_fetch.go +++ b/pkg/relay/handler_fetch.go @@ -174,9 +174,8 @@ func (h *sessionHandler) fetchRangeFilters( // readFetchUpdates is the follow-up dispatch loop for an established FETCH: // REQUEST_UPDATE (§10.9) routes to [sessionHandler.handleFetchUpdate]; any -// other follow-up is ignored. A requester FIN means no more updates; the -// response is already complete, so the relay FINs back (§3.3.2). Scaffolding -// lives in [readRequestStream]. +// other follow-up is ignored. On the requester's FIN the relay FINs back +// (§3.3.2). func (h *sessionHandler) readFetchUpdates(ctx context.Context, req *session.Request) { updates := h.sess.NewRequestUpdateLimiter() fin := readRequestStream(ctx, h.sess, req.Stream, func(m message.Message) bool { @@ -184,8 +183,7 @@ func (h *sessionHandler) readFetchUpdates(ctx context.Context, req *session.Requ return false } if upd, ok := m.(*message.RequestUpdate); ok { - // §10.2.1: parameters outside a FETCH update's scope are - // session-fatal. + // §10.2.1: out-of-scope parameters are session-fatal. if h.sess.CheckPeerParams(message.ScopeUpdateFetch, upd) != nil { return false } @@ -209,19 +207,13 @@ func (h *sessionHandler) readFetchUpdates(ctx context.Context, req *session.Requ return true }) if fin { - // The requester will send no REQUEST_UPDATE, and the response is - // complete: FIN this side too, which completes the request (§3.3.2). _ = req.Stream.Close() } } -// handleFetchUpdate answers a REQUEST_UPDATE (§10.9) to an in-flight FETCH. -// A FETCH response is a finished snapshot by the time the data stream is -// FIN'd, so the relay has no live parameters to mutate, but it must still -// answer with the single mandated REQUEST_OK. Parameters outside a FETCH -// update's scope (§10.2.1) closed the session before this runs; the ones left -// in scope (SUBSCRIBER_PRIORITY, the delivery timeouts, AUTHORIZATION_TOKEN) -// have nothing to change on a finished snapshot. +// handleFetchUpdate answers a REQUEST_UPDATE (§10.9) to an in-flight FETCH +// with REQUEST_OK: the in-scope parameters have nothing to change on a +// finished snapshot. func (h *sessionHandler) handleFetchUpdate(ctx context.Context, req *session.Request) { if err := req.Reply(&message.RequestOK{}); err != nil { h.log.LogAttrs(ctx, slog.LevelDebug, "FETCH REQUEST_UPDATE_OK write failed", @@ -229,10 +221,9 @@ func (h *sessionHandler) handleFetchUpdate(ctx context.Context, req *session.Req } } -// TODO: §10.2.8 says an out-of-range GROUP_ORDER "MUST close the -// session with PROTOCOL_VIOLATION". The SUBSCRIBE / SUBSCRIBE_TRACKS paths do -// (see [checkGroupOrderParam]); the FETCH path still -// read an invalid value here as Ascending. +// TODO: §10.2.8: an out-of-range GROUP_ORDER MUST close the session, as +// [checkGroupOrderParam] does for SUBSCRIBE; the FETCH path reads it as +// Ascending. // // fetchGroupOrder pulls the GROUP_ORDER parameter (§10.2.8) out of a // FETCH's Parameters list. Defaults to ascending when omitted; the @@ -285,9 +276,8 @@ func capFetchEndLocation(filter *message.LocationFilter, largest message.Locatio // back: the FIFO ring is keyed by arrival, so old backfill would evict live // objects. // -// refusal is non-nil when the upstream's FETCH_OK carried Track Properties -// this relay cannot accept (§2.5.1); the track must not be forwarded, and no -// objects are returned. +// A non-nil refusal (see fetchUpstreamRange) means the track must not be +// forwarded; no objects are returned. func (h *sessionHandler) stitchedFetchObjects( ctx context.Context, entry *registry.TrackEntry, @@ -398,12 +388,9 @@ func (h *sessionHandler) pickFetchUpstream(entry *registry.TrackEntry) *registry // §11.4.4's delta encoding wherever the element after a marker would be // a same-group, lower-Object-ID transition. // -// The one exception is a FETCH_OK whose Track Properties this relay cannot -// accept — an unknown Mandatory Track Property, or ones that do not parse -// (§2.5.1): Session.Fetch has cancelled that fetch, and it is returned as a -// refusal instead, since the track MUST NOT be forwarded at all — as is a -// response Object that makes the track malformed (§2.4.2), wrapping -// [session.ErrMalformedTrack]. +// It returns a refusal instead when the track MUST NOT be forwarded: a +// FETCH_OK with unacceptable Track Properties (§2.5.1), or a response Object +// that makes the track malformed (§2.4.2, wrapping [session.ErrMalformedTrack]). func (h *sessionHandler) fetchUpstreamRange( ctx context.Context, up *registry.UpstreamSub, @@ -415,10 +402,8 @@ func (h *sessionHandler) fetchUpstreamRange( unknownWhole := unknownWholeRange(start, endIncl, order) timedOutWhole := timedOutWholeRange(start, endIncl, order) - // §10.2.5: a value of 0 means "the relay MUST NOT wait for upstream - // delivery and MUST report any unavailable Objects as Timed-Out gaps". - // fillTimeout arrives already resolved (see [resolveFillBudget]), so a zero - // here is the subscriber's explicit 0, not an absent parameter. + // §10.2.5: an explicit 0 means "MUST NOT wait for upstream delivery" + // (fillTimeout is already resolved, see [resolveFillBudget]). if fillTimeout == 0 { return timedOutWhole, nil } @@ -448,10 +433,8 @@ func (h *sessionHandler) fetchUpstreamRange( if err != nil { h.log.LogAttrs(ctx, slog.LevelDebug, "upstream FETCH failed", slog.String("err", err.Error())) - // §2.5.1: a FETCH_OK carrying a Mandatory Track Property this - // relay does not understand (Session.Fetch has cancelled that - // fetch) means the track MUST NOT be forwarded; the caller resets - // the downstream stream. + // §2.5.1: Session.Fetch has cancelled it; the caller resets the + // downstream stream. if isTrackPropertiesErr(err) { return nil, err } @@ -495,8 +478,8 @@ func (h *sessionHandler) fetchUpstreamRange( break // clean FIN: the upstream's gaps are authoritative (§11.4.4) } if errors.Is(err, session.ErrMalformedTrack) { - // §2.4.2: cancel the fetch (fr.Close, deferred) and stop the - // response stream; the caller resets the downstream one. + // §2.4.2: fr.Close (deferred) cancels the fetch; the caller + // resets the downstream stream. fs.Cancel(moqt.StreamResetMalformedTrack) return nil, err } @@ -700,9 +683,8 @@ func mergeFetchObjects(order message.GroupOrder, lower, upper []*cache.CachedObj splice = 0 } // cut is where upper's trailing seam-group run starts. A plain group - // comparison suffices: the only markers in upper are the ones GetRange - // makes for expired Objects (§12.3), each at its own Location, so they - // move with the run like the Objects around them. + // comparison suffices: upper's only markers are GetRange's expired-Object + // markers, each at its own Location. cut := len(upper) for cut > 0 && upper[cut-1].GroupID == seamG { cut-- @@ -771,12 +753,8 @@ func streamFetchObjects( ) for _, o := range objs { - // §12.3: "the relay MUST NOT start forwarding any individual Object - // [...] after" its MAX_CACHE_DURATION; a slow reader can hold the - // stream until a cached Object in it expires. Its state is then - // unknown ("Once Objects have expired from cache, their state - // becomes unknown"), which an End of Unknown Range at its Location - // says; a plain gap would assert non-existence (§11.4.4). + // §12.3: a slow reader can hold the stream until a cached Object + // expires; mark it unknown, since a gap asserts non-existence. if !o.IsRangeMarker() && !o.IsStatusMarker() && expired != nil && expired(o) { o = &cache.CachedObject{GroupID: o.GroupID, ObjectID: o.ObjectID, EndOfUnknownRange: true} } @@ -860,9 +838,7 @@ func streamFetchObjects( "relay: fetch serialization order violation: {%d,%d} after {%d,%d}", o.GroupID, o.ObjectID, prevGroup, prevObject) } - // §11.4.4.1: omit ObjectIDDelta when consecutive (prior + 1); - // otherwise the delta is added to the prior ID as is — no +1, - // unlike the §11.4.2 subgroup rule. + // §11.4.4.1: no +1, unlike the §11.4.2 subgroup rule. if o.ObjectID != prevObject+1 { fo.SerializationFlags |= message.FetchFlagObjectIDDelta fo.ObjectIDDelta = o.ObjectID - prevObject diff --git a/pkg/relay/handler_fill.go b/pkg/relay/handler_fill.go index 5549d369..b6c7278d 100644 --- a/pkg/relay/handler_fill.go +++ b/pkg/relay/handler_fill.go @@ -15,19 +15,14 @@ import ( ) // maybeServeFill opens and serves a fill fetch stream for a subscription when -// the SUBSCRIBE or REQUEST_UPDATE carried FILL_PARAMETERS (§5.1.3), which is -// draft-20's replacement for the Joining FETCH. +// the SUBSCRIBE or REQUEST_UPDATE carried FILL_PARAMETERS (§5.1.3). // -// requestID is the Request ID of the message that asked for the fill — the -// SUBSCRIBE's for an initial fill, the REQUEST_UPDATE's for a later one — and -// it is what the FETCH_HEADER carries, so a subscription can have several fill -// fetch streams open at once, each named by its own Request ID. +// requestID is the Request ID of the message that asked for the fill; the +// FETCH_HEADER carries it, so one subscription can have several fills open. // // It returns an error only for a malformed FILL_PARAMETERS, which the caller -// MUST turn into a session-level PROTOCOL_VIOLATION (§10.2.15). Everything -// else is best-effort: §5.1.3.1 has no REQUEST_ERROR for a fill, so a failure -// is signalled by resetting the stream, and the subscription itself is -// unaffected either way. +// MUST turn into a session-level PROTOCOL_VIOLATION (§10.2.15). Any other +// failure resets the fill stream and leaves the subscription unaffected. func (h *sessionHandler) maybeServeFill( ctx context.Context, sub *registry.DownstreamSub, @@ -44,20 +39,14 @@ func (h *sessionHandler) maybeServeFill( return nil } - // From here the peer has asked for a fill, so §5.1.3.1's failure signal - // applies to everything that can still go wrong: "Because there is no - // REQUEST_ERROR associated with a fill fetch stream, the publisher signals a - // fill failure by resetting the stream; it MUST open a fill fetch stream and - // reset it immediately after the FETCH_HEADER if necessary." + // §5.1.3.1: from here a failure MUST open the fill stream and reset it. fail := func(err error) error { h.resetFillStream(ctx, sub, requestID) return err } - // §5.1.3.1: "A publisher opens a fill fetch stream when it processes a - // SUBSCRIBE or REQUEST_UPDATE that carries FILL_PARAMETERS while Forward - // State is 1." FILL_PARAMETERS arriving while paused opens nothing, and a - // later unpause does not retroactively open one. + // §5.1.3.1: only "while Forward State is 1"; a later unpause does not + // open one retroactively. if sub.ForwardState() != 1 { return nil } @@ -100,12 +89,9 @@ func (h *sessionHandler) maybeServeFill( } fillTimeout := resolveFillBudget(inner) - // §5.1.3: "The fill fetch stream inherits the subscription's parameters, - // including subscriber priority, range filters and authorization; - // parameters carried inside FILL_PARAMETERS override them". A filter type - // named inside overrides the subscription's filter of that type, as a - // REQUEST_UPDATE would (§5.1.4: non-zero replaces, zero-length removes); - // the other types are inherited. + // §5.1.3: the fill "inherits the subscription's parameters". A filter + // type inside FILL_PARAMETERS overrides that type as a REQUEST_UPDATE + // would (§5.1.4); the other types are inherited. rangeFilters := sub.GetRangeFilters() if slices.ContainsFunc(inner, func(p message.Parameter) bool { return message.IsRangeFilterParam(p.Type) }) { rangeFilters, err = rangeFilters.Update(inner) @@ -123,15 +109,12 @@ func (h *sessionHandler) maybeServeFill( return nil } -// TODO(draft-20): §5.1.3.1 also requires "When the subscription is cancelled, -// the publisher MUST reset any open fill fetch streams." That needs a watchdog -// resetting the stream on ctx cancellation mid-write, which is a concurrency -// change worth landing with -race coverage — i.e. with the test slice. +// TODO(draft-20): §5.1.3.1 "When the subscription is cancelled, the publisher +// MUST reset any open fill fetch streams" needs a watchdog on ctx cancellation +// mid-write. -// serveFill writes one fill fetch stream and closes it. §5.1.3.1: the FIN is -// what signals the fill is complete, and because a fill has no REQUEST_ERROR -// of its own, a failure is signalled by resetting the stream — -// [sessionHandler.streamFetchRange] does that on a write error. +// serveFill writes one fill fetch stream; the FIN signals completion +// (§5.1.3.1), and [sessionHandler.streamFetchRange] resets it on a write error. func (h *sessionHandler) serveFill( ctx context.Context, sub *registry.DownstreamSub, @@ -153,9 +136,8 @@ func (h *sessionHandler) serveFill( } // resetFillStream signals a fill failure the only way §5.1.3.1 allows: open the -// fill fetch stream and reset it immediately after the FETCH_HEADER. Without -// it the subscriber cannot tell a failed fill from the legitimate "fill range -// is empty, so no stream" case (§5.1.3), and waits forever. +// fill fetch stream and reset it right after the FETCH_HEADER. Otherwise the +// subscriber cannot tell it from an empty fill range, which opens no stream. func (h *sessionHandler) resetFillStream(ctx context.Context, sub *registry.DownstreamSub, requestID uint64) { out, err := openFillOrFetchStream(h.sess, sub, requestID) if err != nil { @@ -173,8 +155,7 @@ func (h *sessionHandler) resetFillStream(ctx context.Context, sub *registry.Down var errSubscriptionTerminated = errors.New("relay: subscription terminated before the stream opened") // openFillOrFetchStream opens a FETCH_HEADER stream. A fill fetch stream -// belongs to sub's subscription and is counted for its §10.12 PUBLISH_DONE -// Stream Count ("including any fill fetch streams"); a standalone FETCH +// counts toward sub's §10.12 PUBLISH_DONE Stream Count; a standalone FETCH // response passes a nil sub. func openFillOrFetchStream( sess *session.Session, diff --git a/pkg/relay/handler_forward.go b/pkg/relay/handler_forward.go index d089bc2e..1381c7fd 100644 --- a/pkg/relay/handler_forward.go +++ b/pkg/relay/handler_forward.go @@ -12,14 +12,10 @@ import ( ) // forwardTrack is a SUBSCRIBE_TRACKS subscriber's [registry.SubscriberEntry.ForwardTrack]: -// it sends the subscriber a PUBLISH for te (§6.1: "the publisher sends PUBLISH -// messages for tracks within matching namespaces") and serves the subscription -// that opens, on this handler's session. The SUBSCRIBE_TRACKS parameters in -// effect now are "the initial Subscription parameters when a PUBLISH is sent as -// a result of SUBSCRIBE_TRACKS" (§10.20.1); see [registry.TracksParams]. -// -// The subscriber gets one forwarded PUBLISH per track, and none for a track it -// publishes itself or already receives. +// it sends the subscriber a PUBLISH for te (§6.1) with the current +// SUBSCRIBE_TRACKS parameters (§10.20.1) and serves the resulting +// subscription. At most one PUBLISH per track, and none for a track the +// subscriber publishes or already receives. func (h *sessionHandler) forwardTrack(ctx context.Context) func(*registry.SubscriberEntry, *registry.TrackEntry) { return func(sub *registry.SubscriberEntry, te *registry.TrackEntry) { if peerSentGoaway(h.sess) { @@ -29,9 +25,7 @@ func (h *sessionHandler) forwardTrack(ctx context.Context) func(*registry.Subscr if !fullName.Namespace.HasPrefix(sub.Prefix()) { return // a TRACK_NAMESPACE_PREFIX update moved the subscription away } - // §5.1.4: "PUBLISH messages which pass the filter will be forwarded - // while those which do not pass it will not be forwarded nor will any - // Objects." + // §5.1.4: PUBLISHes that fail the Range Filters are not forwarded. tp := sub.TracksParams() if !tp.RangeFilters.MatchesTrack(te.GetProperties()) { return @@ -40,9 +34,7 @@ func (h *sessionHandler) forwardTrack(ctx context.Context) func(*registry.Subscr if te.HasUpstreamOn(h.sess) || te.HasDownstreamOn(h.sess) { return } - // The check above misses a forward whose downstream is not - // registered yet; the claim covers that window, until - // serveForwardedPublish registers it. + // The claim covers a forward whose downstream is not registered yet. key := fullName.Key() if !sub.ClaimForward(key) { return @@ -54,14 +46,12 @@ func (h *sessionHandler) forwardTrack(ctx context.Context) func(*registry.Subscr fwd := &message.Publish{ Namespace: fullName.Namespace, Name: fullName.Name, - // §11.1: aliases are per session; the subscriber's session - // allocates the ones the relay publishes on. + // §11.1: aliases are per session. TrackAlias: h.sess.AllocOutboundTrackAlias(), Parameters: publishParamsForSubscriber(tp, te), TrackProperties: properties, } - // Non-blocking (§6.1): with no bidi-stream credit left the relay - // sends PUBLISH_SKIPPED on the SUBSCRIBE_TRACKS stream instead. + // §6.1: without bidi-stream credit, send PUBLISH_SKIPPED instead. stream, err := h.sess.OpenPublish(fwd) if err != nil { sub.ReleaseForward(key) @@ -78,15 +68,10 @@ func (h *sessionHandler) forwardTrack(ctx context.Context) func(*registry.Subscr } } -// serveForwardedPublish serves the subscription a forwarded PUBLISH opened: a -// downstream on te like a SUBSCRIBE's, registered before the response arrives, -// since "If the FORWARD parameter is omitted or equal to 1, the publisher will -// start transmitting objects immediately, possibly before PUBLISH_OK" (§10.11). -// A REQUEST_ERROR (e.g. UNINTERESTED) ends it; otherwise the subscriber's -// REQUEST_UPDATEs are answered (§10.9) until it cancels or the track ends, -// which sends PUBLISH_DONE (§10.12) through the downstream like any other. -// FILL_PARAMETERS and NEW_GROUP_REQUEST among params apply to this -// subscription as they would to a SUBSCRIBE's. +// serveForwardedPublish serves the subscription a forwarded PUBLISH opened, +// as a downstream on te registered before PUBLISH_OK, since objects may flow +// before it (§10.11). A REQUEST_ERROR ends it; otherwise it +// is served like a SUBSCRIBE's. func (h *sessionHandler) serveForwardedPublish( ctx context.Context, stream session.Stream, @@ -98,9 +83,7 @@ func (h *sessionHandler) serveForwardedPublish( fullName := te.FullName sub := registry.NewDownstreamSub(h.allocSubID(), h.sess, stream, fwd.TrackAlias) sub.OpenedByPublish() - // handleSubscribeTracks refused parameters this would reject. "Delivery - // starts at the Next Object relative to the Largest Object" (§10.11) is - // the live fanout's default. + // handleSubscribeTracks already refused parameters this would reject. _ = installSubscribeParams(sub, params) _, largest, has, added := h.tracks.AddDownstreamSnapshotLargest(fullName, sub) registered() @@ -121,26 +104,18 @@ func (h *sessionHandler) serveForwardedPublish( if _, err := h.sess.AwaitPublishOK(ctx, stream); err != nil { h.log.LogAttrs(ctx, slog.LevelDebug, "forwarded PUBLISH refused", slog.String("name", string(fullName.Name)), slog.String("err", err.Error())) - // The request is over (§3.3.3); end this side too, with no - // PUBLISH_DONE after the subscriber's REQUEST_ERROR. + // §3.3.3: no PUBLISH_DONE after the subscriber's REQUEST_ERROR. sub.EndRefused() return } - // §10.20.1: "To join Tracks initiated via the resulting PUBLISHes, the - // subscriber can specify a Location Filter and optionally include - // FILL_PARAMETERS". Each forwarded subscription gets its own fill fetch - // stream, once the subscriber has accepted the PUBLISH, carrying the - // PUBLISH's Request ID — §10.1: "fetch streams reference the Request ID - // of a SUBSCRIBE, PUBLISH, FETCH, or REQUEST_UPDATE" — the one ID that - // names this subscription alone. (§5.1.3 names only the SUBSCRIBE and - // REQUEST_UPDATE cases.) The SUBSCRIBE_TRACKS was validated, so a - // malformed FILL_PARAMETERS cannot reach here. + // §10.20.1: each forwarded subscription gets its own fill fetch stream, + // named by the PUBLISH's Request ID (§10.1; §5.1.3 names only SUBSCRIBE + // and REQUEST_UPDATE). if err := h.maybeServeFill(ctx, sub, te, fullName, fwd.RequestID, params); err != nil { h.log.LogAttrs(ctx, slog.LevelDebug, "fill fetch stream not opened", slog.String("err", err.Error())) } - // §10.2.19: a NEW_GROUP_REQUEST is handled as for a SUBSCRIBE served - // from the track's existing upstream. + // §10.2.19 if p, ok := params.Find(message.ParamNewGroupRequest); ok { h.propagateNewGroupUpstream(ctx, fullName, p.Varint) } diff --git a/pkg/relay/handler_malformed.go b/pkg/relay/handler_malformed.go index a833ad42..e250084a 100644 --- a/pkg/relay/handler_malformed.go +++ b/pkg/relay/handler_malformed.go @@ -9,31 +9,18 @@ import ( "github.com/floatdrop/moq-go/pkg/relay/internal/registry" ) -// endMalformedTrack is the relay's response to a malformed track (§2.4.2) -// detected in an Object that src sent on entry's track. As a relay it "MUST -// immediately terminate downstream subscriptions with PUBLISH_DONE [...] with -// Status Code MALFORMED_TRACK": every downstream subscription, whichever -// upstream fed it, since the track is malformed. As a subscriber it "MUST -// cancel any corresponding subscription or fetches for that Track from that -// publisher": the upstream subscriptions on src only, so a redundant publisher -// (§9.5) keeps serving later subscribers. The Object is never cached — every -// caller detects it before the cache is written. +// endMalformedTrack handles a malformed track (§2.4.2) detected in an Object +// src sent on entry's track: every downstream subscription ends with +// PUBLISH_DONE MALFORMED_TRACK, and only the upstreams on src are cancelled, +// so a redundant publisher (§9.5) keeps serving. Callers never cache the +// Object. Open subgroup streams are reset now, since PUBLISH_DONE waits for +// them (§10.12). Downstream fetch streams are not reset. // -// The terminated subscriptions' open subgroup streams are reset with -// MALFORMED_TRACK now: PUBLISH_DONE waits for them (§10.12), and one left -// open by an idle upstream stream would hold it back indefinitely. +// Downstreams are terminated before the upstream is cancelled: the first +// termination wins, and the upstream's teardown would use its own code. // -// The downstreams are terminated before the upstream is cancelled: -// cancelling makes its owner unregister it, which terminates downstreams with -// the upstream's own code, and the first termination wins. -// -// Cancelling the upstream uses MALFORMED_TRACK too. §3.3.4 defines it for "A -// relay publisher detected that the track was malformed", the downstream -// direction; saying why the relay cancels is this relay's choice under "SHOULD -// use a relevant error code", where CANCELLED would say less. -// -// Downstream fetch streams already serving the track are not reset: the relay -// does not track them per track. +// Interpretation: the upstream cancel also uses MALFORMED_TRACK, which §3.3.4 +// defines for the downstream direction. func (h *sessionHandler) endMalformedTrack( ctx context.Context, entry *registry.TrackEntry, @@ -53,8 +40,7 @@ func (h *sessionHandler) endMalformedTrack( cancelled++ } } - // Once per upstream at Info; a publisher that keeps sending (datagrams, - // other streams) after the cancel is logged at Debug. + // Info once per upstream; later detections are Debug. level := slog.LevelDebug if cancelled > 0 { level = slog.LevelInfo @@ -64,9 +50,7 @@ func (h *sessionHandler) endMalformedTrack( } // resetWriters resets the open subgroup writers of subs on entry with -// MALFORMED_TRACK. Each writer's slot is set to nil, which also keeps the -// joiner scan from reopening one for the same Subgroup. The writers drain in -// the background, joined by the handler's wait group. +// MALFORMED_TRACK. The nil slot keeps the joiner scan from reopening one. func (h *sessionHandler) resetWriters(entry *registry.TrackEntry, subs []*registry.DownstreamSub) { if len(subs) == 0 { return diff --git a/pkg/relay/handler_namespace.go b/pkg/relay/handler_namespace.go index 4c532c28..a7ede1d3 100644 --- a/pkg/relay/handler_namespace.go +++ b/pkg/relay/handler_namespace.go @@ -26,13 +26,9 @@ import ( // ([registry.NamespaceRegistry.AnnouncePublisher]); unregistration // withdraws it. // 5. SUBSCRIBE the publisher for every existing track the namespace covers -// (§9.5; see [sessionHandler.subscribeExistingTracks]). Tracks skipped -// for lack of a subscriber, and tracks subscribed later, reach it from -// the SUBSCRIBE handler once a downstream is registered. -// 6. Block reading the request stream until the publisher cancels it -// (RESET_STREAM, or STOP_SENDING after a FIN — §6.2 "withdrawn by -// cancelling the request", §3.3.3; a FIN alone is not a withdrawal, -// §3.3.2). On exit, unregister from the registry.NamespaceRegistry. +// (§9.5; see [sessionHandler.subscribeExistingTracks]). +// 6. Serve follow-ups until the publisher cancels the request (§6.2), then +// unregister. func (h *sessionHandler) handlePublishNamespace( ctx context.Context, req *session.Request, @@ -53,31 +49,22 @@ func (h *sessionHandler) handlePublishNamespace( } h.names.AnnouncePublisher(entry) - // Scoped to this PUBLISH_NAMESPACE: once the publisher withdraws it (§9.5) - // no further SUBSCRIBEs go out for it. + // §9.5: no further SUBSCRIBEs once the publisher withdraws. nsCtx, cancel := context.WithCancel(ctx) defer cancel() h.spawn(func() { h.subscribeExistingTracks(nsCtx, entry) }) - // Block until the publisher cancels (§6.2, §3.3.3: reset, or - // STOP_SENDING after a FIN) or our ctx is cancelled. Per §6.2 the bidi stream is the publisher's - // keepalive for the advertisement; NAMESPACE / NAMESPACE_DONE - // follow-ups from the publisher need no action (the §9.5 fanout keys - // off tracks, not per-namespace sub-announcements), but REQUEST_UPDATEs - // must be validated and answered. This handler goroutine is the only - // writer on the publisher's stream after the REQUEST_OK above, so the - // acks write directly. + // This goroutine is the only writer on the stream after REQUEST_OK, so + // the acks write directly. h.serveNamespaceFollowups(ctx, req, func(m message.Message) error { return message.Marshal(req.Stream, m) }, nil) } -// subscribeExistingTracks is §9.5: "When a relay receives an authorized -// PUBLISH_NAMESPACE for a namespace that matches one or more existing -// subscriptions to other upstream sessions, it MUST send a SUBSCRIBE to the -// publisher that sent the PUBLISH_NAMESPACE for each matching subscription." -// Tracks it skips, and a downstream SUBSCRIBE racing it, are covered from the -// SUBSCRIBE side by [sessionHandler.subscribeMissingPublishers]. +// subscribeExistingTracks SUBSCRIBEs pub for every existing track its +// namespace covers (§9.5: "it MUST send a SUBSCRIBE to the publisher"). +// Tracks it skips, and a racing downstream SUBSCRIBE, are covered by +// [sessionHandler.subscribeMissingPublishers]. func (h *sessionHandler) subscribeExistingTracks(ctx context.Context, pub *registry.PublisherEntry) { for _, e := range h.tracks.MatchNamespace(pub.Namespace) { if ctx.Err() != nil { @@ -88,13 +75,10 @@ func (h *sessionHandler) subscribeExistingTracks(ctx context.Context, pub *regis } // subscribeMissingPublishers runs once a downstream is on the track's entry -// and SUBSCRIBEs the registered publishers covering the track that have no -// upstream for it: every one when the downstream reused an existing upstream -// set (reused), which may lack publishers skipped while the track had no -// subscriber or stripped when its last one left; otherwise only those -// registered after seq, since a fresh set just tried the rest. A publisher -// whose late-publisher SUBSCRIBE was refused is not asked again until its -// Retry Interval passes, if ever (see [sessionHandler.subscribeLatePublisher]). +// and SUBSCRIBEs the covering publishers that have no upstream for it: all of +// them when the upstream set was reused, otherwise only those registered +// after seq, since a fresh set just tried the rest. Refused publishers are +// skipped (see [sessionHandler.subscribeLatePublisher]). func (h *sessionHandler) subscribeMissingPublishers( ctx context.Context, entry *registry.TrackEntry, @@ -114,25 +98,16 @@ func (h *sessionHandler) subscribeMissingPublishers( } // subscribeLatePublisher opens an upstream subscription for an existing track -// on pub, a publisher whose PUBLISH_NAMESPACE covers it but which is not yet -// among the track's established upstreams (§9.5). The new upstream joins the -// track's merged publisher set like any on-demand one. +// on pubEntry, a publisher whose PUBLISH_NAMESPACE covers it but which is not +// yet among the track's upstreams (§9.5). // -// A refusal is recorded on the track entry so later subscribers do not ask -// again: a REQUEST_ERROR until its Retry Interval passes, or for good when it -// is 0 ("SHOULD NOT be retried", §10.6.2), and a SUBSCRIBE_OK the relay had to -// cancel over its Track Properties (§2.5.1) for good. "For good" lasts while -// the entry and the publisher's registration do. Refusals on the on-demand -// path are not recorded; that path asks every publisher once per upstream set. +// A refusal is recorded on the track entry: a REQUEST_ERROR until its Retry +// Interval passes, or for good when it is 0 (§10.6.2), and a Track Properties +// mismatch (§2.5.1) for good, while the entry and registration last. // -// Skipped, until a later downstream SUBSCRIBE asks again (these are this -// relay's choices; §9.5 does not qualify "each matching subscription"): -// - while the track has no downstream subscriber. An on-demand upstream is -// released when its last downstream leaves, so one opened with none would -// never be; -// - while pub itself receives the track from the relay, which would echo it -// back to its receiver. The on-demand path likewise never subscribes on -// the requesting session. +// Deviation (§9.5 does not qualify "each matching subscription"): skipped +// while the track has no downstream, since the upstream would never be +// released, and while pub itself receives the track, which would echo it. func (h *sessionHandler) subscribeLatePublisher( ctx context.Context, fullName track.FullTrackName, @@ -171,10 +146,9 @@ func (h *sessionHandler) subscribeLatePublisher( slog.String("name", string(fullName.Name))) return } - // Or a downstream may have switched to Forward=1 during it, when this - // upstream was not yet registered for §9.2 propagation to reach. Not - // bound to ctx: a withdrawn PUBLISH_NAMESPACE stops new subscriptions - // (§9.5), not the resume of this established one. + // Or switched to Forward=1 before this upstream was registered for §9.2 + // propagation. Not bound to ctx: a withdrawal (§9.5) stops only new + // subscriptions. if up.ForwardState() == 0 && anyDownstreamForwards(entry) { h.propagateForwardUpstream(context.WithoutCancel(ctx), fullName) } @@ -184,10 +158,8 @@ func (h *sessionHandler) subscribeLatePublisher( // // 1. Authorize and reserve the prefix (PREFIX_OVERLAP). // 2. Reply REQUEST_OK. -// 3. Register in [registry.NamespaceRegistry] with WantsTracks=false. The -// registry queues a NAMESPACE for every namespace already known under -// the prefix (§6.1) and, from then on, NAMESPACE / NAMESPACE_DONE as -// namespaces come and go; the entry's writer sends them in order. +// 3. Register in [registry.NamespaceRegistry], which queues NAMESPACE / +// NAMESPACE_DONE for existing and later namespaces (§6.1). // 4. Serve REQUEST_UPDATEs, including TRACK_NAMESPACE_PREFIX (§10.9.2), // until the subscriber cancels. func (h *sessionHandler) handleSubscribeNamespace( @@ -205,13 +177,11 @@ func (h *sessionHandler) handleSubscribeNamespace( "prefix overlaps an established SUBSCRIBE_NAMESPACE in this session") return } - // prefix follows TRACK_NAMESPACE_PREFIX updates (§10.9.2), so the - // reservation released is the current one. + // prefix follows TRACK_NAMESPACE_PREFIX updates (§10.9.2). prefix := msg.TrackNamespacePrefix defer func() { h.nsPrefixes.release(prefix) }() - // Reply REQUEST_OK before registering: registration queues NAMESPACE - // messages, and §6.1 requires the OK first. + // §6.1: REQUEST_OK before any NAMESPACE, so reply before registering. if err := req.Reply(&message.RequestOK{}); err != nil { h.log.LogAttrs(ctx, slog.LevelDebug, "SubscribeNamespace REQUEST_OK write failed", slog.String("err", err.Error())) @@ -228,13 +198,9 @@ func (h *sessionHandler) handleSubscribeNamespace( ) defer h.names.UnregisterSubscriber(entry) - // Registration queued a NAMESPACE for every namespace already known under - // the prefix (§6.1); the writer sends those and every later change, in - // the order the registry made them. h.spawn(entry.RunWriter) - // REQUEST_UPDATE replies are queued behind the NAMESPACE / - // NAMESPACE_DONE messages already queued, so they keep their order. + // Replies share the entry's queue, keeping their order with NAMESPACE. h.serveNamespaceFollowups(ctx, req, enqueueReply(entry), h.namespaceUpdate(entry, &prefix)) } @@ -243,13 +209,10 @@ func (h *sessionHandler) handleSubscribeNamespace( // 1. Authorize, validate its subscription parameters (§10.20.1), and // reserve the prefix (PREFIX_OVERLAP). // 2. Reply REQUEST_OK. -// 3. Register in [registry.NamespaceRegistry] with WantsTracks=true and this -// handler's forwardTrack, then forward the tracks that already exist -// under the prefix (§10.20). +// 3. Register in [registry.NamespaceRegistry] with forwardTrack, then +// forward the tracks that already exist under the prefix (§10.20). +// Later tracks are forwarded by handlePublish. // 4. Serve REQUEST_UPDATEs until the subscriber cancels. -// -// Tracks published later are forwarded by `handlePublish`, which calls the -// entry's ForwardTrack for each matching subscriber. func (h *sessionHandler) handleSubscribeTracks( ctx context.Context, req *session.Request, @@ -260,9 +223,7 @@ func (h *sessionHandler) handleSubscribeTracks( return } - // §10.20.1: the parameters become each forwarded PUBLISH's subscription, - // so they are refused on the same terms as a SUBSCRIBE's; the Range - // Filters also gate which PUBLISHes are forwarded (§5.1.4). + // §10.20.1: refused on a SUBSCRIBE's terms. params, err := h.resolveTracksParams(msg.Parameters) if err != nil { h.refuseSubscriptionParams(ctx, req, err) @@ -277,9 +238,8 @@ func (h *sessionHandler) handleSubscribeTracks( prefix := msg.TrackNamespacePrefix defer func() { h.trackPrefixes.release(prefix) }() - // Reply REQUEST_OK before registering, so the OK cannot race a - // PUBLISH_SKIPPED that a concurrent publisher's PUBLISH handler - // (emitPublishSkipped) may write to this stream once the entry is visible. + // Reply before registering, so the OK cannot race a PUBLISH_SKIPPED + // written once the entry is visible. if err := req.Reply(&message.RequestOK{}); err != nil { h.log.LogAttrs(ctx, slog.LevelDebug, "SubscribeTracks REQUEST_OK write failed", slog.String("err", err.Error())) @@ -297,26 +257,21 @@ func (h *sessionHandler) handleSubscribeTracks( defer h.names.UnregisterSubscriber(entry) h.spawn(entry.RunWriter) - // §10.20: forward the tracks that already exist under the prefix, too; - // PUBLISHes arriving from now on are forwarded by their handlers. + // §10.20: forward the tracks that already exist under the prefix. for _, te := range h.tracks.MatchNamespace(msg.TrackNamespacePrefix) { if hasEstablishedUpstream(te) { entry.ForwardTrack(entry, te) } } - // REQUEST_UPDATE replies share the entry's queue with a prefix update's - // REQUEST_OK and PUBLISH_SKIPPED (emitPublishSkipped), so each + // Replies share the entry's queue with PUBLISH_SKIPPED, so each // PUBLISH_SKIPPED suffix matches the prefix the subscriber last saw. h.serveNamespaceFollowups(ctx, req, enqueueReply(entry), h.tracksUpdate(entry, &prefix)) } -// subscribeTracksForwarding resolves the FORWARD (§10.2.18) and GROUP_ORDER -// (§10.2.8) parameters a SUBSCRIBE_TRACKS carries. §10.20.1 copies both onto -// the PUBLISH messages the subscription triggers: forward defaults to true -// (FORWARD omitted or 1; only 0 means "don't forward"); groupOrder is 0 when -// omitted (the publisher's default applies) or the Ascending/Descending value. -// An out-of-range value is a *paramProtocolViolation (§10.2.8 / §10.2.18: the -// caller MUST close the session), shared with installSubscribeParams. +// subscribeTracksForwarding resolves a SUBSCRIBE_TRACKS's FORWARD (§10.2.18, +// default true) and GROUP_ORDER (§10.2.8, 0 when omitted), which §10.20.1 +// copies onto forwarded PUBLISHes. An out-of-range value is a +// *paramProtocolViolation. func subscribeTracksForwarding(ps message.Parameters) (forward bool, groupOrder byte, err error) { if err := checkForwardParam(ps); err != nil { return false, 0, err @@ -334,17 +289,11 @@ func subscribeTracksForwarding(ps message.Parameters) (forward bool, groupOrder return forward, groupOrder, nil } -// serveNamespaceFollowups holds a namespace request stream open (the §6.1 / -// §6.2 keepalive previously provided by session.DrainAndWait) while actually -// parsing the follow-ups: a peer REQUEST_UPDATE consumes a §10.1 Request ID -// (validated; violations are session-fatal), may carry §10.2.2 token -// parameters, and must be answered with the single REQUEST_OK or REQUEST_ERROR -// §10.9 mandates. The two subscriptions pass update, which applies it and -// replies; for a PUBLISH_NAMESPACE (update nil) it is acknowledged without -// further action. write sends a reply: -// directly for a PUBLISH_NAMESPACE, through the subscriber entry's queue for -// the two subscriptions. Other follow-ups (NAMESPACE, NAMESPACE_DONE, …) need -// no response and are ignored here. +// serveNamespaceFollowups holds a namespace request stream open and answers +// each REQUEST_UPDATE (§10.9), validating its Request ID (§10.1) and tokens. +// The subscriptions pass update, which applies it and replies; with update +// nil (PUBLISH_NAMESPACE) write sends a plain REQUEST_OK. Other follow-ups +// are ignored. func (h *sessionHandler) serveNamespaceFollowups( ctx context.Context, req *session.Request, @@ -377,9 +326,7 @@ func (h *sessionHandler) serveNamespaceFollowups( if !h.handleFollowupTokens(ctx, upd) { return false } - // The two subscription requests supply update, which applies the - // REQUEST_UPDATE and replies; false ends the request (the session is - // closing, or the update was refused and the stream closed). + // false from update ends the request. if update != nil { if !update(ctx, upd) { return false @@ -390,10 +337,7 @@ func (h *sessionHandler) serveNamespaceFollowups( if err := write(&message.RequestOK{}); err != nil { h.log.LogAttrs(ctx, slog.LevelDebug, "namespace REQUEST_UPDATE_OK write failed", slog.String("err", err.Error())) - // Only a PUBLISH_NAMESPACE's direct write can fail here. The - // handler unregisters when this loop returns; reset the read - // side so the peer learns reads stopped rather than writing - // follow-ups into a void. + // Reset the read side so the peer learns reads stopped. stream.CancelRead(uint64(moqt.StreamResetInternalError)) return false } @@ -401,10 +345,8 @@ func (h *sessionHandler) serveNamespaceFollowups( return true }) if fin { - // A FIN is not a withdrawal or unsubscribe (§3.3.2): PUBLISH_NAMESPACE - // is "withdrawn by cancelling the request" (§6.2), SUBSCRIBE_NAMESPACE - // and SUBSCRIBE_TRACKS are cancelled "by resetting or sending - // STOP_SENDING on the stream" (§6.1). Keep the state until then. + // §3.3.2: a FIN is not a cancellation (§6.1, §6.2); keep the state + // until the peer resets or sends STOP_SENDING. awaitRequestEnd(ctx, stream) } } @@ -458,17 +400,12 @@ func (h *sessionHandler) namespaceUpdate( } // tracksUpdate answers a REQUEST_UPDATE on a SUBSCRIBE_TRACKS. Its parameters -// are merged into the subscription's (see [mergeTracksUpdate]) and, like a -// FORWARD, apply "on future subscriptions that match the prefix. Existing -// subscriptions are unaffected" (§10.2.18); so does a TRACK_NAMESPACE_PREFIX -// (§10.9.2). The merged parameters are refused on the SUBSCRIBE_TRACKS's own -// terms; a refused update ends the request, since "the responder MUST close -// the bidi stream" (§10.9.1), and changes nothing (see [endAfterFinish]). +// are merged (see [mergeTracksUpdate]) and apply only to future forwards +// (§10.2.18: "Existing subscriptions are unaffected"). A refused update +// changes nothing and ends the request (see [endAfterFinish]). // -// Tracks that exist and did not match before the update but do now, by prefix -// or by Range Filter, are forwarded then: SUBSCRIBE_TRACKS asks for "all -// tracks within matching namespaces" (§10.20). A track that matched before is -// not offered again, even if the subscriber refused it. +// Existing tracks that newly match, by prefix or Range Filter, are forwarded +// (§10.20); a track that matched before is not offered again. func (h *sessionHandler) tracksUpdate( e *registry.SubscriberEntry, cur *wire.TrackNamespace, @@ -521,14 +458,10 @@ func (h *sessionHandler) tracksUpdate( } // mergeTracksUpdate applies a REQUEST_UPDATE's parameters to a -// SUBSCRIBE_TRACKS's: a parameter type present in upd replaces every stored -// parameter of that type, and types upd omits are unchanged (§10.9). For a -// Range Filter that is §5.1.4's rule — "Length of 0 removes the filter; -// non-zero replaces it entirely" — per filter type, every SetID of it, so a -// zero-length one is not kept. TRACK_NAMESPACE_PREFIX and AUTHORIZATION_TOKEN -// belong to the update itself, not to the subscriptions it shapes: the -// update's are not kept, and an update carrying a token drops the stored one -// (which no forwarded PUBLISH echoes anyway, §10.2.2). +// SUBSCRIBE_TRACKS's: each type present in upd replaces every stored one of +// that type (§10.9); a zero-length Range Filter removes it (§5.1.4). +// TRACK_NAMESPACE_PREFIX and AUTHORIZATION_TOKEN belong to the update and +// are not kept; a token in upd drops the stored one. func mergeTracksUpdate(stored, upd message.Parameters) message.Parameters { out := slices.DeleteFunc(slices.Clone(stored), func(p message.Parameter) bool { return slices.ContainsFunc(upd, func(u message.Parameter) bool { return u.Type == p.Type }) @@ -544,12 +477,10 @@ func mergeTracksUpdate(stored, upd message.Parameters) message.Parameters { return out } -// resolveTracksParams validates a SUBSCRIBE_TRACKS's parameters, as sent or -// merged with an update, and resolves what forwarding needs. §10.20.1: they -// become each forwarded PUBLISH's subscription, so they are refused on a -// SUBSCRIBE's terms (see [sessionHandler.refuseSubscriptionParams] for the -// error classes); the Range Filters are also checked against -// MAX_FILTER_RANGES (§5.1.4). +// resolveTracksParams validates a SUBSCRIBE_TRACKS's parameters on a +// SUBSCRIBE's terms (§10.20.1; errors as for +// [sessionHandler.refuseSubscriptionParams]) and MAX_FILTER_RANGES (§5.1.4), +// and resolves what forwarding needs. func (h *sessionHandler) resolveTracksParams(ps message.Parameters) (*registry.TracksParams, error) { forward, groupOrder, err := subscribeTracksForwarding(ps) if err != nil { @@ -569,11 +500,8 @@ func (h *sessionHandler) resolveTracksParams(ps message.Parameters) (*registry.T } // prefixUpdater applies a TRACK_NAMESPACE_PREFIX update (§10.9.2) to e and -// replies. A new prefix that "would share a common prefix with another active -// subscription of the same type in the same session" is refused with -// PREFIX_OVERLAP (§10.2.20), checked against reserved excluding the request's -// own current prefix, *cur. A failed update ends the request: "the responder -// MUST close the bidi stream" (§10.9.1); it reports false then. +// replies. An overlapping prefix is refused with PREFIX_OVERLAP (§10.2.20), +// and the updater reports false. func (h *sessionHandler) prefixUpdater( e *registry.SubscriberEntry, reserved *prefixSet, @@ -594,11 +522,8 @@ func (h *sessionHandler) prefixUpdater( } // endAfterFinish ends a namespace subscription whose REQUEST_UPDATE was -// refused. The responder "MUST close the bidi stream" (§10.9.1), and its FIN -// says the request is complete (§3.3.2), so the relay stops serving it rather -// than wait for the requester to answer. It waits for the writer to send the -// queued REQUEST_ERROR and FIN, then reports false, which ends the follow-up -// loop and lets the owner unregister the subscription and release its prefix. +// refused (§10.9.1: "MUST close the bidi stream"). It waits for the writer to +// send the queued REQUEST_ERROR and FIN, then reports false. func endAfterFinish(ctx context.Context, e *registry.SubscriberEntry) bool { select { case <-e.WriterDone(): @@ -614,11 +539,9 @@ type prefixSet struct { prefixes []wire.TrackNamespace } -// reserve records prefix and reports true, or reports false — recording -// nothing — when it overlaps an established one. Two tuple prefixes overlap -// when one is a prefix of the other: the draft's "shares a common prefix", -// read as "matches some of the same namespaces" (read literally, every pair -// would share the empty prefix). The empty prefix overlaps everything. +// reserve records prefix and reports true, or reports false when it overlaps +// an established one. Interpretation: "shares a common prefix" means one is a +// prefix of the other (read literally, every pair shares the empty prefix). func (p *prefixSet) reserve(prefix wire.TrackNamespace) bool { p.mu.Lock() defer p.mu.Unlock() @@ -631,9 +554,8 @@ func (p *prefixSet) reserve(prefix wire.TrackNamespace) bool { return true } -// replace swaps the reservation of old for prefix and reports true, or reports -// false — changing nothing — when prefix overlaps a reservation other than -// old (§10.9.2). +// replace swaps the reservation of old for prefix and reports true, or false +// when prefix overlaps a reservation other than old (§10.9.2). func (p *prefixSet) replace(old, prefix wire.TrackNamespace) bool { p.mu.Lock() defer p.mu.Unlock() diff --git a/pkg/relay/handler_publish.go b/pkg/relay/handler_publish.go index 9f051867..1efdc5c2 100644 --- a/pkg/relay/handler_publish.go +++ b/pkg/relay/handler_publish.go @@ -29,12 +29,8 @@ import ( // 7. Block reading the request stream until the publisher cancels; // unregister on exit. // -// testHookAfterAliasRegistered, when set, runs at the moment a Track Alias -// becomes routable and before the track entry is registered, so a test can -// hold open a window that is otherwise a few statements wide. Never set in -// production. atomic.Pointer because the relay reads it from per-session -// goroutines while a test writes it; the track argument lets a test scope -// itself to its own track rather than perturbing the package's parallel tests. +// testHookAfterAliasRegistered, when set by a test, runs once a Track Alias is +// routable and before the upstream is registered, to hold that window open. var testHookAfterAliasRegistered atomic.Pointer[func(track.FullTrackName)] func (h *sessionHandler) handlePublish(ctx context.Context, req *session.Request, msg *message.Publish) { @@ -43,18 +39,14 @@ func (h *sessionHandler) handlePublish(ctx context.Context, req *session.Request slog.String("name", string(msg.Name)), slog.Uint64("alias", msg.TrackAlias)) - // §10.2.18: an out-of-range FORWARD "MUST close the session with - // PROTOCOL_VIOLATION", as on the SUBSCRIBE path. + // §10.2.18: an out-of-range FORWARD closes the session. if err := checkForwardParam(msg.Parameters); err != nil { _ = h.sess.Close(moqt.SessionProtocolViolation, err.Error()) return } - // §2.5.1: a track carrying a Mandatory Track Property the relay does not - // understand MUST NOT be forwarded; for PUBLISH the answer is - // UNSUPPORTED_EXTENSION. Unparseable Track Properties are refused with - // MALFORMED_TRACK; the draft does not cover them, so that code is this - // repo's choice. + // §2.5.1: refuse an unknown Mandatory Track Property. MALFORMED_TRACK for + // unparseable Track Properties is this repo's choice; the draft is silent. if err := h.sess.CheckTrackProperties(msg.TrackProperties, "PUBLISH"); err != nil { _ = req.RejectError(session.TrackPropertiesRejectCode(err), err.Error()) return @@ -67,13 +59,9 @@ func (h *sessionHandler) handlePublish(ctx context.Context, req *session.Request fullName := track.FullTrackName{Namespace: msg.Namespace, Name: msg.Name} - // Create the entry before the alias below becomes routable. §10.11: if - // FORWARD "is omitted or equal to 1, the publisher will start - // transmitting objects immediately, possibly before PUBLISH_OK" — i.e. - // before AddUpstream runs down in WriteMessageAfterSetup. Without an - // entry to route to, runFanout resets those streams and the track's - // first Group is lost from the cache and from live fanout alike. Same - // window the on-demand SUBSCRIBE path closes; see #85. + // Create the entry before the alias becomes routable: objects may arrive + // "possibly before PUBLISH_OK" (§10.11), and runFanout resets streams for + // a track with no entry. _, createdEntry := h.tracks.GetOrCreateNew(fullName) // §11.1: register the publisher's chosen alias so the fanout path can map @@ -92,38 +80,21 @@ func (h *sessionHandler) handlePublish(ctx context.Context, req *session.Request (*hook)(fullName) } - // A later upstream REQUEST_UPDATE rides this PUBLISH stream (§10.9), - // consuming a fresh Request ID from the relay's own space (§10.1); - // the PUBLISH's ID is recorded for identity/diagnostics. // The publisher sent the PUBLISH, so it may send REQUEST_UPDATE (§10.9). sub := registry.NewUpstreamSub(h.allocSubID(), h.sess, req.Stream, msg.TrackAlias, msg.RequestID, true) - // §5.1: "The initiator of the subscription sets the initial Forward State - // in either PUBLISH or SUBSCRIBE". NewUpstreamSub assumes the omitted - // default of 1; a PUBLISH that says FORWARD=0 is paused until the relay - // resumes it below or via §9.2 propagation. + // §5.1: the PUBLISH sets the initial Forward State (default 1). if f, ok := msg.Parameters.Find(message.ParamForward); ok && f.Byte == 0 { sub.SetForwardState(0) } // Register the upstream and reply REQUEST_OK atomically under the - // stream's broker write lock. Both orderings matter: - // - // - Registration must complete before the peer can observe the OK: a - // publisher that received its OK may immediately be subscribed to - // via another session, and that SUBSCRIBE must find the track (the - // pre-broker code replied first, leaving a visibility window that - // rejected prompt subscribers with DOES_NOT_EXIST). - // - The OK must still be the stream's next message: registration - // makes the sub reachable by §9.2 / §10.2.19 propagation, whose - // REQUEST_UPDATE writes serialize behind the OK on the same lock. - // entry is hoisted out of the closure because the PUBLISH forwarded to each - // subscriber below has to read the track's own watermark back off it. + // stream's broker write lock: registration must precede the OK (a prompt + // SUBSCRIBE elsewhere must find the track), and the OK must be the + // stream's next message ahead of any propagated REQUEST_UPDATE. var entry *registry.TrackEntry if err := sub.Broker.WriteMessageAfterSetup(func() error { entry, _ = h.tracks.AddUpstream(fullName, sub, registry.WithProperties(msg.TrackProperties)) - // §10.2.17 item 1 names PUBLISH alongside SUBSCRIBE_OK: a publisher - // offering a track that already has content reports its largest - // Location here, before any object arrives to establish one. + // §10.2.17: PUBLISH may carry LARGEST_OBJECT. saveLargestLocation(entry, msg.Parameters) return nil }, &message.RequestOK{}); err != nil { @@ -142,10 +113,8 @@ func (h *sessionHandler) handlePublish(ctx context.Context, req *session.Request h.log.LogAttrs(ctx, slog.LevelDebug, "PUBLISH accepted, waiting for publisher", slog.String("name", string(msg.Name))) - // §9.5: "If at least one downstream subscriber for the Track has Forward - // State=1, the Relay MUST change the Forward State to 1 with - // REQUEST_UPDATE." Spawned: the update's response is read by the broker's - // Serve loop, which serveUpstreamStream below starts. + // §9.5: resume a paused upstream if any downstream forwards. Spawned: the + // response is read by the Serve loop serveUpstreamStream starts below. if sub.ForwardState() == 0 && anyDownstreamForwards(entry) { h.spawn(func() { h.propagateForwardUpstream(ctx, fullName) }) } @@ -159,9 +128,7 @@ func (h *sessionHandler) handlePublish(ctx context.Context, req *session.Request } // forwardToTrackSubscribers offers entry's track to every SUBSCRIBE_TRACKS -// holder whose prefix matches (§6.1, §10.20). Each subscriber's own handler -// serves the subscription the PUBLISH opens (see forwardTrack); it skips one -// that already has the track, or publishes it. +// holder whose prefix matches (§6.1, §10.20); see forwardTrack. func (h *sessionHandler) forwardToTrackSubscribers(entry *registry.TrackEntry) { for _, sub := range h.names.MatchSubscribers(entry.FullName.Namespace) { if sub.WantsTracks && sub.ForwardTrack != nil { @@ -177,21 +144,13 @@ var notEchoedInPublish = []message.ParamID{ message.ParamAuthorizationToken, message.ParamForward, message.ParamGroupOrder, message.ParamLargestObject, } -// publishParamsForSubscriber builds the Parameters of a PUBLISH the relay -// sends to sub as a result of its SUBSCRIBE_TRACKS, whose parameters are -// subscribeTracks. None is copied from an upstream: Message Parameters "are not -// forwarded by Relays" (§10.2.1). -// - The SUBSCRIBE_TRACKS parameters are "the initial Subscription -// parameters" and "are explicitly communicated in PUBLISH" (§10.20.1): each -// one PUBLISH may carry (§10.2.1) is echoed, except AUTHORIZATION_TOKEN, -// which "MUST NOT be copied from a SUBSCRIBE_TRACKS to the resulting -// PUBLISH" (§10.2.2). -// - FORWARD and GROUP_ORDER come from the resolved subscription: FORWARD=0 -// only when the subscriber asked not to forward (otherwise omitted, -// meaning 1), GROUP_ORDER when it specified one. -// - LARGEST_OBJECT is the relay's own watermark for the track (§10.2.17 -// requires the largest of every value observed; omitted when there is -// none). +// publishParamsForSubscriber builds the Parameters of a PUBLISH forwarded for +// a SUBSCRIBE_TRACKS. None come from the upstream (§10.2.1: "not forwarded by +// Relays"): +// - SUBSCRIBE_TRACKS parameters valid on PUBLISH are echoed (§10.20.1), +// except AUTHORIZATION_TOKEN (§10.2.2); +// - FORWARD=0 and GROUP_ORDER only when the subscriber set them; +// - LARGEST_OBJECT is the relay's own watermark (§10.2.17). func publishParamsForSubscriber(tp *registry.TracksParams, entry *registry.TrackEntry) message.Parameters { var out message.Parameters for _, p := range tp.Params { @@ -214,16 +173,9 @@ func publishParamsForSubscriber(tp *registry.TracksParams, entry *registry.Track return out } -// emitPublishSkipped sends a PUBLISH_SKIPPED (§10.21) to sub for the track -// fullName. It is the §6.1 response to an exhausted bidi-stream limit: the -// relay cannot open the PUBLISH stream for this PUBLISH, so it tells the -// subscriber on its SUBSCRIBE_TRACKS response stream. Per §6.1 the -// prohibition is scoped to this single PUBLISH — a later re-PUBLISH for the -// track is a fresh forwarding attempt — so nothing is recorded here. -// -// Per §10.21 the message carries only the namespace suffix beyond the -// subscriber's SUBSCRIBE_TRACKS prefix; [registry.NamespaceRegistry.PublishSkipped] -// strips the prefix in force at that point of the stream. +// emitPublishSkipped queues a PUBLISH_SKIPPED (§10.21) for fullName on sub's +// SUBSCRIBE_TRACKS stream, the §6.1 response to an exhausted bidi-stream +// limit. The skip is scoped to this one PUBLISH, so nothing is recorded. func (h *sessionHandler) emitPublishSkipped( ctx context.Context, sub *registry.SubscriberEntry, diff --git a/pkg/relay/handler_subscribe.go b/pkg/relay/handler_subscribe.go index 27a0d830..2d060505 100644 --- a/pkg/relay/handler_subscribe.go +++ b/pkg/relay/handler_subscribe.go @@ -14,24 +14,11 @@ import ( "github.com/floatdrop/moq-go/pkg/relay/internal/registry" ) -// handleSubscribe implements the SUBSCRIBE flow (§9.4, §10.7): -// -// 1. Authorize. -// 2. Look up the track. If an Established upstream exists, serve from it -// (the §9.4 aggregation path). -// 3. Otherwise look for a matching local publisher in the -// [registry.NamespaceRegistry] (§9.5 prefix matching). If one is found, issue an -// upstream SUBSCRIBE on its session with the Next Object filter -// (§9.4 lets relays aggregate subscriptions into "a single upstream -// subscription for the Track"; that filter keeps the upstream stable as -// downstream filters vary) and on SUBSCRIBE_OK -// register the resulting registry.UpstreamSub. -// 4. If no local publisher is available either, reject with -// [moqt.RequestDoesNotExist]. Discovery-driven cross-relay lookup -// plugs in here. -// 5. Allocate an outbound Track Alias, register a [registry.DownstreamSub] in -// [registry.SubEstablished], reply SUBSCRIBE_OK, and block reading the request -// stream until the subscriber cancels. +// handleSubscribe implements the SUBSCRIBE flow (§9.4, §10.7): authorize, +// serve from an Established upstream or establish one on demand (see +// [sessionHandler.subscribeUpstream]), else reject with +// [moqt.RequestDoesNotExist]; then register a [registry.DownstreamSub], reply +// SUBSCRIBE_OK, and serve the request stream until it ends. func (h *sessionHandler) handleSubscribe(ctx context.Context, req *session.Request, msg *message.Subscribe) { h.log.LogAttrs(ctx, slog.LevelDebug, "SUBSCRIBE received", slog.String("namespace", fmt.Sprintf("%v", msg.Namespace)), @@ -44,15 +31,11 @@ func (h *sessionHandler) handleSubscribe(ctx context.Context, req *session.Reque fullName := track.FullTrackName{Namespace: msg.Namespace, Name: msg.Name} - // §10.2.19: a NEW_GROUP_REQUEST on the SUBSCRIBE either rides the upstream - // SUBSCRIBE we are about to open (rule 1, no Established upstream) or, when - // an upstream already exists, is evaluated against it as an Established - // subscription below. + // §10.2.19: a NEW_GROUP_REQUEST rides a new upstream SUBSCRIBE (rule 1), + // or is evaluated against an existing upstream below. newGroupReqParam, hasNewGroupReq := msg.Parameters.Find(message.ParamNewGroupRequest) - // Allocate the Track Alias the relay uses when publishing this track - // downstream. Per §11.1 the outbound alias space is independent of the - // inbound aliases the peer chose for its own PUBLISHes. + // §11.1: outbound aliases are independent of the peer's inbound ones. alias := h.sess.AllocOutboundTrackAlias() sub := registry.NewDownstreamSub(h.allocSubID(), h.sess, req.Stream, alias) @@ -61,11 +44,8 @@ func (h *sessionHandler) handleSubscribe(ctx context.Context, req *session.Reque return } - // Establish (or reuse) an upstream and register the downstream on it. - // Two attempts: AddDownstreamSnapshotLargest refuses to register on an - // entry whose last upstream vanished between the establish check and - // the registration (the §9.4 TOCTOU) — one retry re-runs the on-demand - // establish against the fresh state. + // Two attempts: registration fails if the last upstream vanished after + // the establish check, and the retry re-establishes. var ( entry *registry.TrackEntry snapshotLargest message.Location @@ -81,25 +61,15 @@ func (h *sessionHandler) handleSubscribe(ctx context.Context, req *session.Reque if !ok || !hasEstablishedUpstream(e) { h.log.LogAttrs(ctx, slog.LevelDebug, "SUBSCRIBE no established upstream, trying on-demand", slog.Bool("entry_exists", ok)) - // Try to establish an upstream subscription against a local - // publisher that has advertised the namespace. The established - // entry is fetched again below via AddDownstreamSnapshotLargest, - // so only the side effect (registering the upstream) and the - // established check matter here. var extra message.Parameters if hasNewGroupReq { extra = message.Parameters{message.NewGroupRequestParam(newGroupReqParam.Varint)} } - // §9.2: the upstream Forward value is MUST=1 only if a downstream - // subscriber wants forwarding. The triggering sub is not yet on the - // entry (AddDownstreamSnapshotLargest runs below), so consult it - // directly alongside any downstream already registered on e. + // §9.2: Forward=1 upstream only if some downstream forwards; sub + // is not on the entry yet, so it is checked directly. wantForward := sub.ForwardState() == 1 || anyDownstreamForwards(e) _, established, err := h.subscribeUpstream(ctx, fullName, extra, wantForward) if err != nil { - // A candidate existed but every establish attempt errored. Log - // at Info with the underlying error so this transient failure is - // distinguishable in production from a genuine "nobody serves it". h.log.LogAttrs(ctx, slog.LevelInfo, "SUBSCRIBE rejected: upstream subscribe failed", slog.String("namespace", fmt.Sprintf("%v", msg.Namespace)), slog.String("name", string(msg.Name)), @@ -111,10 +81,6 @@ func (h *sessionHandler) handleSubscribe(ctx context.Context, req *session.Reque return } if !established { - // No local publisher and no remote advertiser — a genuine miss - // or an advertise/subscribe race. Log at Info with the track - // identity + RequestID so it correlates with the client-side - // error (default level hides Debug). h.log.LogAttrs(ctx, slog.LevelInfo, "SUBSCRIBE rejected: no publisher for namespace", slog.String("namespace", fmt.Sprintf("%v", msg.Namespace)), slog.String("name", string(msg.Name)), @@ -128,12 +94,8 @@ func (h *sessionHandler) handleSubscribe(ctx context.Context, req *session.Reque h.log.LogAttrs(ctx, slog.LevelDebug, "SUBSCRIBE serving from existing upstream") } - // Atomically append sub to the entry's Downstream AND snapshot the - // current LargestObject under one entry.mu acquisition. The atomic - // pairing closes the race where a publisher write between separate - // Add + GetLargest calls would update LargestObject + cache the - // object without delivering it to us via live fanout — leaving a - // gap that neither live delivery nor a fill fetch stream covers. + // Register and snapshot Largest atomically, so no object falls between + // live delivery and the fill fetch stream. entry, snapshotLargest, snapshotHas, added = h.tracks.AddDownstreamSnapshotLargest(fullName, sub) if added { break @@ -147,11 +109,8 @@ func (h *sessionHandler) handleSubscribe(ctx context.Context, req *session.Reque sub.SetLargestAtSubscribe(snapshotLargest, snapshotHas) // §9.5: "Relays MUST send SUBSCRIBE messages to all matching publishers". h.subscribeMissingPublishers(ctx, entry, reusedUpstream, pubSeq) - // §10.20: a track that just gained its upstream through this SUBSCRIBE is - // news to SUBSCRIBE_TRACKS holders under its namespace, which so far were - // offered only tracks a publisher PUBLISHed. Offered after this downstream - // is registered, so this subscriber, if it holds one, is not also sent a - // PUBLISH for the track it just subscribed to. + // §10.20: a newly upstreamed track is offered to SUBSCRIBE_TRACKS holders; + // after registration, so this subscriber is not offered its own track. if !reusedUpstream { h.forwardToTrackSubscribers(entry) } @@ -162,10 +121,8 @@ func (h *sessionHandler) handleSubscribe(ctx context.Context, req *session.Reque defer h.tracks.RemoveDownstream(fullName, sub.ID) - // §10.2.17: "If Objects have been published on this Track the - // Publisher MUST include this parameter." LARGEST_OBJECT in - // SUBSCRIBE_OK tells the subscriber where the live edge was when the - // subscription was accepted, which §5.1.6 has it use to size a fill. + // §10.2.17: "If Objects have been published on this Track the Publisher + // MUST include this parameter." var okParams message.Parameters if sub.HasLargestAtSubscribe { okParams = message.Parameters{ @@ -179,12 +136,9 @@ func (h *sessionHandler) handleSubscribe(ctx context.Context, req *session.Reque if sub.IncludesProperties() { // §10.2.21 properties = entry.GetProperties() } - // sub.WriteSubscribeOK, not req.Reply: the sub is registered, so a - // registry teardown goroutine can already reach it — every write on this - // stream must go through the sub's write lock from here on, and the - // OK/termination race must resolve to exactly one §10.7 response - // (a terminator that wins answers with REQUEST_ERROR and this write is - // skipped; see [registry.DownstreamSub.WriteSubscribeOK]). + // Not req.Reply: the sub is registered, so every write must go through + // its write lock, and a racing termination yields exactly one response + // (see [registry.DownstreamSub.WriteSubscribeOK]). if err := sub.WriteSubscribeOK(&message.SubscribeOK{ TrackAlias: alias, Parameters: okParams, @@ -198,51 +152,32 @@ func (h *sessionHandler) handleSubscribe(ctx context.Context, req *session.Reque slog.String("name", string(msg.Name)), slog.Uint64("alias", alias)) - // §5.1.3: FILL_PARAMETERS on the SUBSCRIBE asks for a fill fetch stream, - // draft-20's replacement for the Joining FETCH. installSubscribeParams - // already rejected a malformed one, so an error here cannot be a protocol - // violation the peer has not been told about. + // §5.1.3: FILL_PARAMETERS asks for a fill fetch stream; a malformed one + // was already rejected by installSubscribeParams. if err := h.maybeServeFill(ctx, sub, entry, fullName, msg.RequestID, msg.Parameters); err != nil { h.log.LogAttrs(ctx, slog.LevelDebug, "fill fetch stream not opened", slog.String("err", err.Error())) } - // §10.2.19: when the SUBSCRIBE carried a NEW_GROUP_REQUEST and we are - // serving from an already-Established upstream (so the request did not ride - // a fresh upstream SUBSCRIBE), evaluate it against the upstream as an - // Established subscription and forward it if the rules call for it. if hasNewGroupReq && reusedUpstream { h.propagateNewGroupUpstream(ctx, fullName, newGroupReqParam.Varint) } - // §9.2: a Forward=1 subscriber reusing an existing upstream that was paused - // (Forward=0, established when earlier downstreams didn't forward) MUST - // resume it. A freshly established upstream already reflects this subscriber - // via wantForward, so only the reuse path needs it; propagateForwardUpstream - // skips upstreams already forwarding, making this a no-op otherwise. + // §9.2: a Forward=1 subscriber resumes a paused upstream it reuses. if reusedUpstream && sub.ForwardState() == 1 { h.propagateForwardUpstream(ctx, fullName) } - // Read follow-ups (§10.9 REQUEST_UPDATE) on the bidi stream until the - // subscriber cancels, the relay ends the subscription, or ctx is - // cancelled, dispatching REQUEST_UPDATE so Forward / priority / filter can - // change mid-flight. A subscriber FIN is not a cancel (§3.3.2). h.readSubscribeUpdates(ctx, req.Stream, sub, fullName) h.log.LogAttrs(ctx, slog.LevelDebug, "SUBSCRIBE stream ended", slog.String("name", string(msg.Name))) } -// readSubscribeUpdates is the follow-up dispatch loop for an established -// downstream SUBSCRIBE. It parses messages off the bidi request stream and -// routes REQUEST_UPDATE (§10.9) to [sessionHandler.handleSubscribeUpdate]; -// any other DECODABLE follow-up is ignored. An undecodable one ends the -// loop and resets the read side (see [readRequestStream]). A reset (the -// subscriber cancelled) or ctx cancellation (session shutdown) ends it too. -// A subscriber FIN does not: §3.3.2 says it "is not a request cancellation", -// so the subscription lives on in [awaitRequestEnd] until the subscriber's -// STOP_SENDING or the relay's own PUBLISH_DONE + FIN. On return the deferred -// cleanup in handleSubscribe evicts the subscription. +// readSubscribeUpdates routes REQUEST_UPDATEs (§10.9) on a downstream +// SUBSCRIBE's stream to [sessionHandler.handleSubscribeUpdate] until the +// subscriber cancels, the stream turns undecodable (see [readRequestStream]) +// or ctx ends. A subscriber FIN is not a cancellation (§3.3.2): the +// subscription lives on in [awaitRequestEnd]. func (h *sessionHandler) readSubscribeUpdates( ctx context.Context, stream session.Stream, @@ -280,20 +215,13 @@ func (h *sessionHandler) readSubscribeUpdates( return true }) if fin { - // The subscriber will send no more updates; the subscription lives - // on until it cancels or ends (§3.3.2). awaitRequestEnd(ctx, stream) } } -// handleSubscribeUpdate applies a REQUEST_UPDATE (§10.9) to an established -// downstream subscription. Per §10.9 only the parameters present in the -// update change; omitted ones keep their prior value — which is exactly the -// "override present" behaviour of [installSubscribeParams]. On success it -// records whether the Forward State flipped 0→1 (so it can propagate Forward -// upstream per §9.2) and replies with the single mandated REQUEST_OK. On a -// malformed update it replies REQUEST_ERROR and terminates the subscription -// with PUBLISH_DONE / UPDATE_FAILED. +// handleSubscribeUpdate applies a REQUEST_UPDATE (§10.9) to a downstream +// subscription: present parameters override, omitted ones are kept. A +// malformed update gets REQUEST_ERROR and PUBLISH_DONE / UPDATE_FAILED. func (h *sessionHandler) handleSubscribeUpdate( ctx context.Context, sub *registry.DownstreamSub, @@ -303,10 +231,7 @@ func (h *sessionHandler) handleSubscribeUpdate( prevForward := sub.ForwardState() if err := installSubscribeParams(sub, upd.Parameters); err != nil { if _, ok := errors.AsType[*paramProtocolViolation](err); ok { - // §10.2.8 / §10.2.18: an out-of-range GROUP_ORDER/FORWARD is a - // session-level PROTOCOL_VIOLATION even in a REQUEST_UPDATE — the - // wire-level value is invalid, so it supersedes §10.9's - // request-scoped update-failure path. + // §10.2.8 / §10.2.18: session-level even in a REQUEST_UPDATE. h.log.LogAttrs(ctx, slog.LevelDebug, "REQUEST_UPDATE parameter protocol violation", slog.String("err", err.Error())) _ = h.sess.Close(moqt.SessionProtocolViolation, err.Error()) @@ -314,11 +239,8 @@ func (h *sessionHandler) handleSubscribeUpdate( } h.log.LogAttrs(ctx, slog.LevelDebug, "REQUEST_UPDATE parameter parse failed", slog.String("err", err.Error())) - // §10.9.1: a failed subscription update is answered with REQUEST_ERROR - // and the publisher MUST also terminate the subscription with - // PUBLISH_DONE / UPDATE_FAILED. A bad Range Filter uses INVALID_FILTER - // (§10.6); other malformed params use MALFORMED_TRACK. Writes go through - // the sub's lock — see [registry.DownstreamSub.WriteMessage]. + // §10.9.1: REQUEST_ERROR, then PUBLISH_DONE / UPDATE_FAILED. Writes go + // through the sub's lock. code := moqt.RequestMalformedTrack if errors.Is(err, message.ErrInvalidFilter) { code = moqt.RequestInvalidFilter @@ -331,9 +253,7 @@ func (h *sessionHandler) handleSubscribeUpdate( return } - // §10.2.17: "If Objects have been published on this Track the Publisher - // MUST include" LARGEST_OBJECT, in REQUEST_UPDATE_OK as elsewhere; §10.9.1 - // has the subscriber FETCH a widened range's gap up to it. + // §10.2.17: LARGEST_OBJECT in REQUEST_UPDATE_OK too. reply := &message.RequestOK{} if entry, ok := h.tracks.Get(fullName.Key()); ok { if largest, has := entry.GetLargest(); has { @@ -346,22 +266,18 @@ func (h *sessionHandler) handleSubscribeUpdate( return } - // §9.2: if this update flipped the downstream Forward State 0→1 and the - // relay's upstream subscriptions are paused (Forward 0), the relay MUST - // send REQUEST_UPDATE with Forward=1 to its publishers. + // §9.2: a 0→1 Forward flip resumes paused upstreams. if prevForward == 0 && sub.ForwardState() == 1 { h.propagateForwardUpstream(ctx, fullName) } - // §10.2.19: a NEW_GROUP_REQUEST on a REQUEST_UPDATE for an Established - // subscription is forwarded upstream when the relay rules call for it. + // §10.2.19 if p, ok := upd.Parameters.Find(message.ParamNewGroupRequest); ok { h.propagateNewGroupUpstream(ctx, fullName, p.Varint) } - // §5.1.3: FILL_PARAMETERS on a REQUEST_UPDATE opens a further fill fetch - // stream, named by the REQUEST_UPDATE's own Request ID. It does not cancel - // any fill already in flight — a subscription can have several open at once. + // §5.1.3: a further fill fetch stream, named by the REQUEST_UPDATE's own + // Request ID; fills already in flight continue. if entry, ok := h.tracks.Get(fullName.Key()); ok { if err := h.maybeServeFill(ctx, sub, entry, fullName, upd.RequestID, upd.Parameters); err != nil { h.log.LogAttrs(ctx, slog.LevelDebug, "fill fetch stream not opened", @@ -370,12 +286,9 @@ func (h *sessionHandler) handleSubscribeUpdate( } } -// propagateNewGroupUpstream implements the §10.2.19 relay handling for a -// NEW_GROUP_REQUEST received on an Established downstream subscription: when the -// track supports dynamic Groups and the request is not already covered, the -// relay sends a REQUEST_UPDATE carrying NEW_GROUP_REQUEST on each upstream -// subscription's stream. [registry.TrackEntry.ConsiderNewGroupRequest] encapsulates the -// decision and outstanding-request bookkeeping. +// propagateNewGroupUpstream forwards a downstream NEW_GROUP_REQUEST to each +// upstream as a REQUEST_UPDATE when §10.2.19 calls for it (see +// [registry.TrackEntry.ConsiderNewGroupRequest]). func (h *sessionHandler) propagateNewGroupUpstream( ctx context.Context, fullName track.FullTrackName, @@ -388,9 +301,7 @@ func (h *sessionHandler) propagateNewGroupUpstream( dynamic, err := entry.DynamicGroups() if err != nil { - // §12.6: a DYNAMIC_GROUPS value > 1 is a protocol violation by the - // upstream publisher. Scope the failure to declining the request - // rather than tearing the session down. + // §12.6: a bad DYNAMIC_GROUPS only declines the request here. h.log.LogAttrs(ctx, slog.LevelDebug, "NEW_GROUP_REQUEST: bad DYNAMIC_GROUPS property", slog.String("err", err.Error())) return @@ -409,20 +320,13 @@ func (h *sessionHandler) propagateNewGroupUpstream( slog.String("err", err.Error())) continue } - // §10.2.17 item 1 names REQUEST_UPDATE_OK too, and this is one: the - // response was previously discarded, so a watermark the upstream - // reported here never reached the entry. + // §10.2.17 item 1 includes REQUEST_UPDATE_OK. saveLargestLocation(entry, resp.Parameters) } } -// propagateForwardUpstream implements the §9.2 relay obligation: when a -// downstream subscription becomes Forward=1 while the upstream subscriptions -// feeding its track are Forward=0, the relay re-emits REQUEST_UPDATE with -// Forward=1 on each upstream subscription's stream. The upstream's -// REQUEST_UPDATE_OK may carry LARGEST_OBJECT (§10.2.17); we fold it into the -// track entry's largest watermark so a subsequent fill fetch stream (§5.1.3) -// is contiguous. +// propagateForwardUpstream sends REQUEST_UPDATE Forward=1 to each paused +// upstream of fullName (§9.2), saving any LARGEST_OBJECT in the reply. func (h *sessionHandler) propagateForwardUpstream(ctx context.Context, fullName track.FullTrackName) { entry, ok := h.tracks.Get(fullName.Key()) if !ok { @@ -443,29 +347,20 @@ func (h *sessionHandler) propagateForwardUpstream(ctx context.Context, fullName } } -// subscribeUpstream establishes upstream SUBSCRIBEs for fullName. Per §9.5 it -// subscribes to EVERY matching source for fault tolerance — every local -// publisher advertising a covering namespace and every remote relay Discovery -// resolves (§9.4 cross-relay aggregation) — deduping already-subscribed -// sessions and fanning the rest into one track. The remote-relay branch honors -// an opt-in Config.UpstreamFanIn cap (see [upstreamPool.resolveUpstreams]); the -// local-publisher branch always takes every match. A failed candidate does not -// abort the others. Returns (entry, true, nil) when at least one upstream was -// established, (nil, false, nil) when none is available anywhere, and -// (nil, false, err) when every candidate failed with the last a hard error. -// -// extra carries parameters folded into each upstream SUBSCRIBE alongside the -// Next Object filter (§5.1.2) — currently the NEW_GROUP_REQUEST a downstream -// SUBSCRIBE arrived with (§10.2.19 rule 1). +// subscribeUpstream subscribes fullName on every matching source (§9.5): +// each local publisher of a covering namespace and each remote relay +// Discovery resolves (capped by Config.UpstreamFanIn), skipping sessions +// already subscribed. It returns (entry, true, nil) when any upstream was +// established, (nil, false, nil) when there is no source, and +// (nil, false, err) when every candidate failed. extra is added to each +// upstream SUBSCRIBE. func (h *sessionHandler) subscribeUpstream( ctx context.Context, fullName track.FullTrackName, extra message.Parameters, wantForward bool, ) (*registry.TrackEntry, bool, error) { - // Source dedup: never open a second upstream to a session this track is - // already subscribed on (or to ourselves — a publisher session also owns its - // PUBLISH_NAMESPACE, so subscribing on it would self-loop). + // Never subscribe twice on one session, nor on our own (a self-loop). subscribed := map[*session.Session]bool{h.sess: true} if entry, ok := h.tracks.Get(fullName.Key()); ok { for _, u := range entry.CopyUpstream() { @@ -483,11 +378,8 @@ func (h *sessionHandler) subscribeUpstream( return } subscribed[sess] = true // even on failure: don't retry the same source here - // Hold the claim when it is free, so a late-publisher SUBSCRIBE for the - // same (publisher, track) skips rather than duplicating this one. Never - // wait on another holder: its SUBSCRIBE carried its own Forward and - // NEW_GROUP_REQUEST and may fail for its own reasons, so this request - // subscribes for itself (§9.5), as it always has. + // Hold the claim when free, so a late-publisher SUBSCRIBE skips. Never + // wait on another holder: its SUBSCRIBE may fail for its own reasons. if release, claimed := h.tracks.ClaimUpstream(sess, fullName.Key()); claimed { defer release() } @@ -495,12 +387,8 @@ func (h *sessionHandler) subscribeUpstream( slog.String("source", src)) entry, _, err := h.subscribeUpstreamOnSession(ctx, sess, fullName, extra, wantForward) if err != nil { - // A candidate that fails (session dying, rejection) must not mask the - // other publishers or the Discovery fallback. Remember the error and - // keep going; surface it only if nothing else works out. A - // Track Properties refusal outranks any other failure: §2.5.1 - // fixes the downstream code for it, so a later candidate's - // unrelated error must not replace it. + // Keep going. A Track Properties refusal outranks other errors: + // §2.5.1 fixes its downstream code. if !isTrackPropertiesErr(lastErr) { lastErr = err } @@ -514,7 +402,6 @@ func (h *sessionHandler) subscribeUpstream( } } - // 1. Every local publisher that advertised a namespace covering fullName. publishers := h.names.MatchPublishers(fullName.Namespace) h.log.LogAttrs(ctx, slog.LevelDebug, "subscribeUpstream: namespace registry lookup", slog.String("namespace", fmt.Sprintf("%v", fullName.Namespace)), @@ -523,11 +410,6 @@ func (h *sessionHandler) subscribeUpstream( establish(pub.Session, "local-publisher") } - // 2. Remote relays Discovery resolves for this namespace. §9.5 fans into - // every advertiser by default; a positive Config.UpstreamFanIn instead - // caps this to the top rendezvous-ranked few. The pool dials + reuses one - // session per RelayAddr; resolveUpstreams returns nil when no other relay - // (besides ourselves) serves the namespace. remotes := h.upstreams.resolveUpstreams(ctx, fullName.Namespace) for _, remote := range remotes { establish(remote, "discovery-remote") @@ -536,14 +418,6 @@ func (h *sessionHandler) subscribeUpstream( if anyEstab { return resultEntry, true, nil } - // No upstream anywhere. Surface a candidate's failure if one occurred - // (a better diagnostic than a bare "no publisher"); otherwise (nil,false,nil) - // drives the §9.4 "does not exist" rejection. - // - // Log a summary at Info so the empty-result case is visible in production - // (default level hides Debug): it separates "no local publisher AND no - // remote advertiser" (a genuine miss or an advertise/subscribe race) from - // "candidates existed but every establish failed" (lastErr set). logAttrs := []slog.Attr{ slog.String("namespace", fmt.Sprintf("%v", fullName.Namespace)), slog.String("name", string(fullName.Name)), @@ -557,16 +431,8 @@ func (h *sessionHandler) subscribeUpstream( return nil, false, lastErr } -// subscribeUpstreamOnSession issues the upstream SUBSCRIBE on sess and registers -// the resulting [registry.UpstreamSub] on the track entry. sess is either a local -// publisher's session or a Discovery-resolved remote relay's session — the body -// is identical, only the source differs. -// -// The §9.4 aggregation rule applies: the upstream SUBSCRIBE always uses the -// Next Object filter (§5.1.2) so the upstream subscription's lifetime is decoupled -// from any specific downstream subscriber's filter. The relay can then serve -// many disparate downstream filters from one upstream stream — the fanout -// enforces each downstream filter on the wire. +// subscribeUpstreamOnSession issues the upstream SUBSCRIBE on sess and +// registers the resulting [registry.UpstreamSub] on the track entry. func (h *sessionHandler) subscribeUpstreamOnSession( ctx context.Context, sess *session.Session, @@ -577,20 +443,12 @@ func (h *sessionHandler) subscribeUpstreamOnSession( if peerSentGoaway(sess) { return nil, nil, errPeerGoingAway } - // Next Object filter (§5.1.2) — keeps the upstream subscription stable - // as downstream subscribers come and go with varying filters. + // §9.4: always Next Object (§5.1.2), so one upstream serves every + // downstream filter; the fanout applies those. filter := &message.LocationFilter{Fields: 2} - // Bind the SUBSCRIBE message so we can read back the Request ID the - // session assigned (Subscribe mutates m.RequestID via AllocRequestID). - // The relay reuses that ID when it later sends an upstream - // REQUEST_UPDATE for §9.2 Forward propagation. params := message.Parameters{message.LocationFilterParam(filter)} - // §9.2: Forward=1 upstream is MUST only when a downstream subscriber wants - // Objects forwarded. When none does, the relay exercises its discretion by - // pausing the upstream with Forward=0 so it doesn't pull Objects nobody is - // consuming; propagateForwardUpstream resumes it when a downstream later - // sets Forward=1. Forward=1 stays implicit (omitted) per §10.2.18. + // §9.2: with no forwarding downstream, pause the upstream (Forward=0). if !wantForward { params = append(params, message.ForwardParam(false)) } @@ -600,30 +458,17 @@ func (h *sessionHandler) subscribeUpstreamOnSession( Name: fullName.Name, Parameters: params, } - // Create the track entry before Subscribe, because Subscribe registers - // the SUBSCRIBE_OK's §11.1 Track Alias inside its own response handler — - // from the moment it returns the alias resolves on inbound data streams, - // and the publisher may already be writing. Until an entry exists there is - // nothing for runFanout to route to, so those streams are reset and their - // Objects lost from the cache and from live fanout alike (#85). - // - // Deliberately before rather than inside the round trip: anything done in - // the gap between SUBSCRIBE_OK arriving and the alias being registered - // widens a second window, in which the same streams wait for their alias - // (runFanout holds them only briefly; see resolveInboundTrack), and - // allocating an entry (a 1024-slot cache ring) there measurably does. - // Doing it up front costs an entry for a track that may turn out not to - // exist; trackKnown in handleFetch is what keeps that from being visible - // on the wire. + // Create the entry before Subscribe: the alias resolves as soon as + // Subscribe returns and streams may already be arriving, which runFanout + // can only route to an existing entry. Not inside the round trip, which + // would widen the window streams wait for their alias. var entryCreated bool _, entryCreated = h.tracks.GetOrCreateNew(fullName) upstreamStream, err := sess.Subscribe(ctx, subMsg) if err != nil { if entryCreated { - // Nothing vouched for this track after all. Leaving the entry - // would grow the registry without bound on a session that - // SUBSCRIBEs to names that do not resolve. + // Don't let unresolved names grow the registry. h.tracks.DeleteIfUnused(fullName) } return nil, nil, err @@ -632,81 +477,47 @@ func (h *sessionHandler) subscribeUpstreamOnSession( (*hook)(fullName) } - // Register the upstream subscription on the upstream session as an - // registry.UpstreamSub. The upstream's TrackAlias is the alias the upstream peer - // assigned in SUBSCRIBE_OK; we use it for the fanout's alias remapping. - // The SUBSCRIBE's Request ID (assigned inside sess.Subscribe) is recorded - // for identity; a later upstream REQUEST_UPDATE rides this stream but - // consumes its own fresh ID (§10.1). - // On its own SUBSCRIBE the relay is the requester, so the publisher may - // not send REQUEST_UPDATE (§10.9). + // The relay is the requester, so the publisher may not send + // REQUEST_UPDATE (§10.9). upstreamSub := registry.NewUpstreamSub( h.allocSubID(), sess, upstreamStream, upstreamStream.OK.TrackAlias, subMsg.RequestID, false) upstreamSub.SetFilter(filter) if !wantForward { - // Match the local ForwardState to the Forward=0 we sent upstream, so a - // later §9.2 resume (propagateForwardUpstream) transitions 0→1 rather - // than treating the upstream as already forwarding. NewUpstreamSub - // seeds 1 (the omitted-FORWARD default). upstreamSub.SetForwardState(0) } - // This upstream is a relay/origin we SUBSCRIBE'd on demand, so it is - // expected to answer FETCH — eligible for §9.4 stitch backfill. + // Eligible for §9.4 stitch backfill. upstreamSub.FetchCapable = true - // Mark it on-demand so the registry tears it down when its last - // downstream leaves (see [registry.TrackRegistry.RemoveDownstream]). + // Torn down with its last downstream. upstreamSub.OnDemand = true entry, _ := h.tracks.AddUpstream(fullName, upstreamSub, registry.WithProperties(upstreamStream.OK.TrackProperties)) - // §10.2.17 item 1: a LARGEST_OBJECT in this SUBSCRIBE_OK is one of the - // values the relay's own watermark MUST be the largest of. Unconditional on - // purpose — see [saveLargestLocation] for why the §5.1 Forward-State - // qualifier must not be applied here. + // §10.2.17 item 1; unconditional, see [saveLargestLocation]. saveLargestLocation(entry, upstreamStream.OK.Parameters) - // The reader's lifetime is tied to the UPSTREAM stream, not to the - // downstream subscriber whose SUBSCRIBE happened to trigger this - // subscription — other sessions' subscribers share it (§9.4), so it - // must survive this handler's teardown. It runs relay-scoped (joined - // by Relay.Stop) with the stream's own context: the ctx dies when the - // upstream stream or session ends, and Stop force-closes sessions, - // which errors the reader's Parse either way. + // Relay-scoped, on the upstream stream's context: other sessions' + // subscribers share it (§9.4), so it outlives this handler. h.relayGo(func() { h.serveUpstreamStream(upstreamStream.Context(), upstreamSub) h.tracks.RemoveUpstream(fullName, upstreamSub.ID) - // session.Subscribe registered the SUBSCRIBE_OK's Track Alias for - // inbound routing; drop it with the subscription so subscriber - // churn on a long-lived (pooled) upstream session doesn't accrete - // aliases (§11.1) — a peer reusing a retired alias would otherwise - // trip the duplicate-alias session error. + // Drop the alias with the subscription, so a peer may reuse it (§11.1). sess.UnregisterInboundTrackAlias(upstreamStream.OK.TrackAlias) }) return entry, upstreamSub, nil } -// serveUpstreamStream owns ALL reads on an upstream request stream (the -// relay's on-demand SUBSCRIBE to a publisher, or an accepted PUBLISH) via -// the sub's [session.RequestBroker]: §10.9 responses route to in-flight -// [registry.UpstreamSub.Update] calls; a peer REQUEST_UPDATE closes the -// session with PROTOCOL_VIOLATION on the relay's own SUBSCRIBE (§10.9: the -// publisher did not send the request) and is declined with NOT_SUPPORTED on an -// accepted PUBLISH (the relay installs no update handler); and -// AUTHORIZATION_TOKEN parameters -// go through the session token cache (§10.2.2) — all inside Serve. Other -// follow-ups need no action (PUBLISH_DONE precedes the FIN that ends the -// loop); unsolicited responses are logged. It returns when the publisher -// tears the stream down (EOF / reset) or ctx is cancelled. +// serveUpstreamStream owns all reads on an upstream request stream (the +// relay's SUBSCRIBE, or an accepted PUBLISH) via the sub's +// [session.RequestBroker], which routes §10.9 responses to +// [registry.UpstreamSub.Update]. It returns when the stream ends or ctx is +// cancelled. // -// Do NOT read the stream anywhere else (e.g. [session.DrainAndWait] or -// [session.Session.UpdateRequest]) while this runs: a second reader races +// Nothing else may read the stream while this runs: a second reader races // the broker for the §10.9 responses. func (h *sessionHandler) serveUpstreamStream(ctx context.Context, up *registry.UpstreamSub) { err := up.Broker.Serve(ctx, func(m message.Message) bool { switch m := m.(type) { case *message.PublishDone: - // Kept for the downstream PUBLISH_DONE code (§10.12); the - // publisher FINs the stream afterwards, which ends the loop and - // lets the caller unregister the upstream. + // Kept for the downstream PUBLISH_DONE code (§10.12). up.SetPublishDone(m) case *message.RequestOK, *message.RequestError: // Serve only hands responses here when no Update was pending. @@ -722,10 +533,8 @@ func (h *sessionHandler) serveUpstreamStream(ctx context.Context, up *registry.U } } -// hasEstablishedUpstream reports whether the entry has at least one upstream -// subscription in [registry.SubEstablished]. The §9.4 SUBSCRIBE handler uses this as -// the test for "can we serve a new downstream subscription from existing -// upstream state?". +// hasEstablishedUpstream reports whether the entry has an upstream +// subscription in [registry.SubEstablished]. func hasEstablishedUpstream(entry *registry.TrackEntry) bool { for _, u := range entry.CopyUpstream() { if u.IsEstablished() { @@ -735,24 +544,16 @@ func hasEstablishedUpstream(entry *registry.TrackEntry) bool { return false } -// anyDownstreamForwards reports whether any downstream subscriber already -// registered on entry wants Objects forwarded (Forward=1). §9.2 uses it (with -// the not-yet-registered triggering sub checked separately) to decide the -// upstream Forward value. A nil entry (no track state yet) reports false. +// anyDownstreamForwards reports whether a downstream on entry (which may be +// nil) has Forward=1. func anyDownstreamForwards(entry *registry.TrackEntry) bool { return entry != nil && slices.ContainsFunc(entry.CopyDownstream(), func(d *registry.DownstreamSub) bool { return d.ForwardState() == 1 }) } -// installSubscribeParams extracts the per-subscription policy fields from the -// SUBSCRIBE parameters (§10.2) and records them on sub: LOCATION_FILTER -// (§10.2.9), SUBSCRIBER_PRIORITY (§10.2.7, advisory), GROUP_ORDER (§10.2.8), -// OBJECT_DELIVERY_TIMEOUT (§10.2.4) and SUBGROUP_DELIVERY_TIMEOUT (§10.2.3). -// -// The §5.1.2 / §9.4 LargestObject snapshot is intentionally NOT taken here — the -// caller captures it atomically via -// [registry.TrackRegistry.AddDownstreamSnapshotLargest] and applies it with -// [registry.DownstreamSub.SetLargestAtSubscribe]. +// installSubscribeParams records the subscription parameters present in ps +// (§10.2) on sub, leaving absent ones unchanged. The Largest snapshot is the +// caller's (see [registry.TrackRegistry.AddDownstreamSnapshotLargest]). func installSubscribeParams(sub *registry.DownstreamSub, ps message.Parameters) error { filter, err := message.LocationFilterFromParam(ps) if err != nil { @@ -783,13 +584,8 @@ func installSubscribeParams(sub *registry.DownstreamSub, ps message.Parameters) } sub.SetIncludeProperties(includeProperties(ps)) - // §10.2.3 / §10.2.4 delivery timeouts, overridden per parameter — the same - // "override present" behaviour as the fields above, applied to each - // dimension separately rather than to the pair. They are independent values - // that merely travel together: an absent parameter decodes to zero and §8 - // gives zero the meaning "no timeout", so installing the decoded pair - // whenever either is present would let a REQUEST_UPDATE that adjusts one - // timeout silently disable the other. + // §10.2.3 / §10.2.4: each timeout separately, so an update of one does + // not zero ("no timeout", §8) the other. timeouts := sub.GetDeliveryTimeouts() if p, ok := ps.Find(message.ParamObjectDeliveryTimeout); ok { timeouts.Object = message.MillisecondTimeout(p.Varint) @@ -799,12 +595,7 @@ func installSubscribeParams(sub *registry.DownstreamSub, ps message.Parameters) } sub.SetDeliveryTimeouts(timeouts) - // §5.1.4 Range Filters, merged into the subscription's current ones: a - // type the parameters name is replaced (removed by a zero-length one), the - // others are unchanged. A new subscription has none, so this is simply - // its filters. A malformed/over-limit set is a §10.6 INVALID_FILTER - // (request-scoped) — the caller maps message.ErrInvalidFilter to - // REQUEST_ERROR INVALID_FILTER. + // §5.1.4: a named Range Filter type is replaced, others are kept. if !slices.ContainsFunc(ps, func(p message.Parameter) bool { return message.IsRangeFilterParam(p.Type) }) { return nil } @@ -837,10 +628,8 @@ func (h *sessionHandler) refuseSubscriptionParams(ctx context.Context, req *sess _ = req.RejectError(moqt.RequestInvalidFilter, err.Error()) return } - // §5.1.2 says a malformed LOCATION_FILTER is also a session-level - // PROTOCOL_VIOLATION. We scope that one to this request for now — - // unrelated subscriptions on the same session shouldn't die because - // one peer sent a bad filter. + // Deviation: §5.1.2 makes a malformed LOCATION_FILTER a session-level + // PROTOCOL_VIOLATION; the relay scopes it to the request. h.log.LogAttrs(ctx, slog.LevelDebug, "subscription parameter parse failed", slog.String("err", err.Error())) _ = req.RejectError(moqt.RequestMalformedTrack, err.Error()) @@ -850,17 +639,14 @@ func (h *sessionHandler) refuseSubscriptionParams(ctx context.Context, req *sess // sent GOAWAY (§10.4; see [peerSentGoaway]). var errPeerGoingAway = errors.New("relay: peer sent GOAWAY; no new requests to it (§10.4)") -// paramProtocolViolation marks a parameter value that the draft requires the -// receiver answer with a session-level PROTOCOL_VIOLATION — an out-of-range -// GROUP_ORDER (§10.2.8) or FORWARD (§10.2.18) — as opposed to a request-scoped -// REQUEST_ERROR. Callers detect it with errors.AsType and close the session -// with [moqt.SessionProtocolViolation]. +// paramProtocolViolation marks a parameter value that closes the session with +// PROTOCOL_VIOLATION: an out-of-range GROUP_ORDER (§10.2.8) or FORWARD +// (§10.2.18). type paramProtocolViolation struct{ reason string } func (e *paramProtocolViolation) Error() string { return e.reason } -// checkForwardParam enforces the §10.2.18 FORWARD value range (0 or 1). An -// out-of-range value is a *paramProtocolViolation; absent or valid → nil. +// checkForwardParam enforces the §10.2.18 FORWARD range (0 or 1). func checkForwardParam(ps message.Parameters) error { if p, ok := ps.Find(message.ParamForward); ok && p.Byte > 1 { return ¶mProtocolViolation{fmt.Sprintf("invalid FORWARD value 0x%X (§10.2.18)", p.Byte)} @@ -868,9 +654,7 @@ func checkForwardParam(ps message.Parameters) error { return nil } -// checkGroupOrderParam enforces the §10.2.8 GROUP_ORDER value range (Ascending -// 0x1 or Descending 0x2). An out-of-range value is a *paramProtocolViolation; -// absent or valid → nil. +// checkGroupOrderParam enforces the §10.2.8 GROUP_ORDER range (0x1 or 0x2). func checkGroupOrderParam(ps message.Parameters) error { if p, ok := ps.Find(message.ParamGroupOrder); ok { switch message.GroupOrder(p.Byte) { @@ -882,11 +666,8 @@ func checkGroupOrderParam(ps message.Parameters) error { return nil } -// includeProperties reports whether a request's INCLUDE_PROPERTIES (§10.2.21) -// asks for Track Properties in the response or the resulting PUBLISHes: yes -// unless it is 0 ("If INCLUDE_PROPERTIES is 0, the Track Properties are still -// present in the message, but they SHOULD be empty"). The session has already -// closed over a value other than 0 or 1. +// includeProperties reports whether INCLUDE_PROPERTIES (§10.2.21) asks for +// Track Properties: yes unless it is 0. func includeProperties(ps message.Parameters) bool { p, ok := ps.Find(message.ParamIncludeProperties) return !ok || p.Byte != 0 @@ -895,31 +676,14 @@ func includeProperties(ps message.Parameters) bool { // upstreamRejection is the REQUEST_ERROR for a downstream SUBSCRIBE whose // upstream SUBSCRIBE failed with err. // -// §2.5.1: when the upstream's track carries a Mandatory Track Property this -// relay does not understand, a relay "MUST send REQUEST_ERROR with error code -// UNSUPPORTED_EXTENSION to the downstream subscribers". Unparseable Track -// Properties are MALFORMED_TRACK, which is this repo's choice: the draft does -// not cover them. -// -// An upstream REQUEST_ERROR is passed on by meaning — §10.6.2: "The -// application SHOULD use a relevant error code" — with its Retry Interval -// kept, so "retry in N ms" does not become "SHOULD NOT be retried". A code -// about the track or the publisher's load says the same thing to the -// downstream subscriber and passes through. MALFORMED_TRACK is among them: -// §10.6.2 scopes it to FETCH, but this relay already answers a SUBSCRIBE with -// it over malformed Track Properties. One about the relay's own hop — its -// authorization, the upstream going away, a REDIRECT the relay does not -// follow — or about the relay's own upstream filter (INVALID_RANGE, -// INVALID_FILTER), or a code this relay does not know, becomes -// INTERNAL_ERROR. The upstream SUBSCRIBE always carries the relay's own Next -// Object filter, which is its choice under §9.4's "MAY combine filters from -// downstream subscribers"; if it ever combines them, INVALID_RANGE becomes -// about the downstream request and this mapping must change. -// -// Any other failure (the upstream session died mid-request) reads as the -// track not existing. There is no local deadline on the upstream SUBSCRIBE; one -// that expired would be §10.6.2's TIMEOUT example, "a relay could not -// establish an upstream subscription within the timeout". +// An unknown Mandatory Track Property is UNSUPPORTED_EXTENSION (§2.5.1); +// unparseable Track Properties are MALFORMED_TRACK (an interpretation: the +// draft does not cover them). An upstream REQUEST_ERROR code about the track +// or the publisher's load passes through with its Retry Interval (§10.6.2), +// MALFORMED_TRACK included though §10.6.2 scopes it to FETCH; one about the relay's own hop or its Next Object filter, or an unknown one, +// becomes INTERNAL_ERROR. If the relay ever combines downstream filters +// upstream (§9.4), INVALID_RANGE must pass through too. Any other failure +// reads as DOES_NOT_EXIST. func upstreamRejection(err error) *session.RequestRejectedError { if isTrackPropertiesErr(err) { return &session.RequestRejectedError{Code: session.TrackPropertiesRejectCode(err)} diff --git a/pkg/relay/handler_track_status.go b/pkg/relay/handler_track_status.go index 8c873798..99cf7c16 100644 --- a/pkg/relay/handler_track_status.go +++ b/pkg/relay/handler_track_status.go @@ -40,8 +40,7 @@ func (h *sessionHandler) handleTrackStatus(ctx context.Context, req *session.Req hasProperties := known && len(entry.GetProperties()) > 0 if known && (hasProperties || hasLargest) { reply := &message.TrackStatusOK{} - // §10.2.21: INCLUDE_PROPERTIES=0 empties the Track Properties; it - // does not change whether the track is answered. + // §10.2.21: INCLUDE_PROPERTIES=0 empties the Track Properties only. if hasProperties && includeProperties(msg.Parameters) { reply.TrackProperties = entry.GetProperties() } @@ -51,8 +50,7 @@ func (h *sessionHandler) handleTrackStatus(ctx context.Context, req *session.Req reply.Parameters = append(reply.Parameters, message.LargestObjectParam(largest.Group, largest.Object)) } - // AcceptTrackStatus FINs after the reply (§10.15) and closes the - // session on any follow-up from the requester (§10.9). + // AcceptTrackStatus FINs after the reply (§10.15). if err := req.AcceptTrackStatus(reply); err != nil { h.log.LogAttrs(ctx, slog.LevelDebug, "TRACK_STATUS_OK write failed", slog.String("err", err.Error())) diff --git a/pkg/relay/internal/registry/namespace.go b/pkg/relay/internal/registry/namespace.go index 57848536..8123de1f 100644 --- a/pkg/relay/internal/registry/namespace.go +++ b/pkg/relay/internal/registry/namespace.go @@ -32,35 +32,29 @@ type PublisherEntry struct { // the owner that closes/cancels it on teardown. Stream session.Stream - // announced is set by [NamespaceRegistry.AnnouncePublisher]: from then - // on the entry is a source of its namespace for SUBSCRIBE_NAMESPACE - // subscribers. Guarded by the registry's mu. + // announced is set by [NamespaceRegistry.AnnouncePublisher], making the + // entry a source for SUBSCRIBE_NAMESPACE subscribers. Guarded by the + // registry's mu. announced bool - // Seq orders registrations: each RegisterPublisher assigns the next - // value, so Seq > [NamespaceRegistry.Seq] read earlier means the - // publisher registered since. + // Seq orders registrations: Seq > [NamespaceRegistry.Seq] read earlier + // means the publisher registered since. Seq uint64 } -// TracksParams are a SUBSCRIBE_TRACKS's parameters: "the initial Subscription -// parameters when a PUBLISH is sent as a result of SUBSCRIBE_TRACKS" -// (§10.20.1). A REQUEST_UPDATE replaces the whole value, and it applies to the -// PUBLISHes sent from then on — "Existing subscriptions are unaffected" -// (§10.2.18) — so a value is never modified once stored. +// TracksParams are a SUBSCRIBE_TRACKS's parameters, the initial parameters of +// each PUBLISH it causes (§10.20.1). A REQUEST_UPDATE replaces the whole value +// and affects only later PUBLISHes, so a value is never modified once stored. type TracksParams struct { // Params are the parameters as sent, merged with each update. Params message.Parameters // Forward and GroupOrder are the resolved FORWARD (§10.2.18) and - // GROUP_ORDER (§10.2.8): Forward is true unless FORWARD is 0; GroupOrder - // is 0 when omitted (the publisher's default applies). + // GROUP_ORDER (§10.2.8); GroupOrder is 0 when omitted. Forward bool GroupOrder byte - // RangeFilters are the §5.1.4 Range Filters; a PUBLISH whose Track - // Properties fail the TRACK_PROPERTY_FILTER is not forwarded. nil = no - // restriction. + // RangeFilters are the §5.1.4 Range Filters. nil = no restriction. RangeFilters *message.RangeFilterSet } @@ -73,9 +67,8 @@ var defaultTracksParams = &TracksParams{Forward: true} // matching PUBLISH_NAMESPACE / PUBLISH back to the subscriber as long as the // subscription is alive. type SubscriberEntry struct { - // prefix is the namespace prefix the subscriber asked to be notified - // about; see [SubscriberEntry.Prefix]. Stored only under the registry - // lock, by registration and [NamespaceRegistry.UpdatePrefix]. + // prefix is stored only under the registry lock; see + // [SubscriberEntry.Prefix]. prefix atomic.Pointer[wire.TrackNamespace] // Session is the MOQT session that owns the SUBSCRIBE_NAMESPACE / @@ -83,9 +76,8 @@ type SubscriberEntry struct { Session *session.Session // Stream is the bidi request stream the subscription arrived on. After - // the REQUEST_OK, every message the relay sends on it — NAMESPACE, - // NAMESPACE_DONE, PUBLISH_SKIPPED, replies to REQUEST_UPDATE — is queued - // through the entry and written by [SubscriberEntry.RunWriter], in order. + // the REQUEST_OK, every write to it goes through the entry's queue and + // [SubscriberEntry.RunWriter]. Stream session.Stream // WantsTracks distinguishes SUBSCRIBE_TRACKS (true: forward PUBLISH @@ -95,24 +87,22 @@ type SubscriberEntry struct { // dispatches on this flag. WantsTracks bool - // tracks holds the SUBSCRIBE_TRACKS parameters as last updated; see - // [SubscriberEntry.TracksParams]. Meaningful only when WantsTracks. + // tracks is meaningful only when WantsTracks; see + // [SubscriberEntry.TracksParams]. tracks atomic.Pointer[TracksParams] // ForwardTrack forwards a PUBLISH for a track to a SUBSCRIBE_TRACKS - // subscriber (§6.1, §10.20). It is the subscriber's handler's, set at - // registration: the forwarded subscription belongs to its session. + // subscriber (§10.20), on the subscriber's session. ForwardTrack func(sub *SubscriberEntry, track *TrackEntry) - // forwarding holds the tracks with a forwarded PUBLISH in flight, one - // per track; see [SubscriberEntry.ClaimForward]. + // forwarding holds the tracks with a forwarded PUBLISH in flight; see + // [SubscriberEntry.ClaimForward]. fwdMu sync.Mutex forwarding map[track.Key]struct{} fwdClosed bool // the entry is unregistered: no more forwards - // announced counts the sources of each namespace announced to a - // SUBSCRIBE_NAMESPACE subscriber, by wire key (see namespace_state.go). - // Guarded by the owning registry's mu. + // announced counts the sources of each announced namespace, by wire key + // (see namespace_state.go). Guarded by the owning registry's mu. announced map[string]int // outbox holds the messages queued for [SubscriberEntry.RunWriter], in @@ -122,21 +112,17 @@ type SubscriberEntry struct { outbox []queuedMessage stopped bool outReady chan struct{} - // writing is the message RunWriter is sending now (zero when idle); - // guarded by outMu. Together with outbox it is what is still unsent. + // writing is the message RunWriter is sending now; guarded by outMu. writing queuedMessage - // writerDone is closed when RunWriter returns; see - // [SubscriberEntry.WriterDone]. + // writerDone is closed when RunWriter returns. writerDone chan struct{} closed chan struct{} closeOnce sync.Once } // ClaimForward reserves key while a forwarded PUBLISH for it is being opened -// and registered, and reports whether it was free; [SubscriberEntry.ReleaseForward] -// frees it once the subscription is registered. It reports false once the -// entry is unregistered: §6.1, relays "MUST NOT send any further PUBLISH -// messages to a client without knowing the client is interested". +// and registered, and reports whether it was free. It reports false once the +// entry is unregistered (§6.1: "MUST NOT send any further PUBLISH messages"). func (e *SubscriberEntry) ClaimForward(key track.Key) bool { e.fwdMu.Lock() defer e.fwdMu.Unlock() @@ -157,9 +143,9 @@ func (e *SubscriberEntry) ReleaseForward(key track.Key) { delete(e.forwarding, key) } -// Prefix is the namespace prefix the subscriber asked to be notified about. A -// zero-field prefix means "all namespaces" (§6.1). A TRACK_NAMESPACE_PREFIX -// update (§10.9.2) changes it. +// Prefix is the namespace prefix the subscriber asked to be notified about, +// which a TRACK_NAMESPACE_PREFIX update (§10.9.2) changes. A zero-field prefix +// means "all namespaces" (§6.1). func (e *SubscriberEntry) Prefix() wire.TrackNamespace { return *e.prefix.Load() } // TracksParams returns the SUBSCRIBE_TRACKS parameters now in effect. @@ -300,10 +286,8 @@ func (r *NamespaceRegistry) RegisterPublisher( // AnnouncePublisher makes entry a source of its namespace for // SUBSCRIBE_NAMESPACE subscribers, announcing the namespace to those that had -// no source for it. It is separate from registration, which already makes the -// publisher routable for SUBSCRIBEs: the relay announces only once the -// publisher has its REQUEST_OK, since one that never got it does not believe -// it is publishing. +// no source for it. Call it only once the publisher has its REQUEST_OK; +// registration alone already makes it routable for SUBSCRIBEs. func (r *NamespaceRegistry) AnnouncePublisher(entry *PublisherEntry) { r.mu.Lock() defer r.mu.Unlock() @@ -347,8 +331,7 @@ func (r *NamespaceRegistry) UnregisterPublisher(entry *PublisherEntry) bool { // RegisterSubscriber records a subscriber's SUBSCRIBE_NAMESPACE (when // wantsTracks is false) or SUBSCRIBE_TRACKS (when true). params are the // SUBSCRIBE_TRACKS parameters (§10.20.1), ignored unless wantsTracks; nil -// means none. -// Returns the canonical pointer for use with +// means none. Returns the canonical pointer for use with // [NamespaceRegistry.UnregisterSubscriber]. func (r *NamespaceRegistry) RegisterSubscriber( prefix wire.TrackNamespace, @@ -374,11 +357,10 @@ func (r *NamespaceRegistry) RegisterSubscriber( r.subscribers = append(r.subscribers, entry) if !wantsTracks { // §6.1: announce every namespace already known under the prefix, - // under the same lock that orders later changes to them. + // under the same lock that orders later changes to them. Local + // publishers first, in registration order, then remote-only ones. counts, names := r.namespaceSources(prefix) entry.announced = counts - // Local publishers first, in registration order, then namespaces - // only other relays advertise. for _, p := range r.publishers { k := namespaceWireKey(p.Namespace) if _, pending := names[k]; pending && p.announced { diff --git a/pkg/relay/internal/registry/namespace_state.go b/pkg/relay/internal/registry/namespace_state.go index 062ac915..9e9cdf61 100644 --- a/pkg/relay/internal/registry/namespace_state.go +++ b/pkg/relay/internal/registry/namespace_state.go @@ -11,19 +11,12 @@ import ( // A SUBSCRIBE_NAMESPACE subscriber's view of the namespaces announced to it. // -// §10.18: NAMESPACE_DONE says the publisher stops "serving new subscriptions -// for tracks within the provided Track Namespace", so it is per namespace, not -// per source: two publishers of one namespace announce it once, and it is done -// when the last one leaves. §10.19: "The publisher MUST NOT send NAMESPACE_DONE -// for a namespace suffix before the corresponding NAMESPACE." -// -// Each subscriber therefore counts the sources — local PUBLISH_NAMESPACE -// registrations and remote relays reported by Discovery — of each namespace -// under its prefix. The counts change only under the registry lock, which -// also orders the messages the changes produce: NAMESPACE on a count's 0→1, -// NAMESPACE_DONE on its 1→0. Messages go to the subscriber's outbox, and one -// writer ([SubscriberEntry.RunWriter]) sends them in that order, so no stream -// write happens under the lock. +// §10.18: NAMESPACE_DONE is per namespace, not per source, so each subscriber +// counts the sources (local PUBLISH_NAMESPACEs and Discovery's remote relays) +// of each namespace under its prefix: NAMESPACE on a count's 0→1, +// NAMESPACE_DONE on its 1→0. Counts change only under the registry lock, which +// also orders the messages; [SubscriberEntry.RunWriter] sends them, so no +// stream write happens under the lock. // remoteNamespace is one namespace Discovery reports other relays advertise. type remoteNamespace struct { @@ -88,9 +81,8 @@ func (r *NamespaceRegistry) removeSourceLocked(ns wire.TrackNamespace) { } // RemoteNamespace records that the relay at relayAddr started (published) or -// stopped advertising ns, as Discovery reports it, and announces the change to -// SUBSCRIBE_NAMESPACE subscribers like a local PUBLISH_NAMESPACE would. A -// repeated report changes nothing. +// stopped advertising ns, and announces the change to SUBSCRIBE_NAMESPACE +// subscribers. A repeated report changes nothing. func (r *NamespaceRegistry) RemoteNamespace(ns wire.TrackNamespace, relayAddr string, published bool) { k := namespaceWireKey(ns) r.mu.Lock() @@ -124,14 +116,10 @@ func (r *NamespaceRegistry) RemoteNamespace(ns wire.TrackNamespace, relayAddr st // UpdatePrefix applies a TRACK_NAMESPACE_PREFIX update (§10.9.2) to e and // queues ok, the update's REQUEST_OK, in order with e's other messages. // -// For a SUBSCRIBE_NAMESPACE the announced set is reconciled to the new prefix: -// namespaces it no longer covers are done before ok (their suffixes are -// relative to the old prefix), and namespaces it newly covers are announced -// after ok, relative to the new one — "NAMESPACE and NAMESPACE_DONE messages -// following the REQUEST_OK will contain Track Namespace suffixes relative to -// the updated prefix". A SUBSCRIBE_TRACKS only changes which later PUBLISHes -// match; "Updating the prefix of a SUBSCRIBE_TRACKS has no effect on existing -// subscriptions". +// For a SUBSCRIBE_NAMESPACE, namespaces no longer covered are done before ok +// (suffixes relative to the old prefix) and newly covered ones announced after +// it, relative to the new one. A SUBSCRIBE_TRACKS only changes which later +// PUBLISHes match. func (r *NamespaceRegistry) UpdatePrefix(e *SubscriberEntry, prefix wire.TrackNamespace, ok message.Message) { r.mu.Lock() defer r.mu.Unlock() @@ -142,8 +130,6 @@ func (r *NamespaceRegistry) UpdatePrefix(e *SubscriberEntry, prefix wire.TrackNa return } counts, names := r.namespaceSources(prefix) - // e.announced holds exactly the namespaces with sources under the old - // prefix, so that view names each one to be done. _, oldNames := r.namespaceSources(old) for k := range e.announced { if _, still := counts[k]; !still { @@ -160,49 +146,33 @@ func (r *NamespaceRegistry) UpdatePrefix(e *SubscriberEntry, prefix wire.TrackNa } // Enqueue queues m on e's stream behind every message already queued, for -// replies that must keep their order relative to NAMESPACE / NAMESPACE_DONE -// (a REQUEST_UPDATE's REQUEST_OK, §10.9). +// replies that must keep their order relative to NAMESPACE / NAMESPACE_DONE. func (e *SubscriberEntry) Enqueue(m message.Message) { e.push(m, false) } // Finish queues m as the last message of the request, after which the writer -// FINs the stream: "When a REQUEST_UPDATE fails for a SUBSCRIBE_NAMESPACE, -// SUBSCRIBE_TRACKS or PUBLISH_NAMESPACE, the responder MUST close the bidi -// stream (see Section 3.3.2)". That ends the request: the owner waits for -// [SubscriberEntry.WriterDone] and then unregisters e. +// FINs the stream (§10.9.1, for a failed REQUEST_UPDATE). The owner then waits +// for [SubscriberEntry.WriterDone] and unregisters e. func (e *SubscriberEntry) Finish(m message.Message) { e.push(m, true) } func (e *SubscriberEntry) enqueue(m message.Message) { e.push(m, false) } -// maxQueuedMessages and maxUnsentWait bound a namespace subscription's queue. -// §10.19: "If the publisher is unable to send NAMESPACE or NAMESPACE_DONE -// messages in a timely manner because the SUBSCRIBE_NAMESPACE response stream -// is blocked by flow control, the publisher MAY reset the SUBSCRIBE_NAMESPACE -// response stream." The relay counts a stream as blocked when at least -// maxQueuedMessages are unsent and the oldest of them has waited longer than -// maxUnsentWait — whether the subscriber stopped reading or reads too slowly -// to keep up. -// -// A burst (seeding a subscription, a prefix update, a Discovery resync) that a -// reading subscriber drains within maxUnsentWait does not count; one it needs -// longer for does, on the next message queued. So a subscriber on a slow link -// under a prefix with thousands of namespaces can be reset while it is still -// draining the seed. -// -// The check runs when a message is queued: a stream that is stuck while -// nothing new arrives is left alone, its queue not growing. The same bound -// holds a SUBSCRIBE_TRACKS stream's PUBLISH_SKIPPEDs. +// maxQueuedMessages and maxUnsentWait bound a namespace subscription's queue +// (§10.19: a blocked response stream "MAY" be reset). The relay counts a +// stream as blocked when at least maxQueuedMessages are unsent and the oldest +// has waited longer than maxUnsentWait. A slow subscriber still draining a +// large seed can therefore be reset. The check runs only when a message is +// queued. The same bound holds a SUBSCRIBE_TRACKS stream's PUBLISH_SKIPPEDs. const ( maxQueuedMessages = 1024 maxUnsentWait = time.Second ) -// queuedMessage is a message waiting for RunWriter, with when it was queued -// (a monotonic time, so a wall-clock step does not trip the bound). A nil -// m is the finish marker. +// queuedMessage is a message waiting for RunWriter, with its (monotonic) +// queue time. A nil m is the finish marker. type queuedMessage struct { m message.Message at time.Time @@ -210,9 +180,8 @@ type queuedMessage struct { // push appends m, then the finish marker when last. Nothing is queued once // the request is finishing or its stream failed. A push to a blocked stream -// (see maxQueuedMessages) resets it with EXCESSIVE_LOAD instead — both -// halves, which also unblocks a stuck write and ends the request's reader, so -// the owner unregisters e. +// resets both halves with EXCESSIVE_LOAD instead, which unblocks a stuck write +// and ends the request's reader. func (e *SubscriberEntry) push(m message.Message, last bool) { now := time.Now() e.outMu.Lock() @@ -255,12 +224,10 @@ func (e *SubscriberEntry) blockedLocked(now time.Time) bool { } // RunWriter sends e's queued messages in order until e is unregistered, the -// request finishes, a write fails, or the queue bound resets the stream. Its owner runs it once, for the -// subscription's lifetime. It takes one message at a time, so what it has not -// sent yet stays counted (see maxQueuedMessages). After a failed write it also -// stops reading the stream, so the request's reader returns and the owner -// unregisters e: that is how a peer's STOP_SENDING-only cancel (§3.3.3) ends -// the subscription. +// request finishes, a write fails, or the queue bound resets the stream. Its +// owner runs it once, for the subscription's lifetime. After a failed write it +// also stops reading the stream, so a peer's STOP_SENDING-only cancel (§3.3.3) +// ends the subscription. func (e *SubscriberEntry) RunWriter() { defer close(e.writerDone) for { @@ -275,10 +242,8 @@ func (e *SubscriberEntry) RunWriter() { stopped := e.stopped e.outMu.Unlock() if stopped { - // Only the queue bound's reset leaves the queue empty - // and stopped (Finish queues a marker; a failed write - // returns below): the stream is gone, and the owner - // may be waiting on WriterDone. + // Only the queue bound's reset gets here; the owner may + // be waiting on WriterDone. return } break @@ -307,16 +272,13 @@ func (e *SubscriberEntry) RunWriter() { } } -// WriterDone is closed once RunWriter has returned: after the FIN that -// [SubscriberEntry.Finish] asked for, a failed write, a reset by the queue -// bound (see maxQueuedMessages), or unregistration. +// WriterDone is closed once RunWriter has returned. func (e *SubscriberEntry) WriterDone() <-chan struct{} { return e.writerDone } // PublishSkipped queues a PUBLISH_SKIPPED (§10.21) for the track (ns, name) on // a SUBSCRIBE_TRACKS subscriber, its suffix relative to the prefix in force at -// that point of the stream, so it cannot disagree with a queued prefix -// update's REQUEST_OK. It reports false, queuing nothing, when an update moved -// the prefix off ns. +// that point of the stream. It reports false, queuing nothing, when an update +// moved the prefix off ns. func (r *NamespaceRegistry) PublishSkipped(e *SubscriberEntry, ns wire.TrackNamespace, name []byte) bool { r.mu.Lock() defer r.mu.Unlock() @@ -330,10 +292,9 @@ func (r *NamespaceRegistry) PublishSkipped(e *SubscriberEntry, ns wire.TrackName // ReplaceRemote makes the remote namespaces exactly those in ads, for a // Discovery watch's snapshot (see [discovery.DiscoveryStore.WatchNamespaces]). -// Only differences reach subscribers: a namespace some relay still advertises -// causes nothing, one no longer advertised is done, one newly advertised is -// announced. Sources are added before any is removed, so a namespace whose -// only advertising relay changed is not done and announced again. +// Only differences reach subscribers. Sources are added before any is removed, +// so a namespace whose only advertising relay changed is not done and +// announced again. func (r *NamespaceRegistry) ReplaceRemote(ads []discovery.NamespaceInfo) { want := make(map[string]*remoteNamespace) for _, ad := range ads { @@ -377,8 +338,8 @@ func (r *NamespaceRegistry) ReplaceRemote(ads []discovery.NamespaceInfo) { } } -// namespaceMessage is the NAMESPACE announcing ns to a subscriber of prefix: -// only the fields after the prefix (§10.17). +// namespaceMessage is the NAMESPACE announcing ns to a subscriber of prefix +// (§10.17). func namespaceMessage(ns, prefix wire.TrackNamespace) *message.Namespace { return &message.Namespace{TrackNamespaceSuffix: suffixAfter(ns, prefix)} } diff --git a/pkg/relay/internal/registry/subscription.go b/pkg/relay/internal/registry/subscription.go index b95a313c..edf9ae25 100644 --- a/pkg/relay/internal/registry/subscription.go +++ b/pkg/relay/internal/registry/subscription.go @@ -155,10 +155,8 @@ func (s *Subscription) IsTerminated() bool { return s.State() == SubTerminated } -// SetForwardState updates the §9.2 Forward flag. The relay does not validate -// the value here — §10.2.18's FORWARD is canonically 0 or 1, but allowing -// any int keeps the door open for future extensions (e.g. priority-banded -// forwarding) without an API change. +// SetForwardState updates the §9.2 Forward flag. The value is not validated +// here (§10.2.18's FORWARD is 0 or 1). func (s *Subscription) SetForwardState(v int) { s.mu.Lock() s.forwardState = v @@ -225,8 +223,8 @@ type UpstreamSub struct { done *message.PublishDone } -// SetPublishDone records the PUBLISH_DONE the upstream ended this -// subscription with (§10.12); see [DownstreamDoneCode]. +// SetPublishDone records the PUBLISH_DONE (§10.12) the upstream ended this +// subscription with. func (u *UpstreamSub) SetPublishDone(pd *message.PublishDone) { u.mu.Lock() u.done = pd @@ -241,14 +239,10 @@ func (u *UpstreamSub) publishDone() *message.PublishDone { } // DownstreamDoneCode is the PUBLISH_DONE status code the relay sends its -// subscribers when a track's last upstream ended with upstream (nil: it ended -// without one — reset, or its session went away). §10.12: "The application -// SHOULD use a relevant status code". A code about the track passes through; -// one about the relay's own upstream subscription (it fell behind, its update -// failed, it expired, it lost its authorization, the upstream was overloaded -// or going away) says nothing true about the subscriber's, so it becomes -// INTERNAL_ERROR, as does a code this relay does not know. An upstream gone -// without PUBLISH_DONE ends the track as far as the relay can tell. +// subscribers when a track's last upstream ended with upstream (nil: without +// a PUBLISH_DONE, which counts as TRACK_ENDED). §10.12: "SHOULD use a relevant +// status code". A code about the track passes through; one about the relay's +// own upstream subscription, or an unknown one, becomes INTERNAL_ERROR. func DownstreamDoneCode(upstream *message.PublishDone) moqt.PublishDoneCode { if upstream == nil { return moqt.PublishDoneTrackEnded @@ -299,20 +293,16 @@ func (u *UpstreamSub) WriteMessage(msg message.Message) error { } // CloseOnDemand tears down an on-demand upstream subscription after its -// last downstream left by cancelling the request: pending updates fail fast -// and both directions are reset — §5.1: "The subscriber terminates a -// subscription ... by sending STOP_SENDING". The broker's Serve loop observes -// the reset and exits, and the publisher stops streaming into a void. -// Idempotent; must be called without registry locks held (stream I/O). +// last downstream left by cancelling the request (§5.1: "by sending +// STOP_SENDING"). Idempotent; must be called without registry locks held +// (stream I/O). func (u *UpstreamSub) CloseOnDemand() { u.Cancel(moqt.StreamResetCancelled) } -// Cancel ends the relay's subscription to this upstream — an on-demand -// SUBSCRIBE or an accepted PUBLISH — by resetting both directions of its -// request stream with code (§3.3.3); the broker's Serve loop then exits and -// its owner unregisters the upstream. Idempotent; must be called without -// registry locks held (stream I/O). +// Cancel ends the relay's subscription to this upstream by resetting both +// directions of its request stream with code (§3.3.3). Idempotent; must be +// called without registry locks held (stream I/O). func (u *UpstreamSub) Cancel(code moqt.StreamResetCode) { u.Terminate() if u.Broker == nil { @@ -329,18 +319,12 @@ func (u *UpstreamSub) Cancel(code moqt.StreamResetCode) { // requestID is the §10.1 Request ID of the SUBSCRIBE / PUBLISH that opened // the request stream, recorded for identity and diagnostics. // -// The Forward State starts at 1: per §10.7 a SUBSCRIBE (or accepted PUBLISH) -// that omits the FORWARD parameter implies Forward State 1, and the relay's -// upstream requests never carry FORWARD. Starting at 0 would make the §9.2 -// propagation path emit a spurious REQUEST_UPDATE(Forward=1) on the first -// downstream resume. +// The Forward State starts at 1: an omitted FORWARD means 1 (§10.2.18), and +// the relay's upstream requests never carry it. // -// peerMayUpdate says whether the upstream publisher may send REQUEST_UPDATE on -// the stream: §10.9 allows it only from "The sender of a request", so true for -// an accepted PUBLISH and false for the relay's own SUBSCRIBE. The publisher -// may always send PUBLISH_STATE_NOTIFY (§10.10). A disallowed follow-up closes -// the session with PROTOCOL_VIOLATION. It is a parameter, not a later call, so -// no upstream path can leave the broker permissive by omission. +// peerMayUpdate says whether the upstream publisher may send REQUEST_UPDATE: +// §10.9 allows it only from "The sender of a request", so true for an +// accepted PUBLISH and false for the relay's own SUBSCRIBE. func NewUpstreamSub( id uint64, sess *session.Session, @@ -350,8 +334,7 @@ func NewUpstreamSub( ) *UpstreamSub { broker := sess.NewRequestBroker(stream) broker.PeerMessages(peerMayUpdate, true) - // The only peer that may update here is the publisher of an accepted - // PUBLISH (§10.9), so its REQUEST_UPDATEs carry a publisher's scope. + // Only an accepted PUBLISH's publisher may update here (§10.9). broker.UpdateScope(message.ScopeUpdateFromPublisher) return &UpstreamSub{ state: SubEstablished, @@ -391,19 +374,14 @@ type DownstreamSub struct { // writeMu serializes control-message writes on Stream. // session.Stream does not serialize concurrent writers and one // Marshal is multiple stream Writes, but two goroutines legitimately - // write here: the subscriber's request handler (SUBSCRIBE_OK, - // REQUEST_OK / REQUEST_ERROR replies — via WriteMessage) and registry - // teardown goroutines (PUBLISH_DONE via TerminateWithPublishDone, - // triggered by a *publisher* leaving). + // write here: the subscriber's request handler (via WriteMessage) and + // termination (PUBLISH_DONE via TerminateWithPublishDone). writeMu sync.Mutex - // okSent records that the §10.8 SUBSCRIBE_OK response went out on the - // stream, or that the relay's own PUBLISH opened it (OpenedByPublish); - // guarded by writeMu. A termination racing the subscribe - // handler consults it to answer the request correctly: the peer must - // receive exactly one SUBSCRIBE_OK / REQUEST_ERROR before any - // PUBLISH_DONE — a PUBLISH_DONE with no prior response leaves the - // request permanently unanswered on the subscriber side. + // okSent records that the §10.8 SUBSCRIBE_OK went out, or that the + // relay's own PUBLISH opened the stream (OpenedByPublish); guarded by + // writeMu. A termination consults it: without a prior response it + // answers with REQUEST_ERROR instead of PUBLISH_DONE. okSent bool // Filter is the §5.1.2 filter the subscriber declared. The fanout @@ -425,12 +403,9 @@ type DownstreamSub struct { deliveryTimeouts message.DeliveryTimeouts // LargestAtSubscribe is the largest object the relay had observed on - // this track at the moment the SUBSCRIBE was accepted, per §5.1.2 / - // §9.4, the relay acting as the publisher for its downstream subscribers. - // The Next Object and relative-start filters resolve their start - // location against this snapshot — not against the live, ever-advancing - // TrackEntry watermark — so the subscription's start is fixed at - // subscribe time and doesn't drift as new objects arrive. + // this track when the SUBSCRIBE was accepted (§5.1.2). Filters resolve + // their start against it, not the live watermark, so the start does not + // drift as objects arrive. LargestAtSubscribe message.Location // HasLargestAtSubscribe is false when no objects had been delivered @@ -457,17 +432,15 @@ type DownstreamSub struct { // [DownstreamSub.IncludesProperties]. omitProperties atomic.Bool - // streamsOpened counts the data streams opened for this subscription — - // subgroup streams and fill fetch streams — for the §10.12 PUBLISH_DONE - // Stream Count; streamsOpening counts opens in flight, and streamsOpen the - // opened streams not yet closed. pendingDone is a PUBLISH_DONE waiting for - // them. Guarded by mu, the same lock as the lifecycle state, so no stream - // is counted after termination. See [DownstreamSub.BeginStream]. + // streamsOpened counts the data streams opened for this subscription, + // for the §10.12 Stream Count; streamsOpening counts opens in flight and + // streamsOpen the opened streams not yet closed. pendingDone is a + // PUBLISH_DONE waiting for them. Guarded by mu, the same lock as the + // lifecycle state, so no stream is counted after termination. streamsOpened uint64 streamsOpening int streamsOpen int - // datagramsSending counts datagram sends in flight; see - // [DownstreamSub.BeginDatagram]. + // datagramsSending counts datagram sends in flight. datagramsSending int pendingDone *pendingPublishDone } @@ -479,10 +452,9 @@ type pendingPublishDone struct { reason string } -// BeginStream reserves the open of one data stream for this subscription so -// PUBLISH_DONE can report the §10.12 Stream Count. It returns false once the -// subscription is terminated: the caller must not open the stream. Each true -// must be paired with one [DownstreamSub.EndStream]. +// BeginStream reserves the open of one data stream for this subscription. It +// returns false once the subscription is terminated: the caller must not open +// the stream. Each true must be paired with one [DownstreamSub.EndStream]. func (d *DownstreamSub) BeginStream() bool { d.mu.Lock() defer d.mu.Unlock() @@ -509,8 +481,7 @@ func (d *DownstreamSub) EndStream(opened bool) { } // StreamClosed reports that one of the subscription's opened data streams has -// been closed (FIN) or reset. A PUBLISH_DONE waiting for it goes out once it -// is the last (§10.12). +// been closed (FIN) or reset. func (d *DownstreamSub) StreamClosed() { d.mu.Lock() d.streamsOpen-- @@ -519,10 +490,10 @@ func (d *DownstreamSub) StreamClosed() { d.sendPublishDone(done, count) } -// BeginDatagram reserves one datagram send for this subscription: §10.12's -// PUBLISH_DONE may go out only once the sender "has no further datagrams to -// send". It returns false once the subscription is terminated, and the caller -// must not send. Each true must be paired with one [DownstreamSub.EndDatagram]. +// BeginDatagram reserves one datagram send for this subscription (§10.12: +// PUBLISH_DONE waits until the sender "has no further datagrams to send"). It +// returns false once the subscription is terminated, and the caller must not +// send. Each true must be paired with one [DownstreamSub.EndDatagram]. func (d *DownstreamSub) BeginDatagram() bool { d.mu.Lock() defer d.mu.Unlock() @@ -542,10 +513,9 @@ func (d *DownstreamSub) EndDatagram() { d.sendPublishDone(done, count) } -// takeReadyDoneLocked returns the pending PUBLISH_DONE and its Stream Count -// once no stream of the subscription is open or opening and no datagram is -// being sent, clearing it; nil otherwise. With no open in flight the count is -// exact. The caller holds mu. +// takeReadyDoneLocked returns and clears the pending PUBLISH_DONE and its +// Stream Count once no stream is open or opening and no datagram is being +// sent; nil otherwise. The caller holds mu. func (d *DownstreamSub) takeReadyDoneLocked() (*pendingPublishDone, uint64) { if d.pendingDone == nil || d.streamsOpen > 0 || d.streamsOpening > 0 || d.datagramsSending > 0 { return nil, 0 @@ -642,13 +612,11 @@ func (d *DownstreamSub) SetIncludeProperties(include bool) { d.omitProperties.St // IncludesProperties reports whether the subscriber wants Track Properties // (INCLUDE_PROPERTIES omitted or 1). One that does not also lacks the track's -// DEFAULT_PUBLISHER_PRIORITY (§12.4), so its subgroups and datagrams carry the -// priority inline. +// DEFAULT_PUBLISHER_PRIORITY (§12.4), so objects carry the priority inline. func (d *DownstreamSub) IncludesProperties() bool { return !d.omitProperties.Load() } -// SetGroupOrder records the Group Order (§10.2.8), set once from the SUBSCRIBE: -// "The group order of an existing subscription cannot be changed" (§7.1), and -// GROUP_ORDER is not in a REQUEST_UPDATE's scope. +// SetGroupOrder records the Group Order (§10.2.8), set once from the SUBSCRIBE +// (§7.1: it "cannot be changed"). func (d *DownstreamSub) SetGroupOrder(o uint8) { d.mu.Lock() d.GroupOrder = o @@ -715,50 +683,34 @@ func (d *DownstreamSub) EffectiveStreamPriority( } // ForwardVerdict is [DownstreamSub.ForwardDecision]'s answer for one Object. -// §11.4.3 lets a subgroup stream end with a FIN only when it carried every -// Object of the Subgroup "except any Objects with Locations smaller than the -// subscription's Start Location"; every other skip leaves the Subgroup -// incomplete for this subscription, so its stream must end with a reset. +// §11.4.3 allows a FIN only when the stream carried every Object of the +// Subgroup "except any Objects with Locations smaller than the subscription's +// Start Location"; every other skip means the stream must end with a reset. type ForwardVerdict uint8 const ( // Forward: enqueue the Object. Forward ForwardVerdict = iota // SkipBeforeStart: the Object lies before the subscription's Start - // Location — the one omission §11.4.3 still allows a FIN after, unless - // the Start was raised past Objects already sent (the caller can tell). + // Location, which still allows a FIN unless the Start was raised past + // Objects already sent (the caller can tell). SkipBeforeStart - // SkipObject: a filter drops this Object only (a Range Filter, or the - // Location filter past its End); a later one in the group may still - // pass. The Subgroup is incomplete for this subscription. + // SkipObject: a filter drops this Object only; a later one in the group + // may still pass. SkipObject - // SkipPaused: Forward State 0 omits the Object (§5.1: the publisher does - // not send Objects while it is 0; §5.1.5 treats Forward as a filter). - // The Subgroup is incomplete for this subscription (§11.4.3: "Omitting a - // Subgroup Object due to the subscriber's Forward State"). + // SkipPaused: Forward State 0 omits the Object (§5.1.5). SkipPaused // SkipGroup: the Location filter puts this whole group permanently out of - // range — it lies before an absolute Start or past the End (§11.4.3); the - // stream can be reset promptly. + // range (§11.4.3); the stream can be reset promptly. SkipGroup - // SkipEnded: the subscription is terminated and takes no new Object - // (§10.12). + // SkipEnded: the subscription is terminated (§10.12). SkipEnded ) // ForwardDecision decides whether an Object goes to this subscription, under -// one lock acquisition — the fanout asks it for every Object and every -// subscriber. It ANDs the Forward State, the §5.1.2 Location filter, and the -// §5.1.4 Range Filters (subgroupID/object/priority/objProps) — §5.1.5 "Pass = -// Forward AND Location AND Range" — after the lifecycle state. A Range-filter -// miss drops only the Object, so it is SkipObject; only the Location filter -// can make it SkipGroup or SkipBeforeStart. -// -// The Location filter is evaluated against the subscribe-time LargestObject -// snapshot, *not* the live TrackEntry watermark. Re-evaluating against the -// live watermark would let a subscription's effective start location drift -// forward as objects arrive, silently dropping the very objects the -// subscriber asked to receive. +// one lock acquisition (it runs per Object per subscriber). §5.1.5: "Pass = +// Forward AND Location AND Range". The Location filter uses the subscribe-time +// LargestObject snapshot, not the live watermark. func (d *DownstreamSub) ForwardDecision( group, object, subgroupID uint64, priority uint8, objProps []byte, ) ForwardVerdict { @@ -777,9 +729,7 @@ func (d *DownstreamSub) ForwardDecision( case paused: return SkipPaused } - // Location filter first, so its group-exhaustion signal (§11.4.3) governs: - // a whole group out of range (below a raised Start as much as past a - // narrowed End) resets the stream promptly. + // Location filter first, so its group-exhaustion signal (§11.4.3) governs. loc := message.Location{Group: group, Object: object} if f != nil && !f.Matches(loc, largest, has) { switch { @@ -824,36 +774,18 @@ func GroupOutOfRange(group uint64, f *message.LocationFilter) bool { return ok && group > end.Group } -// TerminateWithPublishDone gracefully ends this downstream subscription -// per §10.12: the relay writes a PUBLISH_DONE message on the -// subscriber's request stream and FINs the send side. That ends the -// handler's wait (the stream's send Context), whether or not the subscriber -// has FINned its own side, and its defer evicts the [DownstreamSub] from the -// [TrackRegistry]. -// -// If the SUBSCRIBE_OK never went out — the sub is registered (and thus -// reachable by teardown) before the handler replies, so a terminator can -// win that race — a PUBLISH_DONE would leave the SUBSCRIBE without the -// single SUBSCRIBE_OK / REQUEST_ERROR response §10.7 requires. In that -// case the termination answers the request with REQUEST_ERROR -// (DOES_NOT_EXIST: the track's source vanished before the subscription -// was established) instead, and [DownstreamSub.WriteSubscribeOK] refuses -// to send the stale OK afterwards. -// -// The Terminate latch prevents double-termination: the first caller -// flips the state; subsequent calls do nothing. Safe to call concurrently -// from any goroutine, and it does no I/O itself: the answer is written on -// its own goroutine (see [DownstreamSub.sendPublishDone]). +// TerminateWithPublishDone ends this downstream subscription (§10.12): the +// relay writes PUBLISH_DONE on the subscriber's request stream and FINs the +// send side. If SUBSCRIBE_OK never went out, it answers with REQUEST_ERROR +// (DOES_NOT_EXIST) instead, and [DownstreamSub.WriteSubscribeOK] then refuses +// the stale OK. // -// "A sender MUST NOT send PUBLISH_DONE until it has closed all streams it -// will ever open" (§10.12): the latch stops new streams, and the answer -// waits until every stream already opened or opening has closed, reported -// through [DownstreamSub.StreamClosed]. Its Stream Count is then exact: the -// number of data streams opened for this subscription, as tracked by -// [DownstreamSub.BeginStream]. +// First termination wins; later calls do nothing. Safe to call concurrently, +// and it does no I/O itself (see [DownstreamSub.sendPublishDone]). // -// Used by [TrackRegistry] when the last upstream feeding a track -// disappears, so dependent subscribers stop waiting silently. +// §10.12: "MUST NOT send PUBLISH_DONE until it has closed all streams". The +// answer waits for every stream opened or opening to be reported through +// [DownstreamSub.StreamClosed], so its Stream Count is exact. func (d *DownstreamSub) TerminateWithPublishDone(code moqt.PublishDoneCode, reason string) { d.mu.Lock() if d.state == SubTerminated { @@ -869,9 +801,7 @@ func (d *DownstreamSub) TerminateWithPublishDone(code moqt.PublishDoneCode, reas // sendPublishDone answers the terminated request on its own goroutine, so a // subscriber that does not read its request stream delays only its own -// answer, not the callers terminating many subscriptions in a row. Before -// SUBSCRIBE_OK the answer is REQUEST_ERROR (DOES_NOT_EXIST: the track's source -// vanished first) instead of PUBLISH_DONE. A nil done is a no-op. +// answer. A nil done is a no-op. func (d *DownstreamSub) sendPublishDone(done *pendingPublishDone, streamCount uint64) { if done == nil || d.Stream == nil { return @@ -884,11 +814,8 @@ func (d *DownstreamSub) sendPublishDone(done *pendingPublishDone, streamCount ui ErrorCode: moqt.RequestDoesNotExist, ErrorReason: done.reason, }) - // Mirror [session.Request.RejectError]: the losing subscribe - // handler returns without ever entering its follow-up read - // loop, so cancel the read side too — otherwise bytes the peer - // sends before seeing the rejection queue in the transport - // forever. + // Mirror [session.Request.RejectError]: nothing reads this + // stream any more, so cancel the read side too. d.Stream.CancelRead(uint64(moqt.StreamResetInternalError)) } else { _ = message.Marshal(d.Stream, &message.PublishDone{ @@ -905,8 +832,7 @@ func (d *DownstreamSub) sendPublishDone(done *pendingPublishDone, streamCount ui // lock and records that the request now has its response, so a later // termination emits PUBLISH_DONE (§10.12) rather than a second response. // If a termination won the race first, it returns -// [ErrSubscriptionTerminated] without writing — the termination answers the -// request with REQUEST_ERROR instead. +// [ErrSubscriptionTerminated] without writing. func (d *DownstreamSub) WriteSubscribeOK(msg *message.SubscribeOK) error { d.writeMu.Lock() defer d.writeMu.Unlock() @@ -921,9 +847,8 @@ func (d *DownstreamSub) WriteSubscribeOK(msg *message.SubscribeOK) error { } // OpenedByPublish records that the relay's own PUBLISH opened this -// subscription (a PUBLISH forwarded to a SUBSCRIBE_TRACKS holder, §6.1), so, -// like one answered with SUBSCRIBE_OK, it ends with PUBLISH_DONE (§10.12) -// rather than REQUEST_ERROR. Call it before registering the subscription. +// subscription, so it ends with PUBLISH_DONE (§10.12) rather than +// REQUEST_ERROR. Call it before registering the subscription. func (d *DownstreamSub) OpenedByPublish() { d.writeMu.Lock() d.okSent = true @@ -931,11 +856,8 @@ func (d *DownstreamSub) OpenedByPublish() { } // EndRefused ends a subscription its subscriber refused (REQUEST_ERROR to the -// relay's PUBLISH, §10.11): it is terminated without a PUBLISH_DONE, and the -// stream is closed in both directions, under the same lock as every other -// write on it. A termination already waiting on the subscription's streams -// (see [DownstreamSub.TerminateWithPublishDone]) is cancelled: the refusal -// ended the request first as far as the subscriber is concerned. +// relay's PUBLISH, §10.11): it is terminated without a PUBLISH_DONE, even one +// already pending, and the stream is closed in both directions under writeMu. func (d *DownstreamSub) EndRefused() { d.mu.Lock() ended := d.state != SubTerminated || d.pendingDone != nil @@ -958,8 +880,8 @@ func (d *DownstreamSub) EndRefused() { // even the SUBSCRIBE_OK reply can otherwise interleave with a PUBLISH_DONE. // // A write after termination fails with ErrSubscriptionTerminated: the -// termination's PUBLISH_DONE + FIN is the last thing on this stream, whether -// it has gone out yet or is waiting on the subscription's data streams. +// termination's PUBLISH_DONE + FIN is the last thing on this stream, even +// while it waits on the subscription's data streams. func (d *DownstreamSub) WriteMessage(msg message.Message) error { d.writeMu.Lock() defer d.writeMu.Unlock() @@ -970,6 +892,5 @@ func (d *DownstreamSub) WriteMessage(msg message.Message) error { } // ErrSubscriptionTerminated is returned by [DownstreamSub.WriteMessage] when -// the subscription has been terminated; its PUBLISH_DONE has gone out or will -// once its streams close. +// the subscription has been terminated. var ErrSubscriptionTerminated = errors.New("registry: subscription terminated") diff --git a/pkg/relay/internal/registry/track_entry.go b/pkg/relay/internal/registry/track_entry.go index c9ebecd5..9563827b 100644 --- a/pkg/relay/internal/registry/track_entry.go +++ b/pkg/relay/internal/registry/track_entry.go @@ -205,9 +205,8 @@ func (e *TrackEntry) AcquireSubgroup(key SubgroupKey, newSet func() any) (sg *Sh return sg, true } -// CopySubgroups returns the Subgroups currently being fanned out, for a -// caller that must act on every open writer at once (see the relay's -// malformed-track handling). The caller takes each one's Mu itself. +// CopySubgroups returns the Subgroups currently being fanned out. The caller +// takes each one's Mu itself. func (e *TrackEntry) CopySubgroups() []*SharedSubgroup { e.sgMu.Lock() defer e.sgMu.Unlock() @@ -463,8 +462,7 @@ func (e *TrackEntry) HasUpstreamOn(sess *session.Session) bool { // NoteRefusal records that pub refused a late-publisher SUBSCRIBE for this // track and may not be asked again before retryAt; a zero retryAt means not -// while this entry and that PUBLISH_NAMESPACE registration both last. A new -// registration is a new *PublisherEntry and starts clean. +// while this entry and that registration both last. func (e *TrackEntry) NoteRefusal(pub *PublisherEntry, retryAt time.Time) { e.mu.Lock() defer e.mu.Unlock() @@ -484,7 +482,6 @@ func (e *TrackEntry) Refused(pub *PublisherEntry, now time.Time) bool { // RetainRefusals forgets refusals that no longer stand at now or whose // publisher is not in current, the registrations still covering the track. -// The write lock is taken only when there is one to forget. func (e *TrackEntry) RetainRefusals(current []*PublisherEntry, now time.Time) { stale := func(p *PublisherEntry, retryAt time.Time) bool { return (!retryAt.IsZero() && !now.Before(retryAt)) || !slices.Contains(current, p) diff --git a/pkg/relay/internal/registry/track_registry.go b/pkg/relay/internal/registry/track_registry.go index 0036a50b..827074a3 100644 --- a/pkg/relay/internal/registry/track_registry.go +++ b/pkg/relay/internal/registry/track_registry.go @@ -181,8 +181,7 @@ func (r *TrackRegistry) Get(key track.Key) (*TrackEntry, bool) { } // MatchNamespace returns every entry whose Track Namespace has prefix as a -// prefix — §9.5 Namespace Prefix Matching from the publisher's side: the -// tracks a PUBLISH_NAMESPACE for prefix covers. +// prefix: the tracks a PUBLISH_NAMESPACE for prefix covers (§9.5). func (r *TrackRegistry) MatchNamespace(prefix wire.TrackNamespace) []*TrackEntry { r.mu.RLock() defer r.mu.RUnlock() @@ -196,10 +195,9 @@ func (r *TrackRegistry) MatchNamespace(prefix wire.TrackNamespace) []*TrackEntry } // ClaimUpstream marks an upstream SUBSCRIBE for key on sess as in flight, so a -// relay-initiated SUBSCRIBE for an existing track (§9.5 late publisher) can -// tell it would duplicate one. On success the caller opens and registers the -// upstream, then calls release. ok is false when sess already has a registered -// upstream for key or another claim is in flight. +// §9.5 late-publisher SUBSCRIBE does not duplicate one. On success the caller +// opens and registers the upstream, then calls release. ok is false when sess +// already has a registered upstream for key or another claim is in flight. func (r *TrackRegistry) ClaimUpstream(sess *session.Session, key track.Key) (release func(), ok bool) { c := upstreamClaim{sess: sess, key: key} r.mu.Lock() @@ -222,12 +220,10 @@ func (r *TrackRegistry) ClaimUpstream(sess *session.Session, key track.Key) (rel } // ReleaseIfUnsubscribed removes the on-demand upstream up from the entry for -// fullName and tears it down if the entry has no downstream left — what -// [TrackRegistry.RemoveDownstream] does when the last downstream leaves. It -// is for an upstream opened for an existing track: its last downstream can -// leave during the SUBSCRIBE round trip, before up is registered and so -// before RemoveDownstream could strip it, and nothing would ever release it. -// Reports whether up was released. +// fullName and tears it down if the entry has no downstream left. It covers +// an upstream whose last downstream left during the SUBSCRIBE round trip, +// before [TrackRegistry.RemoveDownstream] could strip it. Reports whether up +// was released. func (r *TrackRegistry) ReleaseIfUnsubscribed(fullName track.FullTrackName, up *UpstreamSub) bool { key := fullName.Key() r.mu.Lock() @@ -585,8 +581,7 @@ func (r *TrackRegistry) RemoveUpstream( r.mu.Unlock() if upstreamEmpty { - // §10.12: carry the last upstream's reason where it is about the - // track (see DownstreamDoneCode). + // §10.12: carry the last upstream's reason; see DownstreamDoneCode. code := DownstreamDoneCode(gone.publishDone()) for _, sub := range notifyDownstreams { sub.TerminateWithPublishDone(code, "relay: upstream gone") diff --git a/pkg/relay/relay.go b/pkg/relay/relay.go index c0aa996b..a2d4b2d8 100644 --- a/pkg/relay/relay.go +++ b/pkg/relay/relay.go @@ -75,14 +75,11 @@ type Config struct { SessionOptions []session.Option // KnownMandatoryTrackProperties lists the Mandatory Track Property types - // (0x4000–0x7FFF) the relay may forward. §2.5.1: an endpoint that does - // not understand one "MUST NOT process or forward that track", so a - // PUBLISH or upstream SUBSCRIBE_OK carrying any other type is refused - // with UNSUPPORTED_EXTENSION. Empty (the default) forwards no track that - // carries a Mandatory Track Property; tracks without one are unaffected. - // Set it here, not with session.WithKnownMandatoryTrackProperties in - // SessionOptions: an entry there overrides this field, and a nil map - // there turns the check off. + // (0x4000–0x7FFF) the relay may forward; a track carrying any other is + // refused with UNSUPPORTED_EXTENSION (§2.5.1). Empty (the default) + // refuses every Mandatory Track Property. Set it here rather than with + // session.WithKnownMandatoryTrackProperties in SessionOptions, which + // overrides this field (and turns the check off with a nil map). KnownMandatoryTrackProperties []message.PropertyType // Logger is used for relay-level events (accept loop start/stop, @@ -348,8 +345,7 @@ func New(listener Listener, cfg Config) *Relay { // Prepended, so it is the SETUP budget unless the caller states one — and // stated twice it is advertised twice, which is why [Config.MaxFilterRanges] // is the way to change it rather than another WithMaxFilterRanges here. - // Always non-nil, so every session enforces §2.5.1 — an empty set refuses - // every Mandatory Track Property. + // Always non-nil, so every session enforces §2.5.1. knownMandatory := make(map[message.PropertyType]struct{}, len(cfg.KnownMandatoryTrackProperties)) for _, t := range cfg.KnownMandatoryTrackProperties { knownMandatory[t] = struct{}{} diff --git a/pkg/relay/relay_namespace_watch.go b/pkg/relay/relay_namespace_watch.go index 44da7fa7..8cd860ea 100644 --- a/pkg/relay/relay_namespace_watch.go +++ b/pkg/relay/relay_namespace_watch.go @@ -10,28 +10,14 @@ import ( ) // runNamespaceWatch consumes [discovery.DiscoveryStore.WatchNamespaces] and -// forwards namespaces advertised by *other* relays to this relay's local -// SUBSCRIBE_NAMESPACE holders. It is the consume-side mirror of the advertise -// side in [registry.NamespaceRegistry]: that publishes local PUBLISH_NAMESPACE into the -// store; this reflects remote advertisements back out as NAMESPACE / -// NAMESPACE_DONE so a downstream subscriber discovers namespaces served -// elsewhere in the deployment — and can then SUBSCRIBE, which the on-demand -// cross-relay path resolves via FindNamespace. +// records namespaces advertised by *other* relays in the namespace registry, +// which announces them to local SUBSCRIBE_NAMESPACE holders. It runs as one +// goroutine started in [Relay.Start] when Discovery is configured, until ctx +// is cancelled or the store is closed. // -// It runs as a single relay-level goroutine started in [Relay.Start] (only when -// Discovery is configured) and returns when ctx is cancelled or the store -// closes its watch channel. -// -// The watch yields an initial snapshot before following live changes (see -// [discovery.DiscoveryStore.WatchNamespaces]), so this goroutine observes -// namespaces advertised before it started, not just later ones. Each event is -// recorded in the namespace registry, which also seeds a SUBSCRIBE_NAMESPACE -// holder that registers later. A watch that fails to start is retried, and one -// whose channel closes is restarted — which is how a store tells a consumer it -// fell behind, rather than dropping an event. Each watch's snapshot, up to its -// OpSnapshotDone, is reconciled against what the relay already knew -// ([registry.NamespaceRegistry.ReplaceRemote]), so a restart changes only the -// namespaces that did change. +// A watch that fails to start is retried with backoff; one whose channel +// closes (the store's signal that this consumer fell behind) is restarted, +// and its snapshot reconciled via [registry.NamespaceRegistry.ReplaceRemote]. func (r *Relay) runNamespaceWatch(ctx context.Context) { backoff := namespaceWatchBackoffInitial for first := true; ; first = false { @@ -62,9 +48,8 @@ func (r *Relay) runNamespaceWatch(ctx context.Context) { } // consumeNamespaceWatch applies events from ch until it closes, reporting -// true, or ctx is cancelled, reporting false. The snapshot is collected and -// applied as a whole at its OpSnapshotDone; a watch that ends before then -// applies nothing, and the next one's snapshot takes over. +// true, or ctx is cancelled, reporting false. The snapshot is applied as a +// whole at OpSnapshotDone, so a watch that ends before then applies nothing. func (r *Relay) consumeNamespaceWatch(ctx context.Context, ch <-chan discovery.NamespaceEvent) bool { var ( snapshot []discovery.NamespaceInfo @@ -85,8 +70,7 @@ func (r *Relay) consumeNamespaceWatch(ctx context.Context, ch <-chan discovery.N r.names.ReplaceRemote(snapshot) snapshot, synced = nil, true case ev.Op == discovery.OpPublish && ev.Info.RelayAddr != r.cfg.RelayAddr: - // Own-relay advertisements are counted locally; see - // forwardNamespaceEvent. + // Own-relay advertisements are counted locally. snapshot = append(snapshot, ev.Info) } } @@ -100,12 +84,8 @@ const ( ) // forwardNamespaceEvent records one remote namespace event in the namespace -// registry, which announces it to local SUBSCRIBE_NAMESPACE holders whose -// prefix matches, counted together with local publishers of the same -// namespace (§10.18: NAMESPACE_DONE is per namespace). -// -// Own-relay events are skipped: the registry already counts this relay's local -// PUBLISH_NAMESPACE registrations, which are what it advertised. +// registry, counted together with local publishers of the same namespace +// (§10.18: NAMESPACE_DONE is per namespace). Own-relay events are skipped. func (r *Relay) forwardNamespaceEvent(_ context.Context, ev discovery.NamespaceEvent) { if ev.Info.RelayAddr == r.cfg.RelayAddr { return // our own advertisement — already counted locally @@ -116,6 +96,6 @@ func (r *Relay) forwardNamespaceEvent(_ context.Context, ev discovery.NamespaceE case discovery.OpUnpublish: r.names.RemoteNamespace(ev.Info.Prefix, ev.Info.RelayAddr, false) case discovery.OpSnapshotDone: - // Only the snapshot's end carries it; consumeNamespaceWatch handles it. + // Handled by consumeNamespaceWatch. } } diff --git a/pkg/relay/relay_upstream_pool.go b/pkg/relay/relay_upstream_pool.go index bd3b94bd..f247f717 100644 --- a/pkg/relay/relay_upstream_pool.go +++ b/pkg/relay/relay_upstream_pool.go @@ -179,9 +179,8 @@ func (p *upstreamPool) resolveUpstreams(ctx context.Context, ns wire.TrackNamesp continue // fall through to the next-ranked relay } if peerSentGoaway(sess) { - // A relay that sent GOAWAY takes no new requests (§10.4), so it - // must not hold a fan-in slot: fall through to the next-ranked - // one while it drains. + // §10.4: a draining relay takes no new requests, so it must not + // hold a fan-in slot. continue } out = append(out, sess) diff --git a/pkg/relay/session_handler.go b/pkg/relay/session_handler.go index a30b4577..2e802eaa 100644 --- a/pkg/relay/session_handler.go +++ b/pkg/relay/session_handler.go @@ -125,54 +125,27 @@ func (h *sessionHandler) trackRef(name track.FullTrackName) TrackRef { } // saveLargestLocation folds a LARGEST_OBJECT parameter the upstream sent into -// the track's watermark. +// the track's watermark. §10.2.17: a relay advertises the largest of the +// values received in SUBSCRIBE_OK, PUBLISH or REQUEST_UPDATE_OK and the +// Objects it received. // -// §10.2.17 is the operative rule and it is addressed to relays specifically: a -// relay MUST set LARGEST_OBJECT to the largest of (1) any value received from -// the upstream publisher in SUBSCRIBE_OK, PUBLISH or REQUEST_UPDATE_OK, and -// (2) the largest Location of an Object received on an upstream subscription. -// §9.4 makes that binding here ("Relays MUST follow the constraints on -// LARGEST_OBJECT defined in Section 10.2.17"). Only (2) was implemented, so the -// relay advertised a watermark built purely from objects it had watched arrive. -// -// Call this on every path carrying the parameter, unconditionally. -// [registry.TrackEntry.UpdateLargest] keeps the maximum, which is exactly what -// §10.2.17 asks for, so a value already overtaken changes nothing. -// -// Do NOT narrow this to the Forward-State transition. Draft-19's §5.1 had a -// publisher save only the Largest Location from a message that changed the -// Forward State from 0 to 1; draft-20 dropped that requirement (#1872), and -// §10.2.17's relay rule never had the condition. It matters here: -// subscribeUpstreamOnSession sends FORWARD=0 whenever no downstream wants -// forwarding, so gating on the transition would reintroduce the bug below. -// -// What that bug was: a relay is the publisher for its own downstream -// subscribers (§9.4), so a freshly established cross-relay subscription -// reported no Largest Object until the first object happened to flow. For a -// track published *once* that never happens — the live subscription carries -// only future objects, and no §5.1.3 fill fetch stream opens to backfill the -// rest, since the fill range never extends beyond Largest Object. An MSF -// catalog is exactly that shape, so across two relays the participant its -// catalog described stayed invisible for the whole call. +// Call it on every path carrying the parameter, unconditionally, and not only +// on a Forward State 0→1 transition: subscribeUpstreamOnSession sends +// FORWARD=0 when no downstream forwards, and a track published once would then +// report no Largest Object, so no fill fetch stream could backfill it. func saveLargestLocation(entry *registry.TrackEntry, ps message.Parameters) { if p, ok := ps.Find(message.ParamLargestObject); ok { entry.UpdateLargest(message.Location{Group: p.Group, Object: p.Object}) } } -// logInboundGoaway records the peer's GOAWAY (§10.4). The relay does not -// close the session for it: the Timeout is "The time in milliseconds the -// sender will wait for graceful closure", after which the sender "closes the -// session with GOAWAY_TIMEOUT [...] if there are still open requests"; a -// Timeout of 0 sets no deadline at all. What the relay owes the peer is to -// stop initiating requests to it (see [peerSentGoaway]). +// logInboundGoaway records the peer's GOAWAY (§10.4). The relay does not close +// the session: enforcing the Timeout is the sender's job. It only stops +// initiating requests to the peer (see [peerSentGoaway]). // -// As the subscriber on such a session the relay does not do the rest of what -// §9.4.1 and §3.6 describe: it neither moves its subscriptions to the peer's -// NewSessionURI nor closes the session itself once none remain. Dependent -// DownstreamSubs see their tracks end when the session does, and clients -// re-subscribe, which may re-establish the track via the on-demand upstream -// subscribe path. +// Deviation: the relay neither migrates its subscriptions to NewSessionURI nor +// closes the session once none remain (§9.4.1, §3.6); downstream clients +// re-subscribe when the session ends. func (h *sessionHandler) logInboundGoaway(ctx context.Context) { g := h.sess.PeerGoaway() //nolint:gosec // G115: g.Timeout is a peer-supplied ms value; an out-of-range value yields a wrong duration, not a memory-safety issue. @@ -182,13 +155,9 @@ func (h *sessionHandler) logInboundGoaway(ctx context.Context) { slog.String("new_session_uri", string(g.NewSessionURI))) } -// peerSentGoaway reports whether sess's peer has sent GOAWAY on the control -// stream. §10.4: "Upon receiving a GOAWAY on the control stream, an endpoint -// SHOULD NOT initiate new requests to the peer including SUBSCRIBE, PUBLISH, -// FETCH, PUBLISH_NAMESPACE, SUBSCRIBE_NAMESPACE, SUBSCRIBE_TRACKS and -// TRACK_STATUS." The relay initiates SUBSCRIBE (on demand and to late -// publishers), FETCH (stitching) and PUBLISH (to SUBSCRIBE_TRACKS holders); -// each checks this first. +// peerSentGoaway reports whether sess's peer has sent GOAWAY. §10.4: an +// endpoint "SHOULD NOT initiate new requests to the peer"; every relay-initiated +// SUBSCRIBE, FETCH and PUBLISH checks this first. func peerSentGoaway(sess *session.Session) bool { return sess.PeerGoaway() != nil } // subIDCounter allocates process-globally unique subscription IDs. It MUST @@ -210,8 +179,7 @@ func (h *sessionHandler) allocSubID() uint64 { // not close the session itself except on a protocol violation detected by a loop. func (h *sessionHandler) run(ctx context.Context) error { // Watcher ties runCtx to the parent ctx and the session's Done channel so - // loops unblock as soon as the session terminates. An inbound GOAWAY is - // only logged (see logInboundGoaway); the session runs on until it ends. + // loops unblock as soon as the session terminates. runCtx, cancel := context.WithCancel(ctx) defer cancel() go func() { @@ -282,11 +250,9 @@ func (h *sessionHandler) run(ctx context.Context) error { // - the session emits an unrecoverable error from AcceptRequest, // - a non-shutdown read failure occurs. // -// Per-request failures (auth, rejected requests) do NOT terminate the loop — -// the relay rejects the individual request and continues serving the session. -// A stream opened by anything but a request message is different: §3.3 makes -// it session-fatal, and AcceptRequest has closed the session by the time the -// loop sees the error. +// Per-request failures (auth, rejected requests) do NOT terminate the loop. +// A stream opened by anything but a request message is session-fatal (§3.3); +// AcceptRequest has already closed the session. func (h *sessionHandler) runRequestLoop(ctx context.Context) error { err := h.requestMux(ctx).Run(ctx, h.sess) // A malformed / duplicate / overflowing / unknown AUTHORIZATION_TOKEN alias @@ -299,9 +265,6 @@ func (h *sessionHandler) runRequestLoop(ctx context.Context) error { slog.Uint64("code", uint64(tce.Code))) _ = h.sess.Close(tce.Code, tce.Error()) } - // A request stream opened by anything but a Table 5 "First" message - // (REQUEST_UPDATE, PUBLISH_STATE_NOTIFY, a response, ...) is a - // PROTOCOL_VIOLATION that AcceptRequest has already closed the session on. return err } @@ -309,9 +272,8 @@ func (h *sessionHandler) runRequestLoop(ctx context.Context) error { // streams to [sessionHandler.runFanout], fetch response streams to the fetch // router (see the inline comments below). // -// AcceptDataStream skips streams abandoned mid-header (§11.4.1) itself and -// closes the session on a session-fatal header (§3.4, §11.4.2), so any error -// other than a padding stream means the session is gone and the loop ends. +// AcceptDataStream skips abandoned streams and closes the session on a fatal +// header itself, so any error it returns ends the loop. func (h *sessionHandler) runDataLoop(ctx context.Context) error { for { ds, err := h.sess.AcceptDataStream(ctx) @@ -359,9 +321,8 @@ func (h *sessionHandler) runDataLoop(ctx context.Context) error { // namespaceRequest folds in the §13.7.1 per-session cap for the three // namespace-state requests (the §13.1 subscription cap is inline on SUBSCRIBE). // -// All seven request types are registered. Any other first message is a §3.3 -// PROTOCOL_VIOLATION that [session.Session.AcceptRequest] closes the session on -// before dispatch, so no OnUnknown fallback is needed. +// Any other first message is a §3.3 PROTOCOL_VIOLATION that +// [session.Session.AcceptRequest] handles before dispatch. func (h *sessionHandler) requestMux(ctx context.Context) *session.RequestMux { mux := session.NewRequestMux() @@ -458,15 +419,13 @@ func (h *sessionHandler) rejectAuth(ctx context.Context, req *session.Request, k } } -// excessiveLoadRetry is the least wait an EXCESSIVE_LOAD rejection invites. A -// per-session cap frees up when one of the session's earlier requests ends, -// which the relay cannot predict, so this is a guess, not a promise. +// excessiveLoadRetry is the least wait an EXCESSIVE_LOAD rejection invites; a +// guess, since the relay cannot predict when a per-session cap frees up. const excessiveLoadRetry = time.Second // excessiveLoadRetryInterval is the Retry Interval for an EXCESSIVE_LOAD -// rejection: excessiveLoadRetry plus up to half again of random jitter, which -// §10.6.2 suggests "to minimize the risk of synchronized retry storms", -// encoded as milliseconds plus one. +// rejection (§10.6.2): excessiveLoadRetry plus up to 50% jitter, encoded as +// milliseconds plus one. func excessiveLoadRetryInterval() uint64 { const ms = uint64(excessiveLoadRetry / time.Millisecond) return ms + rand.Uint64N(ms/2) + 1 //nolint:gosec // G404: retry jitter, not a secret. @@ -474,8 +433,6 @@ func excessiveLoadRetryInterval() uint64 { // rejectExcessiveLoad rejects a request that exceeds a per-session resource cap // (§13.1 / §13.7.1) with REQUEST_ERROR EXCESSIVE_LOAD and FINs the bidi stream. -// §10.6.2: for EXCESSIVE_LOAD "The sender SHOULD use the Retry Interval to -// indicate when the request can be retried" — see [excessiveLoadRetryInterval]. // what names the limit category for the log/reason. The reject happens before // any registry mutation, so no cleanup is needed. func (h *sessionHandler) rejectExcessiveLoad(ctx context.Context, req *session.Request, what string) { @@ -578,19 +535,11 @@ func (h *sessionHandler) handleFollowupTokens(ctx context.Context, msg message.M // or ctx is cancelled (the read side is then reset with // StreamResetSessionClosed to unblock the parse). A follow-up that cannot be // read — any non-EOF error — resets the read side with -// StreamResetInternalError so the peer learns reads stopped instead of -// filling flow control into a void; a malformed one also closes the session -// with PROTOCOL_VIOLATION (§10). -// -// This is the single scaffolding under readSubscribeUpdates, -// readFetchUpdates — the responder-side follow-up loops, which differ only -// in their per-message dispatch. (Requester-side upstream streams use -// [session.RequestBroker.Serve] instead, which additionally routes §10.9 -// responses to in-flight Update calls.) +// StreamResetInternalError so the peer learns reads stopped; a malformed one +// also closes the session (§10). // -// It reports fin when the requester ended its side with a FIN. That is not a -// cancellation (§3.3.2); callers decide what the request does next — see -// [awaitRequestEnd]. +// It reports fin when the requester ended its side with a FIN, which is not a +// cancellation (§3.3.2); see [awaitRequestEnd]. func readRequestStream( ctx context.Context, sess *session.Session, @@ -611,9 +560,7 @@ func readRequestStream( if !eof { stream.CancelRead(uint64(moqt.StreamResetInternalError)) } - // §10: an unknown type, or a body that does not match its - // Length, "MUST close the session"; §10.2 says the same of an - // unknown Message Parameter, which fails the body. + // §10, §10.2: a malformed message MUST close the session. if errors.Is(err, message.ErrMalformedMessage) { _ = sess.Close(moqt.SessionProtocolViolation, err.Error()) } @@ -636,11 +583,9 @@ func readRequestStream( } } -// isPeerStateNotify reports a PUBLISH_STATE_NOTIFY arriving on a request the -// relay is answering, and closes the session for it. The peer there is the -// requester, and PUBLISH_STATE_NOTIFY "is sent only by the publisher" of a -// subscription; one "for any other request type, or from the subscriber, MUST -// close the session with a PROTOCOL_VIOLATION" (§10.10). +// isPeerStateNotify reports a PUBLISH_STATE_NOTIFY from the requester of a +// request the relay is answering, and closes the session for it (§10.10: "is +// sent only by the publisher"). func (h *sessionHandler) isPeerStateNotify(m message.Message) bool { if _, ok := m.(*message.PublishStateNotify); !ok { return false @@ -651,11 +596,9 @@ func (h *sessionHandler) isPeerStateNotify(m message.Message) bool { } // awaitRequestEnd keeps a request whose requester FINned its side alive until -// it really ends. §3.3.2: a FIN "is not a request cancellation"; §3.3.3: a -// requester that has FINned "and subsequently wishes to cancel sends -// STOP_SENDING on the receiving direction". The stream's send Context ends on -// exactly that STOP_SENDING — or when the relay itself finishes or resets its -// side (e.g. PUBLISH_DONE + FIN) — and ctx ends with the session. +// it really ends (§3.3.2: a FIN "is not a request cancellation"). The send +// Context ends on the requester's STOP_SENDING (§3.3.3) or when the relay ends +// its own side. func awaitRequestEnd(ctx context.Context, stream session.Stream) { select { case <-stream.Context().Done(): @@ -663,12 +606,9 @@ func awaitRequestEnd(ctx context.Context, stream session.Stream) { } } -// serveFetchObjects is the response tail of the FETCH handler: open the data -// stream, stream the stitched range, -// count the objects actually written (the FetchServed metric), FIN, and -// park in the §10.9 follow-up loop until the requester resets or FINs the -// request stream — on a FIN the relay FINs back, completing the request. -// kind tags log lines with the kind of stream ("fetch"). +// serveFetchObjects is the response tail of the FETCH handler: stream the +// stitched range, FIN, and park in the §10.9 follow-up loop until the +// requester resets or FINs the request stream. kind tags log lines. func (h *sessionHandler) serveFetchObjects( ctx context.Context, req *session.Request, @@ -687,9 +627,6 @@ func (h *sessionHandler) serveFetchObjects( return } - // Read follow-ups (§10.9 REQUEST_UPDATE) on the bidi request stream until - // the requester resets or FINs it or ctx is cancelled, so each update is - // answered. h.readFetchUpdates(ctx, req) } @@ -700,8 +637,7 @@ func (h *sessionHandler) serveFetchObjects( // a fill is simply done. // // It reports false when the stream could not be opened, the write failed, or -// the upstream refused the track's Track Properties (§2.5.1); the stream is -// already reset in the latter two cases, and closed (FIN) otherwise. +// the upstream refused the track (§2.5.1); the stream is then already reset. func (h *sessionHandler) streamFetchRange( ctx context.Context, kind string, @@ -721,8 +657,8 @@ func (h *sessionHandler) streamFetchRange( return false } if sub != nil { - // Both exits below close the stream; a fill stream's subscription - // holds its PUBLISH_DONE until then (§10.12). + // A fill stream's subscription holds its PUBLISH_DONE until the + // stream closes (§10.12). defer sub.StreamClosed() } @@ -730,16 +666,10 @@ func (h *sessionHandler) streamFetchRange( // when the cache doesn't cover the whole range (§9.4). objs, refusal := h.stitchedFetchObjects(ctx, entry, fullName, start, end, order, fillTimeout) if refusal != nil { - // §2.5.1, for a FETCH_OK with a Mandatory Track Property the relay - // does not understand: REQUEST_ERROR UNSUPPORTED_EXTENSION if nothing - // was sent downstream yet, a reset "If the relay has already - // forwarded data on a fetch stream". Here FETCH_OK (or, for a fill, - // SUBSCRIBE_OK) went out and this stream's FETCH_HEADER is open, but - // no Object: between the two cases, and only the reset is left - // (an interpretation). Track Properties that do not parse get the - // same, with MALFORMED_TRACK (§3.3.4), as does a malformed Object in - // the upstream's response: §2.4.2 "reset any fetch streams with - // Status Code MALFORMED_TRACK". + // §2.5.1: with FETCH_OK (or SUBSCRIBE_OK) already sent, only a + // reset is left (an interpretation: no Object was forwarded yet). + // Unparseable Track Properties (§3.3.4) and a malformed upstream + // Object (§2.4.2) reset with MALFORMED_TRACK. code := moqt.StreamResetInternalError if errors.Is(refusal, session.ErrMalformedTrackProperties) || errors.Is(refusal, session.ErrMalformedTrack) { code = moqt.StreamResetMalformedTrack From 6260ea32871dc9071316795dda486f618ca9d52d Mon Sep 17 00:00:00 2001 From: Vsevolod Strukchinsky Date: Sat, 26 Sep 2026 10:10:19 +0500 Subject: [PATCH 04/12] test(session): consolidate helpers and group tests by topic Move shared helpers (session openers, closeRecorder, handRolledSetup, requireClosedProtocolViolation, requireStaysOpen, subscribePair, ...) into helpers_test.go, replacing seven near-identical handshake helpers and two close-recording conns. Fold one-fix files into topical ones: handshake_test.go (PATH/AUTHORITY, GREASE, early data streams), malformed_test.go, request_reject_test.go, request_ok_properties_test.go, setup_token_test.go, fetch_test.go, broker_test.go, session_test.go. Table-drive near-copies (FIN mid-object, TRACK_STATUS follow-ups, FETCH_OK End before Start, param-scope openers, PATH/AUTHORITY send refusals) and trim comments to purpose plus section citation. Test-only; every test keeps its assertions and timeouts. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/moqt/session/broker_internal_test.go | 10 +- pkg/moqt/session/broker_test.go | 80 +++ pkg/moqt/session/close_cancel_test.go | 107 +--- pkg/moqt/session/control_violation_test.go | 107 ---- pkg/moqt/session/ctx_cancel_test.go | 32 +- pkg/moqt/session/datagram_test.go | 36 -- pkg/moqt/session/datastream_cancel_test.go | 18 +- pkg/moqt/session/datastream_object_test.go | 29 +- pkg/moqt/session/datastream_test.go | 161 +++-- pkg/moqt/session/datastream_timeout_test.go | 103 +--- pkg/moqt/session/demux_test.go | 21 +- pkg/moqt/session/early_data_stream_test.go | 258 -------- pkg/moqt/session/fetch_test.go | 77 +++ pkg/moqt/session/fetch_violations_test.go | 122 ---- pkg/moqt/session/followup_role_test.go | 114 ---- pkg/moqt/session/handshake_test.go | 463 +++++++++++++++ pkg/moqt/session/helpers_test.go | 294 +++++++++ pkg/moqt/session/malformed_message_test.go | 175 ------ ...ormed_object_test.go => malformed_test.go} | 173 +++++- pkg/moqt/session/param_scope_test.go | 147 ++--- pkg/moqt/session/priority_test.go | 50 +- pkg/moqt/session/publication_update_test.go | 119 +--- .../session/request_ok_properties_test.go | 293 +++++++-- pkg/moqt/session/request_ok_props_test.go | 210 ------- pkg/moqt/session/request_reject_fault_test.go | 100 ---- ...ejected_test.go => request_reject_test.go} | 116 ++-- pkg/moqt/session/request_test.go | 6 +- pkg/moqt/session/request_update_limit_test.go | 9 +- pkg/moqt/session/session_bench_test.go | 1 + pkg/moqt/session/session_test.go | 556 ++---------------- pkg/moqt/session/setup_token_send_test.go | 99 ---- pkg/moqt/session/setup_token_test.go | 184 +++--- pkg/moqt/session/subscribe_test.go | 7 +- pkg/moqt/session/token_verify_test.go | 96 +-- pkg/moqt/session/track_properties_test.go | 70 +-- pkg/moqt/session/track_status_test.go | 131 ++--- 36 files changed, 1808 insertions(+), 2766 deletions(-) delete mode 100644 pkg/moqt/session/control_violation_test.go delete mode 100644 pkg/moqt/session/early_data_stream_test.go delete mode 100644 pkg/moqt/session/fetch_violations_test.go delete mode 100644 pkg/moqt/session/followup_role_test.go create mode 100644 pkg/moqt/session/handshake_test.go create mode 100644 pkg/moqt/session/helpers_test.go delete mode 100644 pkg/moqt/session/malformed_message_test.go rename pkg/moqt/session/{malformed_object_test.go => malformed_test.go} (50%) delete mode 100644 pkg/moqt/session/request_ok_props_test.go delete mode 100644 pkg/moqt/session/request_reject_fault_test.go rename pkg/moqt/session/{request_rejected_test.go => request_reject_test.go} (52%) delete mode 100644 pkg/moqt/session/setup_token_send_test.go diff --git a/pkg/moqt/session/broker_internal_test.go b/pkg/moqt/session/broker_internal_test.go index 8547005f..6e7ef070 100644 --- a/pkg/moqt/session/broker_internal_test.go +++ b/pkg/moqt/session/broker_internal_test.go @@ -36,6 +36,7 @@ func routeEventually(t *testing.T, b *RequestBroker, msg message.Message) { } } +// startUpdate runs b.Update in the background and delivers its error. func startUpdate(b *RequestBroker) <-chan error { errCh := make(chan error, 1) go func() { @@ -45,8 +46,8 @@ func startUpdate(b *RequestBroker) <-chan error { return errCh } +// newTestBroker returns a broker over a stub stream; a zero-value Session suffices for AllocRequestID. func newTestBroker() *RequestBroker { - // A zero-value Session suffices: Update only needs AllocRequestID. return (&Session{}).NewRequestBroker(brokerStubStream{}) } @@ -156,6 +157,8 @@ func TestBrokerUpdate_TimeoutRemovesWaiter(t *testing.T) { // recordingBrokerStream captures writes so a test can decode what went out. type recordingBrokerStream struct { + brokerStubStream + mu sync.Mutex buf []byte } @@ -166,11 +169,6 @@ func (s *recordingBrokerStream) Write(p []byte) (int, error) { s.mu.Unlock() return len(p), nil } -func (s *recordingBrokerStream) Close() error { return nil } -func (s *recordingBrokerStream) CancelWrite(uint64) {} -func (s *recordingBrokerStream) Read([]byte) (int, error) { return 0, nil } -func (s *recordingBrokerStream) CancelRead(uint64) {} -func (s *recordingBrokerStream) Context() context.Context { return context.Background() } // TestBrokerUpdate_ConsumesFreshRequestIDs pins §10.1: REQUEST_UPDATE is a // request message that consumes a Request ID, so every update the broker diff --git a/pkg/moqt/session/broker_test.go b/pkg/moqt/session/broker_test.go index aa95666e..83db0483 100644 --- a/pkg/moqt/session/broker_test.go +++ b/pkg/moqt/session/broker_test.go @@ -197,3 +197,83 @@ func TestBrokerServe_RejectsInvalidUpdateRequestID(t *testing.T) { t.Fatalf("session close cause = %v, want INVALID_REQUEST_ID", err) } } + +// TestFollowupRolesEnforcedByBroker: REQUEST_UPDATE comes only from a +// request's sender or a PUBLISH subscriber (§10.9), and PUBLISH_STATE_NOTIFY +// only from a subscription's publisher (§10.10); anything else closes the +// session with PROTOCOL_VIOLATION. +func TestFollowupRolesEnforcedByBroker(t *testing.T) { + // Each setup serves one side's broker and returns that side's session + // and the peer's end of the stream, which the test writes to. + onSubscriber := func(t *testing.T, c, s *session.Session) (*session.Session, session.Stream) { + sub, pub := subscribePair(t, c, s) + b := sub.Broker() + go func() { _ = b.Serve(t.Context(), nil) }() + return c, pub.Stream // the publisher writes; the subscriber's broker reads + } + onPublisher := func(t *testing.T, c, s *session.Session) (*session.Session, session.Stream) { + sub, pub := subscribePair(t, c, s) + b := pub.Broker() + go func() { _ = b.Serve(t.Context(), nil) }() + return s, sub.Stream + } + onFetchRequester := func(t *testing.T, c, s *session.Session) (*session.Session, session.Stream) { + peer := acceptWith(t, s, func(r *session.Request) (session.Stream, error) { + _, err := r.AcceptFetch(nil) + return r.Stream, err + }) + fr, err := c.Fetch(t.Context(), &message.Fetch{Name: []byte("t")}) + must(t, err) + b := fr.Broker() + go func() { _ = b.Serve(t.Context(), nil) }() + return c, <-peer + } + onPublishReceiver := func(t *testing.T, c, s *session.Session) (*session.Session, session.Stream) { + incs := make(chan *session.IncomingPublication, 1) + go func() { + r, err := s.AcceptRequest(t.Context()) + if err != nil { + return + } + p, _ := r.AcceptPublish() + incs <- p + }() + pub, err := c.Publish(t.Context(), &message.Publish{Name: []byte("t")}) + must(t, err) + inc := <-incs + b := inc.Broker() + go func() { _ = b.Serve(t.Context(), nil) }() + return s, pub.Stream + } + update := func(c *session.Session) message.Message { return &message.RequestUpdate{RequestID: c.AllocRequestID()} } + notify := func(*session.Session) message.Message { return &message.PublishStateNotify{} } + + for _, tc := range []struct { + name string + serve func(t *testing.T, c, s *session.Session) (*session.Session, session.Stream) + msg func(sender *session.Session) message.Message + closes bool + }{ + {"REQUEST_UPDATE from a SUBSCRIBE's publisher", onSubscriber, update, true}, + {"PUBLISH_STATE_NOTIFY from a SUBSCRIBE's subscriber", onPublisher, notify, true}, + {"REQUEST_UPDATE from a FETCH responder", onFetchRequester, update, true}, + {"PUBLISH_STATE_NOTIFY on a FETCH", onFetchRequester, notify, true}, + {"PUBLISH_STATE_NOTIFY from a SUBSCRIBE's publisher (allowed)", onSubscriber, notify, false}, + {"REQUEST_UPDATE from a PUBLISH's sender (allowed)", onPublishReceiver, update, false}, + } { + t.Run(tc.name, func(t *testing.T) { + client, server := openPair(t) + served, peer := tc.serve(t, client, server) + sender := client + if served == client { + sender = server + } + go func() { _ = message.Marshal(peer, tc.msg(sender)) }() + if tc.closes { + requireClosedProtocolViolation(t, served) + return + } + requireStaysOpen(t, served, 200*time.Millisecond) + }) + } +} diff --git a/pkg/moqt/session/close_cancel_test.go b/pkg/moqt/session/close_cancel_test.go index 12818882..f75a497f 100644 --- a/pkg/moqt/session/close_cancel_test.go +++ b/pkg/moqt/session/close_cancel_test.go @@ -3,28 +3,19 @@ package session_test import ( "errors" "io" - "sync" "testing" "time" "github.com/floatdrop/moq-go/pkg/moqt" "github.com/floatdrop/moq-go/pkg/moqt/message" "github.com/floatdrop/moq-go/pkg/moqt/session" - "github.com/floatdrop/moq-go/pkg/moqt/session/sessiontest" "github.com/floatdrop/moq-go/pkg/moqt/wire" ) -// §3.3.2: "A FIN only indicates that an endpoint will send no further messages -// in that direction; it is not a request cancellation." §3.3.3: a request is -// cancelled "by abruptly terminating any directions of the stream that are -// still open, using RESET_STREAM for a direction they are sending and -// STOP_SENDING for a direction they are receiving". Close on a handle whose -// owner ends the request must therefore cancel, not FIN — the peer has to see -// a reset, never a clean end. +// Closing a request handle cancels the request with RESET_STREAM / +// STOP_SENDING (§3.3.3); a FIN is not a cancellation (§3.3.2). -// requireCancelled reads s until it fails and requires the failure to be a -// reset rather than a clean FIN. Messages before it (e.g. a PUBLISH_DONE) are -// skipped. +// requireCancelled reads s until it fails, skipping messages, and requires a reset rather than a clean FIN. func requireCancelled(t *testing.T, s session.Stream) { t.Helper() got := make(chan error, 1) @@ -89,18 +80,8 @@ func TestCloseCancelsRequest(t *testing.T) { return pair{(<-inc).Close, pub.Stream} }}, {"Publication (SUBSCRIBE answered)", func(t *testing.T, c, s *session.Session) pair { - pubs := make(chan *session.Publication, 1) - go func() { - r, err := s.AcceptRequest(t.Context()) - if err != nil { - return - } - p, _ := r.AcceptSubscribe(nil) - pubs <- p - }() - sub, err := c.Subscribe(t.Context(), &message.Subscribe{Name: []byte("t")}) - must(t, err) - return pair{(<-pubs).Close, sub.Stream} + sub, pub := subscribePair(t, c, s) + return pair{pub.Close, sub.Stream} }}, {"FetchRequest", func(t *testing.T, c, s *session.Session) pair { peer := acceptWith(t, s, func(r *session.Request) (session.Stream, error) { @@ -150,23 +131,11 @@ func TestCloseCancelsRequest(t *testing.T) { } } -// TestPublicationCloseAfterDoneKeepsPublishDone: Done is the graceful end — -// PUBLISH_DONE then FIN (§3.3.2). A Close after it must not reset the send -// side and risk losing the PUBLISH_DONE; it only stops reading. +// TestPublicationCloseAfterDoneKeepsPublishDone: after Done's PUBLISH_DONE and +// FIN (§3.3.2), Close only stops reading and must not reset the send side. func TestPublicationCloseAfterDoneKeepsPublishDone(t *testing.T) { client, server := openPair(t) - pubs := make(chan *session.Publication, 1) - go func() { - r, err := server.AcceptRequest(t.Context()) - if err != nil { - return - } - p, _ := r.AcceptSubscribe(nil) - pubs <- p - }() - sub, err := client.Subscribe(t.Context(), &message.Subscribe{Name: []byte("t")}) - must(t, err) - pub := <-pubs + sub, pub := subscribePair(t, client, server) // Read concurrently: on the unbuffered test pipe Done's write completes // only as the subscriber reads. @@ -198,58 +167,11 @@ func TestPublicationCloseAfterDoneKeepsPublishDone(t *testing.T) { } } -// acceptWith accepts the next request on s, answers it with accept, and -// delivers the server side of its stream. -func acceptWith( - t *testing.T, - s *session.Session, - accept func(*session.Request) (session.Stream, error), -) <-chan session.Stream { - t.Helper() - out := make(chan session.Stream, 1) - go func() { - r, err := s.AcceptRequest(t.Context()) - if err != nil { - return - } - stream, err := accept(r) - if err != nil { - return - } - out <- stream - }() - return out -} - -func must(t *testing.T, err error) { - t.Helper() - if err != nil { - t.Fatal(err) - } -} - -// TestFetchOKUnknownMandatoryPropertyCancels pins §2.5.1: "For FETCH_OK -// messages: the subscriber MUST cancel the fetch (see Section 3.3.3)". A FIN -// would leave the publisher serving the fetch (§3.3.2). +// TestFetchOKUnknownMandatoryPropertyCancels: a FETCH_OK with an unknown +// Mandatory Track Property makes the subscriber cancel the fetch (§2.5.1). func TestFetchOKUnknownMandatoryPropertyCancels(t *testing.T) { - aConn, bConn := sessiontest.NewConnPair() - var ( - client, server *session.Session - cErr, sErr error - wg sync.WaitGroup - ) - wg.Go(func() { - client, cErr = session.Client(t.Context(), aConn, - session.WithKnownMandatoryTrackProperties(map[message.PropertyType]struct{}{})) - }) - wg.Go(func() { server, sErr = session.Server(t.Context(), bConn) }) - wg.Wait() - must(t, cErr) - must(t, sErr) - t.Cleanup(func() { - _ = client.Close(moqt.SessionNoError, "") - _ = server.Close(moqt.SessionNoError, "") - }) + client, server := openPairWithOpts(t, + []session.Option{session.WithKnownMandatoryTrackProperties(map[message.PropertyType]struct{}{})}, nil) peer := acceptWith(t, server, func(r *session.Request) (session.Stream, error) { _, err := r.AcceptFetch(&message.FetchOK{TrackProperties: message.AppendTrackProperties( @@ -262,9 +184,8 @@ func TestFetchOKUnknownMandatoryPropertyCancels(t *testing.T) { requireCancelled(t, <-peer) } -// TestRequestBrokerCloseCancels: RequestBroker.Close is a §3.3.3 cancel for -// whatever stream it wraps, not only when that stream's own Close happens to -// cancel. +// TestRequestBrokerCloseCancels: RequestBroker.Close cancels the stream it +// wraps (§3.3.3). func TestRequestBrokerCloseCancels(t *testing.T) { client, server := openPair(t) peer := acceptWith(t, server, func(r *session.Request) (session.Stream, error) { diff --git a/pkg/moqt/session/control_violation_test.go b/pkg/moqt/session/control_violation_test.go deleted file mode 100644 index b64cb45e..00000000 --- a/pkg/moqt/session/control_violation_test.go +++ /dev/null @@ -1,107 +0,0 @@ -package session_test - -import ( - "context" - "errors" - "strings" - "sync" - "testing" - "time" - - "github.com/floatdrop/moq-go/pkg/moqt" - "github.com/floatdrop/moq-go/pkg/moqt/message" - "github.com/floatdrop/moq-go/pkg/moqt/session" - "github.com/floatdrop/moq-go/pkg/moqt/session/sessiontest" - "github.com/floatdrop/moq-go/pkg/moqt/wire" -) - -// TestControlStreamViolationsCloseTheSession covers what the control stream -// does with a message that has no business being on it. -// -// Per table 5 in §10, GOAWAY is the only message valid on the control stream -// after SETUP for the messages in scope; anything else is a protocol violation -// and §3.5 gives PROTOCOL_VIOLATION as the code. Only the GOAWAY branch had a -// test, so both rejection paths — a second SETUP, and a message that belongs on -// a request stream — were unexercised. -// -// This is worth pinning rather than counting: the dispatcher used to carry a -// mechanism for per-rule close codes that nothing ever constructed, and removing -// it rested on the claim that every violation here is a PROTOCOL_VIOLATION. That -// claim had nothing asserting it. Now the close code and the reason are both -// checked, so narrowing the dispatcher again cannot quietly change what a peer -// is told. -func TestControlStreamViolationsCloseTheSession(t *testing.T) { - tests := []struct { - name string - offending message.Message - wantReason string - }{ - { - name: "a second SETUP", - offending: &message.Setup{}, - wantReason: "duplicate SETUP", - }, - { - // SUBSCRIBE is legal, but only as the first message of a request - // stream — never on the control stream. - name: "a request-stream message", - offending: &message.Subscribe{Namespace: wire.Namespace("demo"), Name: []byte("cam")}, - wantReason: "unexpected", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - t.Cleanup(cancel) - ourConn, peerConn := sessiontest.NewConnPair() - - // A hand-rolled peer: complete SETUP the way handshake does — each - // side opens a uni-stream and writes SETUP — then send the offending - // message down the same stream, which is now the control stream. - var wg sync.WaitGroup - wg.Go(func() { - send, err := peerConn.OpenUniStream() - if err != nil { - t.Errorf("peer: OpenUniStream: %v", err) - return - } - if err := message.Marshal(send, &message.Setup{}); err != nil { - t.Errorf("peer: Marshal SETUP: %v", err) - return - } - if recv, err := peerConn.AcceptUniStream(ctx); err == nil { - _, _ = message.Parse(recv) - } - // Post-SETUP, on the control stream. - _ = message.Marshal(send, tt.offending) - }) - - sess, err := session.Client(ctx, ourConn) - if err != nil { - t.Fatalf("Client: %v", err) - } - wg.Wait() - - select { - case <-sess.Done(): - case <-time.After(5 * time.Second): - t.Fatal("session stayed open after a control-stream violation") - } - - var closed *session.ClosedError - if !errors.As(sess.Err(), &closed) { - t.Fatalf("Err() = %v, want a *session.ClosedError", sess.Err()) - } - if closed.Code != moqt.SessionProtocolViolation { - t.Errorf("closed with code %#x, want PROTOCOL_VIOLATION (%#x)", - uint64(closed.Code), uint64(moqt.SessionProtocolViolation)) - } - // The reason travels to the peer, so it should say which rule broke - // rather than being a bare "protocol violation". - if !strings.Contains(closed.Reason, tt.wantReason) { - t.Errorf("reason %q does not mention %q", closed.Reason, tt.wantReason) - } - }) - } -} diff --git a/pkg/moqt/session/ctx_cancel_test.go b/pkg/moqt/session/ctx_cancel_test.go index a03f3559..2837ec48 100644 --- a/pkg/moqt/session/ctx_cancel_test.go +++ b/pkg/moqt/session/ctx_cancel_test.go @@ -3,7 +3,6 @@ package session_test import ( "context" "errors" - "sync" "testing" "time" @@ -57,8 +56,7 @@ func TestServerHandshakeCtxCancel(t *testing.T) { // surface ctx.Err(). func TestAcceptRequestCtxCancel(t *testing.T) { t.Parallel() - aConn, bConn := sessiontest.NewConnPair() - bSess := serverOf(t, aConn, bConn) + _, bSess, aConn, _ := openPairWithConns(t) stream, err := aConn.OpenStream() if err != nil { @@ -91,8 +89,7 @@ func TestAcceptRequestCtxCancel(t *testing.T) { // must unblock it and surface ctx.Err(). func TestAcceptDataStreamCtxCancel(t *testing.T) { t.Parallel() - aConn, bConn := sessiontest.NewConnPair() - bSess := serverOf(t, aConn, bConn) + _, bSess, aConn, _ := openPairWithConns(t) stream, err := aConn.OpenUniStream() if err != nil { @@ -122,36 +119,13 @@ func TestAcceptDataStreamCtxCancel(t *testing.T) { } } -// serverOf completes a real handshake over the pair and returns the server -// session; the client session is closed with the test. -func serverOf(t *testing.T, aConn, bConn session.Conn) *session.Session { - t.Helper() - var ( - wg sync.WaitGroup - aSess, bSess *session.Session - aErr, bErr error - ) - wg.Go(func() { aSess, aErr = session.Client(t.Context(), aConn) }) - wg.Go(func() { bSess, bErr = session.Server(t.Context(), bConn) }) - wg.Wait() - if aErr != nil || bErr != nil { - t.Fatalf("handshake: client=%v server=%v", aErr, bErr) - } - t.Cleanup(func() { - _ = aSess.Close(0, "test done") - _ = bSess.Close(0, "test done") - }) - return bSess -} - // TestSessionErrPublishedBeforeDone pins the <-Done(); Err() contract: the // close cause is visible (and race-free under -race) the moment Done fires, // and carries the actual §3.5 code and reason — not the transport close // result, which is nil in the common case. func TestSessionErrPublishedBeforeDone(t *testing.T) { t.Parallel() - aConn, bConn := sessiontest.NewConnPair() - bSess := serverOf(t, aConn, bConn) + _, bSess := openPair(t) go bSess.Close(0x3 /* PROTOCOL_VIOLATION */, "test cause") diff --git a/pkg/moqt/session/datagram_test.go b/pkg/moqt/session/datagram_test.go index 25cf718d..3f6f8dc1 100644 --- a/pkg/moqt/session/datagram_test.go +++ b/pkg/moqt/session/datagram_test.go @@ -1,51 +1,15 @@ package session_test import ( - "sync" "testing" - "github.com/floatdrop/moq-go/pkg/moqt" "github.com/floatdrop/moq-go/pkg/moqt/message" - "github.com/floatdrop/moq-go/pkg/moqt/session" - "github.com/floatdrop/moq-go/pkg/moqt/session/sessiontest" "github.com/floatdrop/moq-go/pkg/moqt/wire" ) // paddingDatagramType mirrors the unexported constant in datagram.go (§11.5.2). const paddingDatagramType uint64 = 0x132B3E29 -// openPairWithConns is openPair's sibling that also returns the underlying -// conns, so tests can inject raw datagrams (padding, unknown types) that the -// Session API would never produce. -func openPairWithConns(t *testing.T) (cli, srv *session.Session, cliConn, srvConn session.Conn) { - t.Helper() - ctx := t.Context() - cliConn, srvConn = sessiontest.NewConnPair() - - var ( - wg sync.WaitGroup - cErr, sErr error - ) - wg.Go(func() { - cli, cErr = session.Client(ctx, cliConn, session.WithImplementation("test/client")) - }) - wg.Go(func() { - srv, sErr = session.Server(ctx, srvConn, session.WithImplementation("test/server")) - }) - wg.Wait() - if cErr != nil { - t.Fatalf("client Open: %v", cErr) - } - if sErr != nil { - t.Fatalf("server Open: %v", sErr) - } - t.Cleanup(func() { - _ = cli.Close(moqt.SessionNoError, "test cleanup") - _ = srv.Close(moqt.SessionNoError, "test cleanup") - }) - return cli, srv, cliConn, srvConn -} - func TestSendReceiveDatagram_RoundTrip(t *testing.T) { cli, srv := openPair(t) ctx := t.Context() diff --git a/pkg/moqt/session/datastream_cancel_test.go b/pkg/moqt/session/datastream_cancel_test.go index f49f74e6..ce3b654b 100644 --- a/pkg/moqt/session/datastream_cancel_test.go +++ b/pkg/moqt/session/datastream_cancel_test.go @@ -9,22 +9,8 @@ import ( "github.com/floatdrop/moq-go/pkg/moqt/session" ) -// TestIncomingFetchStreamCancelResetsTheReadSide covers the one Cancel in the -// package that no test anywhere reaches. -// -// Its subgroup twin is exercised by the relay's tests, so it reads as covered -// in a whole-suite profile; this one is at 0% across every package. -// -// What it is NOT guarding is the obvious hazard of a hand-copied one-liner, -// resetting the wrong half: ReceiveStream has no CancelWrite, so that mistake -// does not compile. What it does guard is that Cancel reaches the peer at all — -// a body that drops the call, or resets something other than the stream's own -// source, still compiles and still looks right, and §3.3.4 expects a receiver -// declining the rest of a stream to reset its read side so the sender stops. -// -// So the assertion is about the peer rather than the caller: the writer must -// see the stream fail. Asserting Cancel "did not panic" would restate the -// implementation and catch nothing. +// TestIncomingFetchStreamCancelResetsTheReadSide: Cancel resets the read side +// so the peer's writer sees the stream fail (§3.3.4). func TestIncomingFetchStreamCancelResetsTheReadSide(t *testing.T) { cli, srv := openPair(t) ctx := t.Context() diff --git a/pkg/moqt/session/datastream_object_test.go b/pkg/moqt/session/datastream_object_test.go index 063d7816..7ddc97b5 100644 --- a/pkg/moqt/session/datastream_object_test.go +++ b/pkg/moqt/session/datastream_object_test.go @@ -250,15 +250,8 @@ func TestFetchObjectRoundTrip(t *testing.T) { } } -// TestWriteObjectWrongType verifies that the type system prevents passing a -// FetchObject to an OutgoingSubgroupStream at compile time. At runtime we -// verify that a subgroup stream correctly rejects a nil payload (zero-value -// object) without panicking, and that a fetch stream correctly rejects a nil -// payload without panicking. -// -// The compile-time guarantee is the primary value: WriteObject(*SubgroupObject) -// and WriteObject(*FetchObject) are distinct method signatures, so the wrong -// type is a compile error, not a runtime error. +// TestWriteObjectWrongType: the wrong object type is a compile error (distinct +// WriteObject signatures); at runtime a zero-value object must not panic. func TestWriteObjectWrongType(t *testing.T) { cli, srv := openPair(t) ctx := t.Context() @@ -734,12 +727,9 @@ func TestIncomingFetchStream_ReadDecoded_Descending(t *testing.T) { } } -// TestIncomingFetchStream_ReadDecoded_EndOfRange verifies that -// §11.4.4.2 absence markers surface via EndOfNonExistentRange / -// EndOfUnknownRange and BECOME the prior Group/Object ID for the next -// object ("Prior Group ID and prior Object ID: The values from the End of -// Range indicator"), while the prior Subgroup ID and Priority stay those -// of the last actual object. +// TestIncomingFetchStream_ReadDecoded_EndOfRange: End of Range markers surface +// as EndOfNonExistentRange / EndOfUnknownRange and become the prior Group and +// Object ID; the prior Subgroup ID and Priority stay the last Object's (§11.4.4.2). func TestIncomingFetchStream_ReadDecoded_EndOfRange(t *testing.T) { cli, srv := openPair(t) ctx := t.Context() @@ -827,12 +817,9 @@ func TestIncomingFetchStream_ReadDecoded_EndOfRange(t *testing.T) { } } -// TestIncomingFetchStream_ReadDecoded_FirstObjectViolations verifies -// §11.4.4.1: "The first Object MUST include a Group ID Delta and Object ID -// Delta [...]. If the first Object in the FETCH response uses a flag that -// references fields in the prior Object, the Subscriber MUST close the session -// with a PROTOCOL_VIOLATION." Each field that can reference the prior Object -// is covered. +// TestIncomingFetchStream_ReadDecoded_FirstObjectViolations: a first Object +// missing a delta, or referencing any prior-Object field, closes the session +// with PROTOCOL_VIOLATION (§11.4.4.1). func TestIncomingFetchStream_ReadDecoded_FirstObjectViolations(t *testing.T) { tests := []struct { name string diff --git a/pkg/moqt/session/datastream_test.go b/pkg/moqt/session/datastream_test.go index b4f2234f..7d6e0916 100644 --- a/pkg/moqt/session/datastream_test.go +++ b/pkg/moqt/session/datastream_test.go @@ -155,10 +155,8 @@ func TestAcceptDataStreamReservedSubgroupIDMode(t *testing.T) { requireClosedProtocolViolation(t, server) } -// TestAcceptDataStreamUnknownTypeClosesSession pins §3.4: "An endpoint that -// receives an unknown stream type MUST close the session." AcceptDataStream -// does so itself, so no caller can leave the session half-open by merely -// stopping its accept loop. +// TestAcceptDataStreamUnknownTypeClosesSession: an unknown stream type closes +// the session (§3.4), which AcceptDataStream does itself. func TestAcceptDataStreamUnknownTypeClosesSession(t *testing.T) { client, server := openPair(t) @@ -180,11 +178,8 @@ func TestAcceptDataStreamUnknownTypeClosesSession(t *testing.T) { requireClosedProtocolViolation(t, server) } -// TestAcceptDataStreamSkipsAbortedHeaders pins §11.4.1: "Early termination of -// a unidirectional stream does not affect the MOQT application state." A data -// stream that ends or is reset before its header is complete is abandoned, and -// AcceptDataStream goes on to return the next stream rather than an error the -// caller would take as fatal. +// TestAcceptDataStreamSkipsAbortedHeaders: a data stream ended or reset before +// its header is complete is skipped, not an error (§11.4.1). func TestAcceptDataStreamSkipsAbortedHeaders(t *testing.T) { client, server := openPair(t) conn := session.SessionConn(client) @@ -231,53 +226,66 @@ func TestAcceptDataStreamSkipsAbortedHeaders(t *testing.T) { } } -// requireClosedProtocolViolation waits for sess to close and checks the code. -func requireClosedProtocolViolation(t *testing.T, sess *session.Session) { - t.Helper() - select { - case <-sess.Done(): - case <-time.After(2 * time.Second): - t.Fatal("session stayed open; want PROTOCOL_VIOLATION close") - } - closed, ok := errors.AsType[*session.ClosedError](sess.Err()) - if !ok { - t.Fatalf("Err() = %v, want a *session.ClosedError", sess.Err()) - } - if closed.Code != moqt.SessionProtocolViolation { - t.Errorf("closed with code %#x, want PROTOCOL_VIOLATION (%#x)", - uint64(closed.Code), uint64(moqt.SessionProtocolViolation)) - } -} - -// TestSubgroupStreamFINMidObjectClosesSession pins §11.4: "If a stream ends -// gracefully (i.e., the stream terminates with a FIN) in the middle of a -// serialized Object, the session SHOULD be closed with a PROTOCOL_VIOLATION." -// ReadObject must neither report the torn stream as a clean io.EOF nor leave -// the session open. -func TestSubgroupStreamFINMidObjectClosesSession(t *testing.T) { - client, server := openPair(t) - go func() { - out, err := client.OpenSubgroup(message.SubgroupHeader{TrackAlias: 7, EndOfGroup: true}) - if err != nil { - return // surfaces as the AcceptDataStream error below - } - _, _ = out.Write([]byte{0x00}) // Object ID Delta, then FIN - _ = out.Close() - }() +// TestFINMidObjectClosesSession: a data stream FINed inside a serialized +// Object is io.ErrUnexpectedEOF, not io.EOF, and closes the session with +// PROTOCOL_VIOLATION (§11.4). +func TestFINMidObjectClosesSession(t *testing.T) { + for _, tc := range []struct { + name string + open func(*session.Session) (io.WriteCloser, error) + body []byte // the start of an Object, followed by FIN + read func(session.DataStream) error + }{ + { + "subgroup", + func(c *session.Session) (io.WriteCloser, error) { + return c.OpenSubgroup(message.SubgroupHeader{TrackAlias: 7, EndOfGroup: true}) + }, + []byte{0x00}, // Object ID Delta + func(ds session.DataStream) error { + _, err := ds.(*session.IncomingSubgroupStream).ReadObject() + return err + }, + }, + { + "fetch", + func(c *session.Session) (io.WriteCloser, error) { + return c.OpenFetchStream(message.FetchHeader{RequestID: 1}) + }, + // Serialization Flags with Group and Object ID Delta present. + []byte{byte(message.FetchFlagGroupIDDelta | message.FetchFlagObjectIDDelta)}, + func(ds session.DataStream) error { + _, err := ds.(*session.IncomingFetchStream).ReadObject() + return err + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + client, server := openPair(t) + go func() { + out, err := tc.open(client) + if err != nil { + return // surfaces as the AcceptDataStream error below + } + _, _ = out.Write(tc.body) + _ = out.Close() + }() - ds, err := server.AcceptDataStream(t.Context()) - if err != nil { - t.Fatalf("AcceptDataStream: %v", err) - } - _, err = ds.(*session.IncomingSubgroupStream).ReadObject() - if errors.Is(err, io.EOF) || !errors.Is(err, io.ErrUnexpectedEOF) { - t.Fatalf("ReadObject = %v, want io.ErrUnexpectedEOF (and not io.EOF)", err) + ds, err := server.AcceptDataStream(t.Context()) + if err != nil { + t.Fatalf("AcceptDataStream: %v", err) + } + err = tc.read(ds) + if errors.Is(err, io.EOF) || !errors.Is(err, io.ErrUnexpectedEOF) { + t.Fatalf("ReadObject = %v, want io.ErrUnexpectedEOF (and not io.EOF)", err) + } + requireClosedProtocolViolation(t, server) + }) } - requireClosedProtocolViolation(t, server) } -// TestSubgroupStreamResetMidObjectKeepsSession is the other half: a reset is -// §11.4.1 cancellation, not a malformed stream, and leaves the session alone. +// TestSubgroupStreamResetMidObjectKeepsSession: a reset mid-object is +// cancellation (§11.4.1), not a malformed stream, and leaves the session up. func TestSubgroupStreamResetMidObjectKeepsSession(t *testing.T) { client, server := openPair(t) go func() { @@ -296,41 +304,11 @@ func TestSubgroupStreamResetMidObjectKeepsSession(t *testing.T) { if _, err := ds.(*session.IncomingSubgroupStream).ReadObject(); err == nil || errors.Is(err, io.EOF) { t.Fatalf("ReadObject = %v, want a reset error", err) } - select { - case <-server.Done(): - t.Fatalf("session closed after a mid-object reset: %v", server.Err()) - case <-time.After(50 * time.Millisecond): - } -} - -// TestFetchStreamFINMidObjectClosesSession is the FETCH-stream counterpart of -// TestSubgroupStreamFINMidObjectClosesSession: §11.4 covers every data stream. -func TestFetchStreamFINMidObjectClosesSession(t *testing.T) { - client, server := openPair(t) - go func() { - out, err := client.OpenFetchStream(message.FetchHeader{RequestID: 1}) - if err != nil { - return - } - // Serialization Flags with Group and Object ID Delta present, then FIN. - _, _ = out.Write([]byte{byte(message.FetchFlagGroupIDDelta | message.FetchFlagObjectIDDelta)}) - _ = out.Close() - }() - - ds, err := server.AcceptDataStream(t.Context()) - if err != nil { - t.Fatalf("AcceptDataStream: %v", err) - } - _, err = ds.(*session.IncomingFetchStream).ReadObject() - if errors.Is(err, io.EOF) || !errors.Is(err, io.ErrUnexpectedEOF) { - t.Fatalf("ReadObject = %v, want io.ErrUnexpectedEOF (and not io.EOF)", err) - } - requireClosedProtocolViolation(t, server) + requireStaysOpen(t, server, 50*time.Millisecond) } -// TestSubgroupObjectIDOverflowClosesSession pins §11.4.2: "If the resulting -// Object ID would be greater than 2^64 - 1, the endpoint MUST close the -// session with a PROTOCOL_VIOLATION." Without the check the ID wraps to 0. +// TestSubgroupObjectIDOverflowClosesSession: an Object ID past 2^64-1 closes +// the session with PROTOCOL_VIOLATION rather than wrapping (§11.4.2). func TestSubgroupObjectIDOverflowClosesSession(t *testing.T) { client, server := openPair(t) go func() { @@ -357,9 +335,8 @@ func TestSubgroupObjectIDOverflowClosesSession(t *testing.T) { requireClosedProtocolViolation(t, server) } -// TestFetchIDOverflowClosesSession pins §11.4.4.1: "If the computed Group ID -// would be less than 0 or greater than 2^64-1, the Subscriber MUST close the -// Session with error 'PROTOCOL_VIOLATION'", and the same for the Object ID. +// TestFetchIDOverflowClosesSession: a computed Group or Object ID below 0 or +// past 2^64-1 closes the session with PROTOCOL_VIOLATION (§11.4.4.1). func TestFetchIDOverflowClosesSession(t *testing.T) { const maxID = uint64(math.MaxUint64) both := message.FetchFlagGroupIDDelta | message.FetchFlagObjectIDDelta @@ -424,12 +401,8 @@ func TestFetchIDOverflowClosesSession(t *testing.T) { } } -// TestSubgroupInvalidObjectClosesSession: a subgroup object the draft says -// is session-fatal must close the session, not just fail its stream. -// - §11.2.1.2: properties on a non-Normal status object — "MUST close the -// session with a PROTOCOL_VIOLATION". -// - §11.2.1.1: an unknown Object Status "SHOULD be treated as a protocol -// error and the session SHOULD be closed with a PROTOCOL_VIOLATION". +// TestSubgroupInvalidObjectClosesSession: properties on a non-Normal status +// Object (§11.2.1.2) and an unknown Object Status (§11.2.1.1) close the session. func TestSubgroupInvalidObjectClosesSession(t *testing.T) { tests := []struct { name string diff --git a/pkg/moqt/session/datastream_timeout_test.go b/pkg/moqt/session/datastream_timeout_test.go index 8c43ba63..3b589cc9 100644 --- a/pkg/moqt/session/datastream_timeout_test.go +++ b/pkg/moqt/session/datastream_timeout_test.go @@ -15,12 +15,9 @@ import ( "github.com/floatdrop/moq-go/pkg/moqt/wire" ) -// TestObjectDeliveryTimeoutObjectPropertyOverride verifies the §12.2 -// first-object override: the Track-level OBJECT_DELIVERY_TIMEOUT is long, but -// the first object of the subgroup carries an OBJECT_DELIVERY_TIMEOUT Object -// Property that shortens it, so a later write past the short (overridden) -// timeout resets the stream. If the override were ignored, the long Track-level -// timeout would let the second write through. +// TestObjectDeliveryTimeoutObjectPropertyOverride: an OBJECT_DELIVERY_TIMEOUT +// Object Property on the subgroup's first Object overrides the Track value +// (§12.2), so a write past the shorter timeout resets the stream. func TestObjectDeliveryTimeoutObjectPropertyOverride(t *testing.T) { client, server := openPair(t) @@ -77,15 +74,9 @@ func TestObjectDeliveryTimeoutObjectPropertyOverride(t *testing.T) { } } -// TestObjectDeliveryTimeoutIsPerObjectNotPerStream pins the §8 clock, which -// starts at "the last header byte of every object" and is checked "before -// attempting to pass it to the underlying transport". Objects that arrive fresh keep passing however long the stream has -// been open — the timeout bounds an object's age, not a stream's lifetime. -// -// The distinction is invisible to any test that stalls the sender, because a -// stall breaches both readings at once. It shows up only here, where nothing is -// ever late: a per-stream clock resets this stream partway through, a per-object -// clock delivers every object. +// TestObjectDeliveryTimeoutIsPerObjectNotPerStream: the §8 clock runs per +// Object, so fresh Objects keep passing however long the stream is open. Only a +// sender that is never late tells this apart from a per-stream clock. func TestObjectDeliveryTimeoutIsPerObjectNotPerStream(t *testing.T) { client, server := openPair(t) @@ -142,18 +133,9 @@ func TestObjectDeliveryTimeoutIsPerObjectNotPerStream(t *testing.T) { } } -// TestObjectDeliveryTimeoutOverrideCannotOutrankSubscriber pins §8's resolution -// ORDER, which is not symmetric: "the publisher's value is the Object Property -// when present on the first object of the subgroup, and the Track Property -// otherwise. If both the publisher's value and the subscriber's value are -// non-zero, the smaller of the two is used." -// -// So the first-object override replaces the publisher's Track-level value and -// nothing else — the subscriber's value is then compared against the result. A -// publisher cannot lengthen a subscriber's timeout by overriding its own. An -// implementation that merges the two halves first and applies the override to -// the merged value gets this backwards, and silently hands the publisher a veto -// over every subscriber's deadline. +// TestObjectDeliveryTimeoutOverrideCannotOutrankSubscriber: the first-object +// override replaces only the publisher's value, and the smaller of that and the +// subscriber's is used (§8), so a publisher cannot lengthen a subscriber's timeout. func TestObjectDeliveryTimeoutOverrideCannotOutrankSubscriber(t *testing.T) { client, server := openPair(t) @@ -211,17 +193,9 @@ func TestObjectDeliveryTimeoutOverrideCannotOutrankSubscriber(t *testing.T) { } } -// TestObjectDeliveryTimeoutOverrideIgnoredOnReplayStream pins §12.2's "it is -// ignored on any other object in the subgroup" across a stream boundary. -// -// The override belongs to the first object of the SUBGROUP, which is not the -// same as the first object on a STREAM. A relay reaches the difference on two -// routine paths: a subscriber that joins while the subgroup is already in -// flight, and a §11.4.3 gap-reopen. Both open a stream with the §11.4.2 -// FIRST_OBJECT bit clear (ReplayingSubgroup), starting at whatever object comes -// next — and if that object happens to carry a timeout property, honouring it -// lets a publisher stretch, shrink or disable the timeout from the middle of a -// subgroup. Here the stray property would extend 50 ms to 10 s. +// TestObjectDeliveryTimeoutOverrideIgnoredOnReplayStream: the override applies +// only to the subgroup's first Object (§12.2), not the first Object of a replay +// stream (FIRST_OBJECT clear, §11.4.2). Here it would stretch 50 ms to 10 s. func TestObjectDeliveryTimeoutOverrideIgnoredOnReplayStream(t *testing.T) { client, server := openPair(t) @@ -291,16 +265,9 @@ func TestObjectDeliveryTimeoutOverrideIgnoredOnReplayStream(t *testing.T) { // OBJECT_DELIVERY_TIMEOUT: Write() enforcement // --------------------------------------------------------------------------- -// TestObjectDeliveryTimeoutWriteRawIsNotEnforced pins a deliberate gap: the raw -// Write escape hatch does NOT enforce OBJECT_DELIVERY_TIMEOUT. -// -// §8 measures the timeout per object, from the moment that object was received. -// Write takes bytes with no object boundaries in them and no receipt time, so -// it has neither input the check needs. It used to enforce a stream-lifetime -// cap instead — first Write starts a clock, later Writes fail once it elapses — -// which reset healthy senders for no reason beyond having kept the stream open, -// and let a genuinely stale object through on a stream that had just reopened. -// Callers that want the timeout use WriteObjectReceivedAt. +// TestObjectDeliveryTimeoutWriteRawIsNotEnforced: raw Write carries no Object +// boundaries or receipt time, so it does not enforce the per-Object §8 timeout; +// WriteObjectReceivedAt does. func TestObjectDeliveryTimeoutWriteRawIsNotEnforced(t *testing.T) { client, server := openPair(t) @@ -447,20 +414,15 @@ func TestObjectDeliveryTimeoutDisabled(t *testing.T) { // SUBGROUP_DELIVERY_TIMEOUT: Close() enforcement // --------------------------------------------------------------------------- -// TestSubgroupDeliveryTimeoutReset pins the §8 reset: once the subgroup is -// closed, a stream the peer has not finished acknowledging within -// SUBGROUP_DELIVERY_TIMEOUT is reset with DELIVERY_TIMEOUT. -// -// The stream's Context ends at Close, as quic-go's does, so only the -// [session.DeliveryTrackingSendStream] signal can tell delivery apart from the -// FIN being queued. Waiting on Context instead pre-empts the timer on every -// real transport, and the reset never fires. +// TestSubgroupDeliveryTimeoutReset: a closed subgroup the peer has not fully +// acknowledged within SUBGROUP_DELIVERY_TIMEOUT is reset with DELIVERY_TIMEOUT +// (§8). Only the DeliveryTrackingSendStream signal reports delivery. func TestSubgroupDeliveryTimeoutReset(t *testing.T) { t.Parallel() fake := newFinishingSendStream() const timeout = 20 * time.Millisecond - ds := newOutgoingDataStreamForTest(fake) + ds := session.NewOutgoingSubgroupStream(fake) ds = ds.WithDeliveryTimeouts(message.DeliveryTimeouts{Subgroup: timeout}, message.DeliveryTimeouts{}) if _, err := ds.Write([]byte("payload")); err != nil { @@ -488,7 +450,7 @@ func TestSubgroupDeliveryTimeoutNoReset(t *testing.T) { fake := newFinishingSendStream() const timeout = 100 * time.Millisecond - ds := newOutgoingDataStreamForTest(fake) + ds := session.NewOutgoingSubgroupStream(fake) ds = ds.WithDeliveryTimeouts(message.DeliveryTimeouts{Subgroup: timeout}, message.DeliveryTimeouts{}) if _, err := ds.Write([]byte("payload")); err != nil { @@ -509,17 +471,15 @@ func TestSubgroupDeliveryTimeoutNoReset(t *testing.T) { } } -// TestSubgroupDeliveryTimeoutNotEnforcedWithoutDeliverySignal pins the -// documented gap: a transport that cannot report delivery (quic-go and -// webtransport-go today) gets no SUBGROUP_DELIVERY_TIMEOUT reset. Resetting on -// the timer alone would reset streams the peer already has in full, and a -// peer that has not read them yet would drop that data. +// TestSubgroupDeliveryTimeoutNotEnforcedWithoutDeliverySignal: without a delivery +// signal (quic-go, webtransport-go) there is no SUBGROUP_DELIVERY_TIMEOUT reset, +// which could otherwise drop data the peer already has. func TestSubgroupDeliveryTimeoutNotEnforcedWithoutDeliverySignal(t *testing.T) { t.Parallel() fake := newFakeSendStream() const timeout = 20 * time.Millisecond - ds := newOutgoingDataStreamForTest(fake) + ds := session.NewOutgoingSubgroupStream(fake) ds = ds.WithDeliveryTimeouts(message.DeliveryTimeouts{Subgroup: timeout}, message.DeliveryTimeouts{}) if _, err := ds.Write([]byte("payload")); err != nil { @@ -543,7 +503,7 @@ func TestSubgroupDeliveryTimeoutDisabled(t *testing.T) { t.Parallel() fake := newFinishingSendStream() - ds := newOutgoingDataStreamForTest(fake) + ds := session.NewOutgoingSubgroupStream(fake) // No subgroup timeout. ds = ds.WithDeliveryTimeouts(message.DeliveryTimeouts{}, message.DeliveryTimeouts{}) @@ -634,6 +594,7 @@ type fakeSendStream struct { mu sync.Mutex } +// newFakeSendStream returns an empty fakeSendStream. func newFakeSendStream() *fakeSendStream { ctx, cancel := context.WithCancel(context.Background()) return &fakeSendStream{buf: &bytes.Buffer{}, ctx: ctx, cancel: cancel} @@ -677,19 +638,9 @@ type finishingSendStream struct { finished chan struct{} } +// newFinishingSendStream returns a finishingSendStream whose peer has not acknowledged yet. func newFinishingSendStream() *finishingSendStream { return &finishingSendStream{fakeSendStream: newFakeSendStream(), finished: make(chan struct{})} } func (f *finishingSendStream) Finished() <-chan struct{} { return f.finished } - -// newOutgoingDataStreamForTest constructs an OutgoingSubgroupStream backed by -// the given SendStream. This bypasses the session layer so we can unit-test -// the timeout logic in isolation. -// -// It uses the exported session.NewOutgoingSubgroupStream constructor (see -// export_test.go). The timeout tests exercise Write (raw bytes) rather than -// WriteObject, so no header fields are needed. -func newOutgoingDataStreamForTest(dst session.SendStream) *session.OutgoingSubgroupStream { - return session.NewOutgoingSubgroupStream(dst) -} diff --git a/pkg/moqt/session/demux_test.go b/pkg/moqt/session/demux_test.go index c5eddb92..bfded2ed 100644 --- a/pkg/moqt/session/demux_test.go +++ b/pkg/moqt/session/demux_test.go @@ -172,16 +172,9 @@ func collectEvents[T any](t *testing.T, ch <-chan T, n int) []T { return out } -// TestDemuxParksStreamsUntilAliasKnown pins the reordering allowance in -// §11.4.2: "if an endpoint receives a subgroup with an unknown Track Alias, it -// MAY abandon the stream, or choose to buffer it for a brief period to handle -// reordering with the control message that establishes the Track Alias". -// -// Demux buffers. It has to: a subscriber learns a track's alias from its -// SUBSCRIBE_OK, and a publisher may push the track's first subgroup streams -// before that reply has been read, so the opening Groups of a live broadcast -// routinely land with no handler registered. Abandoning them loses media that -// was delivered perfectly well. +// TestDemuxParksStreamsUntilAliasKnown: a subgroup for an unknown Track Alias is +// buffered until its alias is registered (§11.4.2 MAY buffer), since the first +// Groups often arrive before SUBSCRIBE_OK is read. func TestDemuxParksStreamsUntilAliasKnown(t *testing.T) { t.Parallel() pub, sub := openPair(t) @@ -237,12 +230,8 @@ func TestDemuxParksStreamsUntilAliasKnown(t *testing.T) { // rather than exported: the bound is an implementation choice, not API. const parkLimitForTest = 8 -// TestDemuxParkingIsBounded covers what stops parked streams accumulating. -// -// A parked stream is header-parsed and then left unread, so its body sits in -// the transport's receive buffers holding connection-level flow control. §11.4.2 -// allows buffering "for a brief period" and abandoning otherwise; these bounds -// are how Demux abandons. +// TestDemuxParkingIsBounded: parked streams hold flow control, so parking is +// bounded and the rest are abandoned (§11.4.2: buffer "for a brief period"). func TestDemuxParkingIsBounded(t *testing.T) { t.Parallel() diff --git a/pkg/moqt/session/early_data_stream_test.go b/pkg/moqt/session/early_data_stream_test.go deleted file mode 100644 index 9703d1c4..00000000 --- a/pkg/moqt/session/early_data_stream_test.go +++ /dev/null @@ -1,258 +0,0 @@ -package session_test - -import ( - "context" - "errors" - "sync" - "testing" - "time" - - "github.com/floatdrop/moq-go/pkg/moqt" - "github.com/floatdrop/moq-go/pkg/moqt/message" - "github.com/floatdrop/moq-go/pkg/moqt/session" - "github.com/floatdrop/moq-go/pkg/moqt/session/sessiontest" - "github.com/floatdrop/moq-go/pkg/moqt/wire" -) - -// §3.3: "Unidirectional streams containing Objects or bidirectional -// stream(s) beginning with a request message could arrive prior to the -// control streams, in which case the data SHOULD be buffered until both -// control streams arrive and setup is complete." - -// TestDataStreamBeforeControlStream: a subgroup stream the peer opened before -// its control stream does not fail the handshake; it is delivered, intact, -// once the session is up. -func TestDataStreamBeforeControlStream(t *testing.T) { - ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) - defer cancel() - clientConn, serverConn := sessiontest.NewConnPair() - - var wg sync.WaitGroup - defer wg.Wait() - wg.Go(func() { - data, err := clientConn.OpenUniStream() - if err != nil { - t.Errorf("OpenUniStream(data): %v", err) - return - } - // Written in the background: the pipe is unbuffered and the server - // holds the stream unread until its session is up. - wg.Go(func() { - hdr := message.SubgroupHeader{TrackAlias: 5, GroupID: 3, SubgroupIDMode: message.SubgroupIDExplicit} - if err := message.WriteSubgroupHeader(data, hdr); err != nil { - return - } - var w wire.Writer - (&message.SubgroupObject{ObjectIDDelta: 0, Payload: []byte("early")}).Append(&w, false) - _, _ = data.Write(w.Bytes()) - _ = data.Close() - }) - time.Sleep(20 * time.Millisecond) // the data stream is accepted first - ctrl, err := clientConn.OpenUniStream() - if err != nil { - t.Errorf("OpenUniStream(control): %v", err) - return - } - if err := message.Marshal(ctrl, &message.Setup{}); err != nil { - t.Errorf("SETUP: %v", err) - return - } - if recv, err := clientConn.AcceptUniStream(ctx); err == nil { - _, _ = message.Parse(recv) - } - }) - - srv, err := session.Server(ctx, serverConn) - if err != nil { - t.Fatalf("server handshake with a data stream first: %v", err) - } - defer srv.Close(moqt.SessionNoError, "test cleanup") - ds, err := srv.AcceptDataStream(ctx) - if err != nil { - t.Fatalf("AcceptDataStream: %v", err) - } - sg, ok := ds.(*session.IncomingSubgroupStream) - if !ok || sg.Header.TrackAlias != 5 || sg.Header.GroupID != 3 { - t.Fatalf("early stream = %T %+v, want the subgroup for alias 5 group 3", ds, ds) - } - obj, err := sg.ReadObject() - if err != nil || string(obj.Payload) != "early" { - t.Fatalf("early Object = %+v, %v; want payload \"early\"", obj, err) - } -} - -// TestEarlyDataStreamsBounded: the handshake holds at most 32 early data -// streams; one more is refused (its writer sees the stream stopped), and the -// 32 held ones are delivered once the session is up. -func TestEarlyDataStreamsBounded(t *testing.T) { - const held = 32 - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - clientConn, serverConn := sessiontest.NewConnPair() - - type result struct { - sess *session.Session - err error - } - srvCh := make(chan result, 1) - go func() { - s, err := session.Server(ctx, serverConn) - srvCh <- result{s, err} - }() - // openUni retries while the pipe's small accept queue is full. - openUni := func() session.SendStream { - for { - st, err := clientConn.OpenUniStream() - if err == nil { - return st - } - if !errors.Is(err, session.ErrNoStreamCredit) || ctx.Err() != nil { - t.Fatalf("OpenUniStream: %v", err) - } - time.Sleep(time.Millisecond) - } - } - - refused := make(chan struct{}, held+1) - var wg sync.WaitGroup - defer wg.Wait() - for i := range held + 1 { - data := openUni() - wg.Go(func() { - hdr := message.SubgroupHeader{TrackAlias: uint64(i), SubgroupIDMode: message.SubgroupIDExplicit} - if message.WriteSubgroupHeader(data, hdr) != nil { - refused <- struct{}{} - return - } - _ = data.Close() - }) - } - select { - case <-refused: - case <-ctx.Done(): - t.Fatal("the 33rd early data stream was not refused") - } - - ctrl := openUni() - wg.Go(func() { - _ = message.Marshal(ctrl, &message.Setup{}) - if recv, err := clientConn.AcceptUniStream(ctx); err == nil { - _, _ = message.Parse(recv) - } - }) - r := <-srvCh - if r.err != nil { - t.Fatalf("Server: %v", r.err) - } - defer r.sess.Close(moqt.SessionNoError, "test cleanup") - for i := range held { - if _, err := r.sess.AcceptDataStream(ctx); err != nil { - t.Fatalf("AcceptDataStream #%d: %v", i, err) - } - } -} - -// TestHandshakeSkipsPaddingAndAbortedStreams: before the control stream, -// a padding stream is discarded, not held (§11.5.1: "The receiver MUST -// discard all data received on a padding stream"), and a stream reset before -// its type arrived is skipped as it would be after setup, rather than failing -// the handshake. -func TestHandshakeSkipsPaddingAndAbortedStreams(t *testing.T) { - ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) - defer cancel() - clientConn, serverConn := sessiontest.NewConnPair() - - var wg sync.WaitGroup - defer wg.Wait() - wg.Go(func() { - aborted, err := clientConn.OpenUniStream() - if err != nil { - return - } - aborted.CancelWrite(uint64(moqt.StreamResetCancelled)) // reset before any byte - padding, err := clientConn.OpenUniStream() - if err != nil { - return - } - wg.Go(func() { - _, _ = padding.Write(wire.AppendVarint(nil, message.PaddingStreamType)) - _, _ = padding.Write(make([]byte, 64)) - _ = padding.Close() - }) - data, err := clientConn.OpenUniStream() - if err != nil { - return - } - wg.Go(func() { - _ = message.WriteSubgroupHeader(data, message.SubgroupHeader{ - TrackAlias: 5, SubgroupIDMode: message.SubgroupIDExplicit, - }) - _ = data.Close() - }) - time.Sleep(20 * time.Millisecond) - ctrl, err := clientConn.OpenUniStream() - if err != nil { - return - } - _ = message.Marshal(ctrl, &message.Setup{}) - if recv, err := clientConn.AcceptUniStream(ctx); err == nil { - _, _ = message.Parse(recv) - } - }) - - srv, err := session.Server(ctx, serverConn) - if err != nil { - t.Fatalf("handshake with an aborted and a padding stream first: %v", err) - } - defer srv.Close(moqt.SessionNoError, "test cleanup") - ds, err := srv.AcceptDataStream(ctx) - if err != nil { - t.Fatalf("AcceptDataStream = %v, want the subgroup stream (padding discarded)", err) - } - if sg, ok := ds.(*session.IncomingSubgroupStream); !ok || sg.Header.TrackAlias != 5 { - t.Fatalf("AcceptDataStream = %T, want the subgroup for alias 5", ds) - } -} - -// TestHandshakeFailsOnStreamFINedBeforeType: a uni stream FINed before a whole -// type varint is no valid stream of any kind; if it was the control stream, -// closing it "results in the session being closed as a PROTOCOL_VIOLATION" -// (§3.3). Only a stream reset before its type is skipped (see -// TestHandshakeSkipsPaddingAndAbortedStreams). -func TestHandshakeFailsOnStreamFINedBeforeType(t *testing.T) { - for name, prefix := range map[string][]byte{ - "empty": nil, - "partial varint": {0x80}, // announces a 2-byte varint, then FIN - } { - t.Run(name, func(t *testing.T) { - ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) - defer cancel() - clientConn, serverConn := sessiontest.NewConnPair() - rec := &closeRecorder{Conn: serverConn, code: make(chan uint64, 1)} - go func() { - st, err := clientConn.OpenUniStream() - if err != nil { - return - } - _, _ = st.Write(prefix) - _ = st.Close() - }() - sess, err := session.Server(ctx, rec) - if err == nil { - _ = sess.Close(moqt.SessionNoError, "test cleanup") - t.Fatal("handshake succeeded past a stream FINed before its type") - } - if errors.Is(err, context.DeadlineExceeded) { - t.Fatal("the handshake waited out its deadline; want it to fail on the FINed stream") - } - select { - case code := <-rec.code: - if code != uint64(moqt.SessionProtocolViolation) { - t.Fatalf("closed with %#x, want PROTOCOL_VIOLATION", code) - } - default: - t.Fatalf("handshake failed (%v) without closing the conn", err) - } - }) - } -} diff --git a/pkg/moqt/session/fetch_test.go b/pkg/moqt/session/fetch_test.go index 9ad57dca..d30bd813 100644 --- a/pkg/moqt/session/fetch_test.go +++ b/pkg/moqt/session/fetch_test.go @@ -208,3 +208,80 @@ func TestFetchRejected(t *testing.T) { wg.Wait() } + +// --------------------------------------------------------------------------- +// FETCH responses that close the session +// --------------------------------------------------------------------------- + +// TestFetchOKEndBeforeStartClosesSession: an End Location before the FETCH's +// Start closes the session with PROTOCOL_VIOLATION (§10.14); End == Start is a +// one-Object range. A Start relative to the Largest Object is comparable too, +// since a FETCH's End defaults to it (§5.1.2) and FETCH_OK's End never passes +// it (§10.14): the Next Object and relative StartGroup 0 start past it, except +// when End is {0, 0} ("no content yet"). +func TestFetchOKEndBeforeStartClosesSession(t *testing.T) { + t.Parallel() + absolute := message.LocationFilter{Fields: 2, StartGroup: 5, StartObject: 2} + cases := []struct { + name string + filter message.LocationFilter + end message.Location + closes bool + }{ + {"End in an earlier Group", absolute, message.Location{Group: 4, Object: 9}, true}, + {"End earlier in the Start Group", absolute, message.Location{Group: 5, Object: 1}, true}, + {"End at the Start", absolute, message.Location{Group: 5, Object: 2}, false}, + {"Next Object", message.LocationFilter{Fields: 2}, message.Location{Group: 3, Object: 4}, true}, + {"Next Object, End {0,0}", message.LocationFilter{Fields: 2}, message.Location{}, false}, + {"relative StartGroup 0", message.LocationFilter{Fields: 1}, message.Location{Group: 2}, true}, + {"relative StartGroup 2", message.LocationFilter{Fields: 1, StartGroup: 2}, message.Location{Group: 2}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + client, server := openPair(t) + answerWith(t, server, &message.FetchOK{EndLocation: tc.end}) + _, err := client.Fetch(t.Context(), &message.Fetch{ + Namespace: videoNS, Name: []byte("t"), + Parameters: message.Parameters{message.LocationFilterParam(&tc.filter)}, + }) + if !tc.closes { + if err != nil { + t.Fatalf("Fetch: %v", err) + } + return + } + if err == nil { + t.Fatal("Fetch accepted a FETCH_OK whose End Location precedes the Start") + } + requireClosedProtocolViolation(t, client) + }) + } +} + +// TestFetchObjectInvalidFlagsCloseSession: Serialization Flags of 128 and +// above other than the End of Range values are a PROTOCOL_VIOLATION (§11.4.4). +func TestFetchObjectInvalidFlagsCloseSession(t *testing.T) { + t.Parallel() + client, server := openPair(t) + go func() { + out, err := server.OpenFetchStream(message.FetchHeader{RequestID: 0}) + if err != nil { + return + } + _ = out.WriteObject(&message.FetchObject{SerializationFlags: 0x81, ObjectPayload: []byte("x")}) + _ = out.Close() + }() + ds, err := client.AcceptDataStream(t.Context()) + if err != nil { + t.Fatalf("AcceptDataStream: %v", err) + } + fs, ok := ds.(*session.IncomingFetchStream) + if !ok { + t.Fatalf("AcceptDataStream = %T, want a FETCH stream", ds) + } + if _, err := fs.ReadObject(); err == nil { + t.Fatal("ReadObject accepted Serialization Flags 0x81") + } + requireClosedProtocolViolation(t, client) +} diff --git a/pkg/moqt/session/fetch_violations_test.go b/pkg/moqt/session/fetch_violations_test.go deleted file mode 100644 index 6083190c..00000000 --- a/pkg/moqt/session/fetch_violations_test.go +++ /dev/null @@ -1,122 +0,0 @@ -package session_test - -import ( - "testing" - - "github.com/floatdrop/moq-go/pkg/moqt/message" - "github.com/floatdrop/moq-go/pkg/moqt/session" -) - -// TestFetchOKEndBeforeStartClosesSession: §10.14 "If End Location is smaller -// than the Start Location in the corresponding FETCH the receiver MUST close -// the session with a PROTOCOL_VIOLATION." An End equal to the Start is a -// one-Object range and stays open. -func TestFetchOKEndBeforeStartClosesSession(t *testing.T) { - t.Parallel() - start := message.Location{Group: 5, Object: 2} - cases := []struct { - name string - end message.Location - closes bool - }{ - {"End in an earlier Group", message.Location{Group: 4, Object: 9}, true}, - {"End earlier in the Start Group", message.Location{Group: 5, Object: 1}, true}, - {"End at the Start", start, false}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - client, server := openPair(t) - answerWith(t, server, &message.FetchOK{EndLocation: tc.end}) - _, err := client.Fetch(t.Context(), &message.Fetch{ - Namespace: videoNS, Name: []byte("t"), - Parameters: message.Parameters{message.LocationFilterParam(&message.LocationFilter{ - Fields: 2, StartGroup: start.Group, StartObject: start.Object, - })}, - }) - if !tc.closes { - if err != nil { - t.Fatalf("Fetch: %v", err) - } - return - } - if err == nil { - t.Fatal("Fetch accepted a FETCH_OK whose End Location precedes the Start") - } - requireClosedProtocolViolation(t, client) - }) - } -} - -// TestFetchObjectInvalidFlagsCloseSession: §11.4.4 defines the Serialization -// Flags values of 128 and above that mark an End of Range, and "Any other value -// is a PROTOCOL_VIOLATION" closes the session. -func TestFetchObjectInvalidFlagsCloseSession(t *testing.T) { - t.Parallel() - client, server := openPair(t) - go func() { - out, err := server.OpenFetchStream(message.FetchHeader{RequestID: 0}) - if err != nil { - return - } - _ = out.WriteObject(&message.FetchObject{SerializationFlags: 0x81, ObjectPayload: []byte("x")}) - _ = out.Close() - }() - ds, err := client.AcceptDataStream(t.Context()) - if err != nil { - t.Fatalf("AcceptDataStream: %v", err) - } - fs, ok := ds.(*session.IncomingFetchStream) - if !ok { - t.Fatalf("AcceptDataStream = %T, want a FETCH stream", ds) - } - if _, err := fs.ReadObject(); err == nil { - t.Fatal("ReadObject accepted Serialization Flags 0x81") - } - requireClosedProtocolViolation(t, client) -} - -// TestFetchOKEndBeforeRelativeStartClosesSession: a Start relative to the -// Largest Object is still comparable, because a FETCH without an End Location -// ends at the Largest Object (§5.1.2) and FETCH_OK's End never goes beyond it -// (§10.14). The Next Object ({Largest.Group, Largest.Object + 1}) and a -// relative StartGroup of 0 ({Largest.Group + 1, 0}) start past it, so a -// FETCH_OK for either has End < Start. The one exception is an End of {0, 0}, -// which cannot be told apart from "no content yet", where both Starts are -// {0, 0} too; that stays open. A relative StartGroup of 1 or more starts at or -// before the Largest Object. -func TestFetchOKEndBeforeRelativeStartClosesSession(t *testing.T) { - t.Parallel() - cases := []struct { - name string - filter message.LocationFilter - end message.Location - closes bool - }{ - {"Next Object", message.LocationFilter{Fields: 2}, message.Location{Group: 3, Object: 4}, true}, - {"Next Object, End {0,0}", message.LocationFilter{Fields: 2}, message.Location{}, false}, - {"relative StartGroup 0", message.LocationFilter{Fields: 1}, message.Location{Group: 2}, true}, - {"relative StartGroup 2", message.LocationFilter{Fields: 1, StartGroup: 2}, message.Location{Group: 2}, false}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - client, server := openPair(t) - answerWith(t, server, &message.FetchOK{EndLocation: tc.end}) - _, err := client.Fetch(t.Context(), &message.Fetch{ - Namespace: videoNS, Name: []byte("t"), - Parameters: message.Parameters{message.LocationFilterParam(&tc.filter)}, - }) - if !tc.closes { - if err != nil { - t.Fatalf("Fetch: %v", err) - } - return - } - if err == nil { - t.Fatal("Fetch accepted a FETCH_OK whose End Location precedes the Start") - } - requireClosedProtocolViolation(t, client) - }) - } -} diff --git a/pkg/moqt/session/followup_role_test.go b/pkg/moqt/session/followup_role_test.go deleted file mode 100644 index e5d8ab5e..00000000 --- a/pkg/moqt/session/followup_role_test.go +++ /dev/null @@ -1,114 +0,0 @@ -package session_test - -import ( - "testing" - "time" - - "github.com/floatdrop/moq-go/pkg/moqt/message" - "github.com/floatdrop/moq-go/pkg/moqt/session" -) - -// §10.9: REQUEST_UPDATE comes from "The sender of a request" or from "A -// subscriber ... of a subscription established with PUBLISH"; "An endpoint -// that receives a REQUEST_UPDATE other than in the two cases above MUST close -// the session with a PROTOCOL_VIOLATION." §10.10: PUBLISH_STATE_NOTIFY "applies -// only to subscriptions, and is sent only by the publisher. An endpoint that -// receives a PUBLISH_STATE_NOTIFY for any other request type, or from the -// subscriber, MUST close the session with a PROTOCOL_VIOLATION." - -func TestFollowupRolesEnforcedByBroker(t *testing.T) { - // Each setup serves one side's broker and returns that side's session - // and the peer's end of the stream, which the test writes to. - subscribe := func(t *testing.T, c, s *session.Session) (*session.Subscription, *session.Publication) { - t.Helper() - pubs := make(chan *session.Publication, 1) - go func() { - r, err := s.AcceptRequest(t.Context()) - if err != nil { - return - } - p, _ := r.AcceptSubscribe(nil) - pubs <- p - }() - sub, err := c.Subscribe(t.Context(), &message.Subscribe{Name: []byte("t")}) - must(t, err) - return sub, <-pubs - } - onSubscriber := func(t *testing.T, c, s *session.Session) (*session.Session, session.Stream) { - sub, pub := subscribe(t, c, s) - b := sub.Broker() - go func() { _ = b.Serve(t.Context(), nil) }() - return c, pub.Stream // the publisher writes; the subscriber's broker reads - } - onPublisher := func(t *testing.T, c, s *session.Session) (*session.Session, session.Stream) { - sub, pub := subscribe(t, c, s) - b := pub.Broker() - go func() { _ = b.Serve(t.Context(), nil) }() - return s, sub.Stream - } - onFetchRequester := func(t *testing.T, c, s *session.Session) (*session.Session, session.Stream) { - peer := acceptWith(t, s, func(r *session.Request) (session.Stream, error) { - _, err := r.AcceptFetch(nil) - return r.Stream, err - }) - fr, err := c.Fetch(t.Context(), &message.Fetch{Name: []byte("t")}) - must(t, err) - b := fr.Broker() - go func() { _ = b.Serve(t.Context(), nil) }() - return c, <-peer - } - onPublishReceiver := func(t *testing.T, c, s *session.Session) (*session.Session, session.Stream) { - incs := make(chan *session.IncomingPublication, 1) - go func() { - r, err := s.AcceptRequest(t.Context()) - if err != nil { - return - } - p, _ := r.AcceptPublish() - incs <- p - }() - pub, err := c.Publish(t.Context(), &message.Publish{Name: []byte("t")}) - must(t, err) - inc := <-incs - b := inc.Broker() - go func() { _ = b.Serve(t.Context(), nil) }() - return s, pub.Stream - } - update := func(c *session.Session) message.Message { return &message.RequestUpdate{RequestID: c.AllocRequestID()} } - - for _, tc := range []struct { - name string - serve func(t *testing.T, c, s *session.Session) (*session.Session, session.Stream) - msg func(sender *session.Session) message.Message - closes bool - }{ - {"REQUEST_UPDATE from a SUBSCRIBE's publisher", onSubscriber, update, true}, - {"PUBLISH_STATE_NOTIFY from a SUBSCRIBE's subscriber", onPublisher, - func(*session.Session) message.Message { return &message.PublishStateNotify{} }, true}, - {"REQUEST_UPDATE from a FETCH responder", onFetchRequester, update, true}, - {"PUBLISH_STATE_NOTIFY on a FETCH", onFetchRequester, - func(*session.Session) message.Message { return &message.PublishStateNotify{} }, true}, - {"PUBLISH_STATE_NOTIFY from a SUBSCRIBE's publisher (allowed)", onSubscriber, - func(*session.Session) message.Message { return &message.PublishStateNotify{} }, false}, - {"REQUEST_UPDATE from a PUBLISH's sender (allowed)", onPublishReceiver, update, false}, - } { - t.Run(tc.name, func(t *testing.T) { - client, server := openPair(t) - served, peer := tc.serve(t, client, server) - sender := client - if served == client { - sender = server - } - go func() { _ = message.Marshal(peer, tc.msg(sender)) }() - if tc.closes { - requireClosedProtocolViolation(t, served) - return - } - select { - case <-served.Done(): - t.Fatalf("session closed on an allowed follow-up: %v", served.Err()) - case <-time.After(200 * time.Millisecond): - } - }) - } -} diff --git a/pkg/moqt/session/handshake_test.go b/pkg/moqt/session/handshake_test.go new file mode 100644 index 00000000..7431fc7b --- /dev/null +++ b/pkg/moqt/session/handshake_test.go @@ -0,0 +1,463 @@ +package session_test + +import ( + "context" + "errors" + "slices" + "strings" + "sync" + "testing" + "time" + + "github.com/floatdrop/moq-go/pkg/moqt" + "github.com/floatdrop/moq-go/pkg/moqt/message" + "github.com/floatdrop/moq-go/pkg/moqt/session" + "github.com/floatdrop/moq-go/pkg/moqt/session/sessiontest" + "github.com/floatdrop/moq-go/pkg/moqt/wire" +) + +func TestHandshakeExchangesPeerOptions(t *testing.T) { + client, server := openPair(t) + + clientSawServer := client.PeerOptions() + if len(clientSawServer) != 1 || string(clientSawServer[0].ByteVal) != "mediamesh-test/server" { + t.Fatalf("client received wrong peer options: %+v", clientSawServer) + } + serverSawClient := server.PeerOptions() + if len(serverSawClient) != 1 || string(serverSawClient[0].ByteVal) != "mediamesh-test/client" { + t.Fatalf("server received wrong peer options: %+v", serverSawClient) + } +} + +// TestGreaseRoundTrip: a GREASE SETUP option from WithGrease reaches the peer, +// which ignores it (§14: unknown SETUP option types MUST be ignored). +func TestGreaseRoundTrip(t *testing.T) { + aSess, bSess := openPairWithOpts(t, + []session.Option{session.WithImplementation("grease-test/client"), session.WithGrease()}, + []session.Option{session.WithImplementation("grease-test/server"), session.WithGrease()}, + ) + + assertHasGrease := func(name string, opts []wire.KVPair) { + t.Helper() + isGrease := func(kv wire.KVPair) bool { return kv.Type >= 0x9D && (kv.Type-0x9D)%0x7F == 0 } + if !slices.ContainsFunc(opts, isGrease) { + t.Errorf("%s: no GREASE option in PeerOptions %+v", name, opts) + } + } + assertHasGrease("server saw client GREASE", bSess.PeerOptions()) + assertHasGrease("client saw server GREASE", aSess.PeerOptions()) +} + +// failOpenConn fails OpenUniStream, while AcceptUniStream blocks forever. +type failOpenConn struct{ session.Conn } + +func (c *failOpenConn) OpenUniStream() (session.SendStream, error) { + return nil, errors.New("synthetic open failure") +} + +// TestHandshakeFailFastCancelsSibling: when one half of the handshake fails, +// the other returns promptly instead of waiting on a stream that never opens. +func TestHandshakeFailFastCancelsSibling(t *testing.T) { + a, _ := sessiontest.NewConnPair() // b is unused so AcceptUniStream blocks + conn := &failOpenConn{Conn: a} + + done := make(chan error, 1) + go func() { + _, err := session.Client(t.Context(), conn) + done <- err + }() + + select { + case err := <-done: + if err == nil { + t.Fatal("expected handshake error, got nil") + } + case <-time.After(500 * time.Millisecond): + t.Fatal("handshake hung; errgroup did not cancel sibling") + } +} + +// --------------------------------------------------------------------------- +// PATH and AUTHORITY setup options (§10.3.1.1, §10.3.1.2) +// --------------------------------------------------------------------------- + +// pathAndAuthority holds one well-formed PATH and AUTHORITY option each. +var pathAndAuthority = []struct { + name string + opt wire.KVPair +}{ + {"PATH", message.PathOption("/relay")}, + {"AUTHORITY", message.AuthorityOption("relay.example:4433")}, +} + +// webTransportConn makes a sessiontest conn report itself as WebTransport. +type webTransportConn struct{ session.Conn } + +func (webTransportConn) IsWebTransport() bool { return true } + +// TestClientSendsPathAndAuthority: WithPath and WithAuthority arrive under +// their §15.4 codepoints (a wrong key would be silently ignored, §10.3). +func TestClientSendsPathAndAuthority(t *testing.T) { + _, serverSess := openPairWithOpts(t, + []session.Option{session.WithPath("/relay?room=1"), session.WithAuthority("relay.example:4433")}, + nil, + ) + + want := map[uint64]string{ + uint64(message.SetupOptionPath): "/relay?room=1", + uint64(message.SetupOptionAuthority): "relay.example:4433", + } + got := make(map[uint64]string) + for _, opt := range serverSess.PeerOptions() { + got[opt.Type] = string(opt.ByteVal) + } + for typ, val := range want { + if got[typ] != val { + t.Errorf("server saw option 0x%02X = %q, want %q (all: %+v)", + typ, got[typ], val, serverSess.PeerOptions()) + } + } +} + +// TestClientRejectsServerSentPathAndAuthority: PATH and AUTHORITY MUST NOT +// come from a server, and a client receiving one closes the session +// (§10.3.1.1, §10.3.1.2). The server is hand-rolled: a moq-go one refuses. +func TestClientRejectsServerSentPathAndAuthority(t *testing.T) { + for _, tt := range pathAndAuthority { + t.Run(tt.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + t.Cleanup(cancel) + clientConn, serverConn := sessiontest.NewConnPair() + + var wg sync.WaitGroup + wg.Go(func() { handRolledSetup(ctx, t, serverConn, tt.opt) }) + clientSess, clientErr := session.Client(ctx, clientConn) + wg.Wait() + + if clientErr == nil { + _ = clientSess.Close(moqt.SessionNoError, "test cleanup") + t.Fatalf("client accepted a server-sent %s option; want the session refused", tt.name) + } + if clientSess != nil { + t.Errorf("client returned a session alongside the error: %+v", clientSess) + } + // The reason travels to the peer, so it names the option. + if !strings.Contains(clientErr.Error(), tt.name) { + t.Errorf("error %q does not name the %s option", clientErr, tt.name) + } + }) + } +} + +// TestServerRejectsPathOrAuthorityOverWebTransport: either option "received +// while WebTransport is used" closes the session (§10.3.1.1, §10.3.1.2). +func TestServerRejectsPathOrAuthorityOverWebTransport(t *testing.T) { + for _, tt := range pathAndAuthority { + t.Run(tt.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + t.Cleanup(cancel) + clientConn, serverConn := sessiontest.NewConnPair() + + var wg sync.WaitGroup + wg.Go(func() { handRolledSetup(ctx, t, clientConn, tt.opt) }) + sess, err := session.Server(ctx, webTransportConn{serverConn}) + wg.Wait() + + if err == nil { + _ = sess.Close(moqt.SessionNoError, "test cleanup") + t.Fatalf( + "server accepted a %s option over WebTransport; §10.3.1 requires the session be closed", + tt.name) + } + if !strings.Contains(err.Error(), "WebTransport") { + t.Errorf("error %q does not explain the WebTransport restriction", err) + } + }) + } +} + +// TestRefusesToSendPathOrAuthority: the send side of §10.3.1.1/§10.3.1.2. A +// server, a WebTransport client, or a value that is not RFC 3986 (one +// net/url lets through) fails the open instead of dropping the option. +func TestRefusesToSendPathOrAuthority(t *testing.T) { + asServer := func(opt session.Option) func(context.Context) (*session.Session, error) { + return func(ctx context.Context) (*session.Session, error) { + _, serverConn := sessiontest.NewConnPair() + return session.Server(ctx, serverConn, opt) + } + } + asClient := func(wrap func(session.Conn) session.Conn, opt session.Option) func(context.Context) (*session.Session, error) { + return func(ctx context.Context) (*session.Session, error) { + clientConn, _ := sessiontest.NewConnPair() + return session.Client(ctx, wrap(clientConn), opt) + } + } + webTransport := func(c session.Conn) session.Conn { return webTransportConn{c} } + native := func(c session.Conn) session.Conn { return c } + + for _, tc := range []struct { + name string + want string // what the error must mention + open func(context.Context) (*session.Session, error) + }{ + {"PATH/server", "PATH", asServer(session.WithPath("/relay"))}, + {"PATH/webtransport", "WebTransport", asClient(webTransport, session.WithPath("/relay"))}, + {"PATH/malformed", "PATH", asClient(native, session.WithPath("/room?tags=[a,b]"))}, + {"AUTHORITY/server", "AUTHORITY", asServer(session.WithAuthority("relay.example:4433"))}, + {"AUTHORITY/webtransport", "WebTransport", asClient(webTransport, session.WithAuthority("relay.example:4433"))}, + {"AUTHORITY/malformed", "AUTHORITY", asClient(native, session.WithAuthority("[fe80::1%en0]:4433"))}, + } { + t.Run(tc.name, func(t *testing.T) { requireRefusedOpen(t, tc.want, tc.open) }) + } +} + +// TestServerClosesMalformedPathOrAuthority: a value that is not RFC 3986 +// closes the session with MALFORMED_PATH / MALFORMED_AUTHORITY +// (§10.3.1.1, §10.3.1.2); a well-formed pair still opens. +func TestServerClosesMalformedPathOrAuthority(t *testing.T) { + for _, tc := range []struct { + name string + opts []wire.KVPair + want moqt.SessionErrorCode // SessionNoError: the session opens + }{ + {"well-formed", []wire.KVPair{ + message.AuthorityOption("relay.example:4433"), message.PathOption("/relay?room=1"), + }, moqt.SessionNoError}, + {"AUTHORITY", []wire.KVPair{message.AuthorityOption("relay example")}, moqt.SessionMalformedAuthority}, + {"AUTHORITY empty host", []wire.KVPair{message.AuthorityOption(":4433")}, moqt.SessionMalformedAuthority}, + {"PATH", []wire.KVPair{message.PathOption("relay")}, moqt.SessionMalformedPath}, + } { + t.Run(tc.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + t.Cleanup(cancel) + clientConn, serverConn := sessiontest.NewConnPair() + rec := newCloseRecorder(serverConn) + + var wg sync.WaitGroup + wg.Go(func() { handRolledSetup(ctx, t, clientConn, tc.opts...) }) + sess, err := session.Server(ctx, rec) + wg.Wait() + if tc.want == moqt.SessionNoError { + if err != nil { + t.Fatalf("server refused well-formed PATH/AUTHORITY: %v", err) + } + _ = sess.Close(moqt.SessionNoError, "test cleanup") + return + } + if err == nil { + _ = sess.Close(moqt.SessionNoError, "test cleanup") + t.Fatalf("server accepted a malformed %s", tc.name) + } + requireClosedAlready(t, rec, tc.want, err) + }) + } +} + +// --------------------------------------------------------------------------- +// Streams that arrive before the control stream (§3.3: SHOULD be buffered) +// --------------------------------------------------------------------------- + +// TestDataStreamBeforeControlStream: a subgroup stream opened before the +// control stream is delivered intact once the session is up. +func TestDataStreamBeforeControlStream(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) + defer cancel() + clientConn, serverConn := sessiontest.NewConnPair() + + var wg sync.WaitGroup + defer wg.Wait() + wg.Go(func() { + data, err := clientConn.OpenUniStream() + if err != nil { + t.Errorf("OpenUniStream(data): %v", err) + return + } + // In the background: the server holds the stream unread until setup. + wg.Go(func() { + hdr := message.SubgroupHeader{TrackAlias: 5, GroupID: 3, SubgroupIDMode: message.SubgroupIDExplicit} + if err := message.WriteSubgroupHeader(data, hdr); err != nil { + return + } + var w wire.Writer + (&message.SubgroupObject{ObjectIDDelta: 0, Payload: []byte("early")}).Append(&w, false) + _, _ = data.Write(w.Bytes()) + _ = data.Close() + }) + time.Sleep(20 * time.Millisecond) // the data stream is accepted first + handRolledSetup(ctx, t, clientConn) + }) + + srv, err := session.Server(ctx, serverConn) + if err != nil { + t.Fatalf("server handshake with a data stream first: %v", err) + } + defer srv.Close(moqt.SessionNoError, "test cleanup") + ds, err := srv.AcceptDataStream(ctx) + if err != nil { + t.Fatalf("AcceptDataStream: %v", err) + } + sg, ok := ds.(*session.IncomingSubgroupStream) + if !ok || sg.Header.TrackAlias != 5 || sg.Header.GroupID != 3 { + t.Fatalf("early stream = %T %+v, want the subgroup for alias 5 group 3", ds, ds) + } + obj, err := sg.ReadObject() + if err != nil || string(obj.Payload) != "early" { + t.Fatalf("early Object = %+v, %v; want payload \"early\"", obj, err) + } +} + +// TestEarlyDataStreamsBounded: the handshake holds at most 32 early data +// streams; the 33rd is refused, and the 32 are delivered after setup. +func TestEarlyDataStreamsBounded(t *testing.T) { + const held = 32 + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + clientConn, serverConn := sessiontest.NewConnPair() + + type result struct { + sess *session.Session + err error + } + srvCh := make(chan result, 1) + go func() { + s, err := session.Server(ctx, serverConn) + srvCh <- result{s, err} + }() + // openUni retries while the pipe's small accept queue is full. + openUni := func() session.SendStream { + for { + st, err := clientConn.OpenUniStream() + if err == nil { + return st + } + if !errors.Is(err, session.ErrNoStreamCredit) || ctx.Err() != nil { + t.Fatalf("OpenUniStream: %v", err) + } + time.Sleep(time.Millisecond) + } + } + + refused := make(chan struct{}, held+1) + var wg sync.WaitGroup + defer wg.Wait() + for i := range held + 1 { + data := openUni() + wg.Go(func() { + hdr := message.SubgroupHeader{TrackAlias: uint64(i), SubgroupIDMode: message.SubgroupIDExplicit} + if message.WriteSubgroupHeader(data, hdr) != nil { + refused <- struct{}{} + return + } + _ = data.Close() + }) + } + select { + case <-refused: + case <-ctx.Done(): + t.Fatal("the 33rd early data stream was not refused") + } + + ctrl := openUni() + wg.Go(func() { + _ = message.Marshal(ctrl, &message.Setup{}) + if recv, err := clientConn.AcceptUniStream(ctx); err == nil { + _, _ = message.Parse(recv) + } + }) + r := <-srvCh + if r.err != nil { + t.Fatalf("Server: %v", r.err) + } + defer r.sess.Close(moqt.SessionNoError, "test cleanup") + for i := range held { + if _, err := r.sess.AcceptDataStream(ctx); err != nil { + t.Fatalf("AcceptDataStream #%d: %v", i, err) + } + } +} + +// TestHandshakeSkipsPaddingAndAbortedStreams: before setup, a padding stream +// is discarded (§11.5.1) and a stream reset before its type is skipped. +func TestHandshakeSkipsPaddingAndAbortedStreams(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) + defer cancel() + clientConn, serverConn := sessiontest.NewConnPair() + + var wg sync.WaitGroup + defer wg.Wait() + wg.Go(func() { + aborted, err := clientConn.OpenUniStream() + if err != nil { + return + } + aborted.CancelWrite(uint64(moqt.StreamResetCancelled)) // reset before any byte + padding, err := clientConn.OpenUniStream() + if err != nil { + return + } + wg.Go(func() { + _, _ = padding.Write(wire.AppendVarint(nil, message.PaddingStreamType)) + _, _ = padding.Write(make([]byte, 64)) + _ = padding.Close() + }) + data, err := clientConn.OpenUniStream() + if err != nil { + return + } + wg.Go(func() { + _ = message.WriteSubgroupHeader(data, message.SubgroupHeader{ + TrackAlias: 5, SubgroupIDMode: message.SubgroupIDExplicit, + }) + _ = data.Close() + }) + time.Sleep(20 * time.Millisecond) + handRolledSetup(ctx, t, clientConn) + }) + + srv, err := session.Server(ctx, serverConn) + if err != nil { + t.Fatalf("handshake with an aborted and a padding stream first: %v", err) + } + defer srv.Close(moqt.SessionNoError, "test cleanup") + ds, err := srv.AcceptDataStream(ctx) + if err != nil { + t.Fatalf("AcceptDataStream = %v, want the subgroup stream (padding discarded)", err) + } + if sg, ok := ds.(*session.IncomingSubgroupStream); !ok || sg.Header.TrackAlias != 5 { + t.Fatalf("AcceptDataStream = %T, want the subgroup for alias 5", ds) + } +} + +// TestHandshakeFailsOnStreamFINedBeforeType: a uni stream FINed before a whole +// type varint closes the session as a PROTOCOL_VIOLATION (§3.3); only a reset +// is skipped. +func TestHandshakeFailsOnStreamFINedBeforeType(t *testing.T) { + for name, prefix := range map[string][]byte{ + "empty": nil, + "partial varint": {0x80}, // announces a 2-byte varint, then FIN + } { + t.Run(name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) + defer cancel() + clientConn, serverConn := sessiontest.NewConnPair() + rec := newCloseRecorder(serverConn) + go func() { + st, err := clientConn.OpenUniStream() + if err != nil { + return + } + _, _ = st.Write(prefix) + _ = st.Close() + }() + sess, err := session.Server(ctx, rec) + if err == nil { + _ = sess.Close(moqt.SessionNoError, "test cleanup") + t.Fatal("handshake succeeded past a stream FINed before its type") + } + if errors.Is(err, context.DeadlineExceeded) { + t.Fatal("the handshake waited out its deadline; want it to fail on the FINed stream") + } + requireClosedAlready(t, rec, moqt.SessionProtocolViolation, err) + }) + } +} diff --git a/pkg/moqt/session/helpers_test.go b/pkg/moqt/session/helpers_test.go new file mode 100644 index 00000000..e991b23c --- /dev/null +++ b/pkg/moqt/session/helpers_test.go @@ -0,0 +1,294 @@ +package session_test + +import ( + "context" + "errors" + "strings" + "sync" + "testing" + "time" + + "github.com/floatdrop/moq-go/pkg/moqt" + "github.com/floatdrop/moq-go/pkg/moqt/message" + "github.com/floatdrop/moq-go/pkg/moqt/session" + "github.com/floatdrop/moq-go/pkg/moqt/session/sessiontest" + "github.com/floatdrop/moq-go/pkg/moqt/wire" +) + +// videoNS is the track namespace tests use when the value does not matter. +var videoNS = wire.TrackNamespace{[]byte("video")} + +// openSessions runs the SETUP handshake over the given conns and closes both sessions on cleanup. +func openSessions( + t *testing.T, + clientConn, serverConn session.Conn, + clientOpts, serverOpts []session.Option, +) (client, server *session.Session) { + t.Helper() + var ( + wg sync.WaitGroup + cErr, sErr error + ) + wg.Go(func() { client, cErr = session.Client(t.Context(), clientConn, clientOpts...) }) + wg.Go(func() { server, sErr = session.Server(t.Context(), serverConn, serverOpts...) }) + wg.Wait() + if cErr != nil { + t.Fatalf("client Open: %v", cErr) + } + if sErr != nil { + t.Fatalf("server Open: %v", sErr) + } + // Close is idempotent, so tests may also close explicitly. + t.Cleanup(func() { + if err := client.Close(moqt.SessionNoError, "test cleanup"); err != nil { + t.Errorf("client cleanup Close: %v", err) + } + if err := server.Close(moqt.SessionNoError, "test cleanup"); err != nil { + t.Errorf("server cleanup Close: %v", err) + } + }) + return client, server +} + +// openPair opens a client/server pair that announce their implementations; serverOpts configure the server. +func openPair(t *testing.T, serverOpts ...session.Option) (client, server *session.Session) { + t.Helper() + clientConn, serverConn := sessiontest.NewConnPair() + return openSessions(t, clientConn, serverConn, + []session.Option{session.WithImplementation("mediamesh-test/client")}, + append([]session.Option{session.WithImplementation("mediamesh-test/server")}, serverOpts...)) +} + +// openPairWithOpts opens a client/server pair with exactly the given options on each side. +func openPairWithOpts(t *testing.T, clientOpts, serverOpts []session.Option) (client, server *session.Session) { + t.Helper() + clientConn, serverConn := sessiontest.NewConnPair() + return openSessions(t, clientConn, serverConn, clientOpts, serverOpts) +} + +// openPairWithLimits opens a pair whose client has aBidiLimit bidi-stream credit (negative: unlimited). +func openPairWithLimits(t *testing.T, aBidiLimit int) (client, server *session.Session) { + t.Helper() + clientConn, serverConn := sessiontest.NewConnPairWithLimits(aBidiLimit, -1) + return openSessions(t, clientConn, serverConn, nil, nil) +} + +// openPairWithConns is openPair that also returns the conns, for injecting raw streams and datagrams. +func openPairWithConns(t *testing.T) (cli, srv *session.Session, cliConn, srvConn session.Conn) { + t.Helper() + cliConn, srvConn = sessiontest.NewConnPair() + cli, srv = openSessions(t, cliConn, srvConn, + []session.Option{session.WithImplementation("test/client")}, + []session.Option{session.WithImplementation("test/server")}) + return cli, srv, cliConn, srvConn +} + +// handRolledSetup plays a non-moq-go peer's handshake on conn: it sends SETUP with opts and reads the +// other side's SETUP, returning its control stream (nil after reporting a failure). +func handRolledSetup(ctx context.Context, t *testing.T, conn session.Conn, opts ...wire.KVPair) session.SendStream { + t.Helper() + send, err := conn.OpenUniStream() + if err != nil { + t.Errorf("hand-rolled peer: OpenUniStream: %v", err) + return nil + } + if err := message.Marshal(send, &message.Setup{Options: opts}); err != nil { + t.Errorf("hand-rolled peer: Marshal SETUP: %v", err) + return nil + } + if recv, err := conn.AcceptUniStream(ctx); err == nil { + _, _ = message.Parse(recv) + } + return send +} + +// closeRecorder records the code its session closes the conn with, which sessiontest does not pass on. +type closeRecorder struct { + session.Conn + + code chan uint64 +} + +// newCloseRecorder wraps conn in a closeRecorder. +func newCloseRecorder(conn session.Conn) *closeRecorder { + return &closeRecorder{Conn: conn, code: make(chan uint64, 1)} +} + +func (c *closeRecorder) CloseWithError(code uint64, reason string) error { + select { + case c.code <- code: + default: + } + return c.Conn.CloseWithError(code, reason) +} + +// requireClosedWith waits up to 2s for closed to report want. +func requireClosedWith(t *testing.T, closed <-chan uint64, want moqt.SessionErrorCode) { + t.Helper() + select { + case code := <-closed: + if code != uint64(want) { + t.Fatalf("closed with code %#x, want %#x", code, uint64(want)) + } + case <-time.After(2 * time.Second): + t.Fatalf("connection stayed open; want close with %#x", uint64(want)) + } +} + +// requireClosedAlready checks that a failed open (openErr) had already closed rec with want. +func requireClosedAlready(t *testing.T, rec *closeRecorder, want moqt.SessionErrorCode, openErr error) { + t.Helper() + select { + case code := <-rec.code: + if code != uint64(want) { + t.Fatalf("closed with %#x, want %#x", code, uint64(want)) + } + default: + t.Fatalf("open failed (%v) without closing the conn", openErr) + } +} + +// requireRefusedOpen fails t unless open, given a one-second deadline, errors mentioning want. +func requireRefusedOpen(t *testing.T, want string, open func(context.Context) (*session.Session, error)) { + t.Helper() + // The refusal precedes any I/O; the deadline only bounds a regression. + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + sess, err := open(ctx) + if err == nil { + _ = sess.Close(moqt.SessionNoError, "test cleanup") + t.Fatalf("session opened; want it refused with an error mentioning %q", want) + } + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q does not mention %q", err, want) + } +} + +// requireClosedProtocolViolation waits for sess to close and checks the code. +func requireClosedProtocolViolation(t *testing.T, sess *session.Session) { + t.Helper() + select { + case <-sess.Done(): + case <-time.After(2 * time.Second): + t.Fatal("session stayed open; want PROTOCOL_VIOLATION close") + } + closed, ok := errors.AsType[*session.ClosedError](sess.Err()) + if !ok { + t.Fatalf("Err() = %v, want a *session.ClosedError", sess.Err()) + } + if closed.Code != moqt.SessionProtocolViolation { + t.Errorf("closed with code %#x, want PROTOCOL_VIOLATION (%#x)", + uint64(closed.Code), uint64(moqt.SessionProtocolViolation)) + } +} + +// requireStaysOpen fails t if sess closes within d. +func requireStaysOpen(t *testing.T, sess *session.Session, d time.Duration) { + t.Helper() + select { + case <-sess.Done(): + t.Fatalf("session closed: %v", sess.Err()) + case <-time.After(d): + } +} + +// must fails t on a non-nil err. +func must(t *testing.T, err error) { + t.Helper() + if err != nil { + t.Fatal(err) + } +} + +// acceptWith accepts the next request on s, answers it with accept, and delivers the server side of its stream. +func acceptWith( + t *testing.T, + s *session.Session, + accept func(*session.Request) (session.Stream, error), +) <-chan session.Stream { + t.Helper() + out := make(chan session.Stream, 1) + go func() { + r, err := s.AcceptRequest(t.Context()) + if err != nil { + return + } + stream, err := accept(r) + if err != nil { + return + } + out <- stream + }() + return out +} + +// answerWith replies to the first request server receives with resp. +func answerWith(t *testing.T, server *session.Session, resp message.Message) { + t.Helper() + go func() { + r, err := server.AcceptRequest(t.Context()) + if err != nil { + return + } + _ = message.Marshal(r.Stream, resp) + }() +} + +// subscribePair subscribes client to track "t" and returns both ends once server's AcceptSubscribe answers it. +func subscribePair(t *testing.T, client, server *session.Session) (*session.Subscription, *session.Publication) { + t.Helper() + pubs := make(chan *session.Publication, 1) + go func() { + defer close(pubs) + r, err := server.AcceptRequest(t.Context()) + if err != nil { + return + } + if p, err := r.AcceptSubscribe(nil); err == nil { + pubs <- p + } + }() + sub, err := client.Subscribe(t.Context(), &message.Subscribe{Name: []byte("t")}) + must(t, err) + pub, ok := <-pubs + if !ok { + t.Fatal("server failed to accept the SUBSCRIBE") + } + return sub, pub +} + +// readWithin parses one message from s, failing the wait after d. +func readWithin(s session.Stream, d time.Duration) (message.Message, error) { + type result struct { + msg message.Message + err error + } + got := make(chan result, 1) + go func() { + msg, err := message.Parse(s) + got <- result{msg, err} + }() + select { + case r := <-got: + return r.msg, r.err + case <-time.After(d): + return nil, errors.New("timed out: the stream is still open") + } +} + +// drainOneSubgroup accepts and drains one subgroup stream, so the publisher's writes complete on the test pipe. +func drainOneSubgroup(t *testing.T, client *session.Session) { + ds, err := client.AcceptDataStream(t.Context()) + if err != nil { + return + } + sg, ok := ds.(*session.IncomingSubgroupStream) + if !ok { + return + } + for { + if _, err := sg.ReadObject(); err != nil { + return + } + } +} diff --git a/pkg/moqt/session/malformed_message_test.go b/pkg/moqt/session/malformed_message_test.go deleted file mode 100644 index 9f134fd0..00000000 --- a/pkg/moqt/session/malformed_message_test.go +++ /dev/null @@ -1,175 +0,0 @@ -package session_test - -import ( - "bytes" - "io" - "testing" - "time" - - "github.com/floatdrop/moq-go/pkg/moqt/message" - "github.com/floatdrop/moq-go/pkg/moqt/wire" -) - -// §10: "If the length does not match the length of the Message Body, the -// receiver MUST close the session with a PROTOCOL_VIOLATION", and "An endpoint -// that receives an unknown message type MUST close the session". That holds -// for every message on a request stream, not only the first: each read point -// is covered — the opener, its response, and a follow-up. A frame the peer -// never finished (the stream ended mid-body) is not a length mismatch, and -// stays scoped to its stream. - -// writeTrailing writes m's frame with one byte more in the body than m -// encodes, so the Length covers bytes the Message Body does not. -func writeTrailing(w io.Writer, m message.Message) error { - enc := wire.NewWriter(nil) - m.Append(enc) - return wire.WriteFrame(w, uint64(m.Type()), append(enc.Bytes(), 0x00)) -} - -// writeShort writes m's frame with its last body byte cut off, so the Length -// ends before the Message Body's fields do. For a message whose last field -// runs to the end of the body (Track Properties), a trailing byte is not a -// mismatch, so this is the malformation that applies. -func writeShort(w io.Writer, m message.Message) error { - enc := wire.NewWriter(nil) - m.Append(enc) - body := enc.Bytes() - return wire.WriteFrame(w, uint64(m.Type()), body[:len(body)-1]) -} - -func TestMalformedOpenerClosesSession(t *testing.T) { - t.Parallel() - longName := bytes.Repeat([]byte("n"), 4097) // §2.4.1: over 4,096 bytes - cases := []struct { - name string - write func(io.Writer) error - }{ - {"trailing bytes", func(w io.Writer) error { - return writeTrailing(w, &message.Subscribe{Namespace: videoNS, Name: []byte("t")}) - }}, - {"Full Track Name over 4,096 bytes", func(w io.Writer) error { - return message.Marshal(w, &message.Subscribe{Namespace: videoNS, Name: longName}) - }}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - _, server, cliConn, _ := openPairWithConns(t) - stream, err := cliConn.OpenStream() - if err != nil { - t.Fatalf("OpenStream: %v", err) - } - go func() { _ = tc.write(stream) }() - if _, err := server.AcceptRequest(t.Context()); err == nil { - t.Fatal("AcceptRequest accepted a malformed opener") - } - requireClosedProtocolViolation(t, server) - }) - } -} - -// TestTruncatedOpenerResetsOnlyStream: a stream that ends before its first -// frame is complete is the peer giving up on the request, not a Length that -// disagrees with the body. -func TestTruncatedOpenerResetsOnlyStream(t *testing.T) { - t.Parallel() - _, server, cliConn, _ := openPairWithConns(t) - stream, err := cliConn.OpenStream() - if err != nil { - t.Fatalf("OpenStream: %v", err) - } - go func() { - // Type SUBSCRIBE, Length 16, then only two body bytes and a FIN. - _, _ = stream.Write([]byte{byte(message.TypeSubscribe), 0x00, 0x10, 0x00, 0x00}) - _ = stream.Close() - }() - if _, err := server.AcceptRequest(t.Context()); err == nil { - t.Fatal("AcceptRequest accepted a truncated opener") - } - select { - case <-server.Done(): - t.Fatalf("session closed on a truncated opener: %v", server.Err()) - case <-time.After(100 * time.Millisecond): - } -} - -func TestMalformedResponseClosesSession(t *testing.T) { - t.Parallel() - client, server := openPair(t) - go func() { - r, err := server.AcceptRequest(t.Context()) - if err != nil { - return - } - _ = writeShort(r.Stream, &message.SubscribeOK{TrackAlias: 1}) - }() - if _, err := client.Subscribe(t.Context(), &message.Subscribe{Namespace: videoNS, Name: []byte("t")}); err == nil { - t.Fatal("Subscribe accepted a malformed SUBSCRIBE_OK") - } - requireClosedProtocolViolation(t, client) -} - -// TestMalformedFollowupClosesSession covers the broker, which reads every -// follow-up on a request the application holds a typed handle for. -func TestMalformedFollowupClosesSession(t *testing.T) { - t.Parallel() - cases := []struct { - name string - write func(io.Writer) error - }{ - {"trailing bytes", func(w io.Writer) error { - return writeTrailing(w, &message.RequestUpdate{RequestID: 1}) - }}, - {"unknown message type", func(w io.Writer) error { - return wire.WriteFrame(w, 0x3F00, nil) // unassigned type - }}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - client, server := openPair(t) - go func() { - r, err := server.AcceptRequest(t.Context()) - if err != nil { - return - } - if _, err := r.AcceptPublish(); err != nil { - return - } - _ = tc.write(r.Stream) - }() - pub, err := client.Publish(t.Context(), &message.Publish{Namespace: videoNS, Name: []byte("t")}) - if err != nil { - t.Fatalf("Publish: %v", err) - } - go func() { _ = pub.Broker().Serve(t.Context(), nil) }() - requireClosedProtocolViolation(t, client) - }) - } -} - -func TestMalformedPublishSkippedClosesSession(t *testing.T) { - t.Parallel() - client, server := openPair(t) - go func() { - r, err := server.AcceptRequest(t.Context()) - if err != nil { - return - } - if err := r.Reply(&message.RequestOK{}); err != nil { - return - } - _ = writeTrailing( - r.Stream, - &message.PublishSkipped{TrackNamespaceSuffix: wire.TrackNamespace{[]byte("x")}, TrackName: []byte("t")}, - ) - }() - ts, err := client.SubscribeTracks(t.Context(), &message.SubscribeTracks{TrackNamespacePrefix: videoNS}) - if err != nil { - t.Fatalf("SubscribeTracks: %v", err) - } - if _, err := ts.ReadPublishSkipped(); err == nil { - t.Fatal("ReadPublishSkipped accepted a malformed PUBLISH_SKIPPED") - } - requireClosedProtocolViolation(t, client) -} diff --git a/pkg/moqt/session/malformed_object_test.go b/pkg/moqt/session/malformed_test.go similarity index 50% rename from pkg/moqt/session/malformed_object_test.go rename to pkg/moqt/session/malformed_test.go index de2201a0..393c32bc 100644 --- a/pkg/moqt/session/malformed_object_test.go +++ b/pkg/moqt/session/malformed_test.go @@ -1,7 +1,9 @@ package session_test import ( + "bytes" "errors" + "io" "sync" "testing" "time" @@ -11,26 +13,175 @@ import ( "github.com/floatdrop/moq-go/pkg/moqt/wire" ) -// §2.4.2: "When a subscriber detects a Malformed Track, it MUST cancel any -// corresponding subscription or fetches for that Track from that publisher -// (see Section 3.3.3), and SHOULD deliver an error to the application." The -// session delivers the error — ErrMalformedTrack from the read — and leaves -// the cancelling to the caller, which holds the subscription. The session -// itself stays up: a malformed track is not a protocol violation. +// --------------------------------------------------------------------------- +// Malformed request-stream messages (§10): a Length that disagrees with the +// Message Body, or an unknown type, closes the session with +// PROTOCOL_VIOLATION at every read point — the opener, its response, and a +// follow-up. A frame cut short by the stream ending stays scoped to its stream. +// --------------------------------------------------------------------------- + +// writeTrailing writes m's frame with one extra body byte, so the Length covers bytes the body does not. +func writeTrailing(w io.Writer, m message.Message) error { + enc := wire.NewWriter(nil) + m.Append(enc) + return wire.WriteFrame(w, uint64(m.Type()), append(enc.Bytes(), 0x00)) +} + +// writeShort writes m's frame without its last body byte, for messages whose last field runs to the end. +func writeShort(w io.Writer, m message.Message) error { + enc := wire.NewWriter(nil) + m.Append(enc) + body := enc.Bytes() + return wire.WriteFrame(w, uint64(m.Type()), body[:len(body)-1]) +} + +func TestMalformedOpenerClosesSession(t *testing.T) { + t.Parallel() + longName := bytes.Repeat([]byte("n"), 4097) // §2.4.1: over 4,096 bytes + cases := []struct { + name string + write func(io.Writer) error + }{ + {"trailing bytes", func(w io.Writer) error { + return writeTrailing(w, &message.Subscribe{Namespace: videoNS, Name: []byte("t")}) + }}, + {"Full Track Name over 4,096 bytes", func(w io.Writer) error { + return message.Marshal(w, &message.Subscribe{Namespace: videoNS, Name: longName}) + }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + _, server, cliConn, _ := openPairWithConns(t) + stream, err := cliConn.OpenStream() + if err != nil { + t.Fatalf("OpenStream: %v", err) + } + go func() { _ = tc.write(stream) }() + if _, err := server.AcceptRequest(t.Context()); err == nil { + t.Fatal("AcceptRequest accepted a malformed opener") + } + requireClosedProtocolViolation(t, server) + }) + } +} + +// TestTruncatedOpenerResetsOnlyStream: a stream ending before its first frame +// is complete is the peer giving up, not a Length mismatch. +func TestTruncatedOpenerResetsOnlyStream(t *testing.T) { + t.Parallel() + _, server, cliConn, _ := openPairWithConns(t) + stream, err := cliConn.OpenStream() + if err != nil { + t.Fatalf("OpenStream: %v", err) + } + go func() { + // Type SUBSCRIBE, Length 16, then only two body bytes and a FIN. + _, _ = stream.Write([]byte{byte(message.TypeSubscribe), 0x00, 0x10, 0x00, 0x00}) + _ = stream.Close() + }() + if _, err := server.AcceptRequest(t.Context()); err == nil { + t.Fatal("AcceptRequest accepted a truncated opener") + } + requireStaysOpen(t, server, 100*time.Millisecond) +} + +func TestMalformedResponseClosesSession(t *testing.T) { + t.Parallel() + client, server := openPair(t) + go func() { + r, err := server.AcceptRequest(t.Context()) + if err != nil { + return + } + _ = writeShort(r.Stream, &message.SubscribeOK{TrackAlias: 1}) + }() + if _, err := client.Subscribe(t.Context(), &message.Subscribe{Namespace: videoNS, Name: []byte("t")}); err == nil { + t.Fatal("Subscribe accepted a malformed SUBSCRIBE_OK") + } + requireClosedProtocolViolation(t, client) +} + +// TestMalformedFollowupClosesSession covers the broker, which reads every +// follow-up on a request the application holds a typed handle for. +func TestMalformedFollowupClosesSession(t *testing.T) { + t.Parallel() + cases := []struct { + name string + write func(io.Writer) error + }{ + {"trailing bytes", func(w io.Writer) error { + return writeTrailing(w, &message.RequestUpdate{RequestID: 1}) + }}, + {"unknown message type", func(w io.Writer) error { + return wire.WriteFrame(w, 0x3F00, nil) // unassigned type + }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + client, server := openPair(t) + go func() { + r, err := server.AcceptRequest(t.Context()) + if err != nil { + return + } + if _, err := r.AcceptPublish(); err != nil { + return + } + _ = tc.write(r.Stream) + }() + pub, err := client.Publish(t.Context(), &message.Publish{Namespace: videoNS, Name: []byte("t")}) + if err != nil { + t.Fatalf("Publish: %v", err) + } + go func() { _ = pub.Broker().Serve(t.Context(), nil) }() + requireClosedProtocolViolation(t, client) + }) + } +} + +func TestMalformedPublishSkippedClosesSession(t *testing.T) { + t.Parallel() + client, server := openPair(t) + go func() { + r, err := server.AcceptRequest(t.Context()) + if err != nil { + return + } + if err := r.Reply(&message.RequestOK{}); err != nil { + return + } + _ = writeTrailing( + r.Stream, + &message.PublishSkipped{TrackNamespaceSuffix: wire.TrackNamespace{[]byte("x")}, TrackName: []byte("t")}, + ) + }() + ts, err := client.SubscribeTracks(t.Context(), &message.SubscribeTracks{TrackNamespacePrefix: videoNS}) + if err != nil { + t.Fatalf("SubscribeTracks: %v", err) + } + if _, err := ts.ReadPublishSkipped(); err == nil { + t.Fatal("ReadPublishSkipped accepted a malformed PUBLISH_SKIPPED") + } + requireClosedProtocolViolation(t, client) +} + +// --------------------------------------------------------------------------- +// Malformed Tracks (§2.4.2): the read returns ErrMalformedTrack and the +// session stays up; cancelling the subscription is left to the caller. +// --------------------------------------------------------------------------- // objProps builds raw Object Properties. func objProps(pairs ...wire.KVPair) []byte { return message.AppendTrackProperties(pairs) } +// requireMalformedTrack checks err wraps ErrMalformedTrack and sess stays up. func requireMalformedTrack(t *testing.T, err error, sess *session.Session) { t.Helper() if !errors.Is(err, session.ErrMalformedTrack) { t.Fatalf("read = %v, want an error wrapping ErrMalformedTrack", err) } - select { - case <-sess.Done(): - t.Fatalf("session closed over a malformed track: %v", sess.Err()) - case <-time.After(50 * time.Millisecond): - } + requireStaysOpen(t, sess, 50*time.Millisecond) } // TestSubgroupObjectMalformedProperties: the checks use the Object's absolute diff --git a/pkg/moqt/session/param_scope_test.go b/pkg/moqt/session/param_scope_test.go index 93746a8b..67dc9176 100644 --- a/pkg/moqt/session/param_scope_test.go +++ b/pkg/moqt/session/param_scope_test.go @@ -6,22 +6,18 @@ import ( "github.com/floatdrop/moq-go/pkg/moqt/message" "github.com/floatdrop/moq-go/pkg/moqt/session" - "github.com/floatdrop/moq-go/pkg/moqt/wire" ) -// §10.2.1: "Each Message Parameter definition indicates the message types in -// which it can appear. If it appears in some other type of message, the -// receiving endpoint MUST close the connection with a PROTOCOL_VIOLATION." -// §10.2: "An endpoint that receives an unknown Message Parameter MUST close -// the session with PROTOCOL_VIOLATION", and receivers SHOULD do the same for -// "unexpected duplicate parameters". Each receive point is covered: request +// A Message Parameter outside the message types its definition lists +// (§10.2.1), an unknown one, or an unexpected duplicate (§10.2) closes the +// session with PROTOCOL_VIOLATION. Each receive point is covered: request // openers, their responses, REQUEST_UPDATE and its response, and // PUBLISH_STATE_NOTIFY. -var videoNS = wire.TrackNamespace{[]byte("video")} - // TestParamScopeOpeners: a request opener carrying a parameter its message -// does not define, a duplicate, or an unknown type closes the receiver. +// does not define, a duplicate (also inside FILL_PARAMETERS, §10.2.15), or an +// unknown type closes the receiver. SUBSCRIBE_TRACKS takes SUBSCRIBE's +// parameters (§10.20.1), but not response ones such as EXPIRES. func TestParamScopeOpeners(t *testing.T) { cases := []struct { name string @@ -61,6 +57,20 @@ func TestParamScopeOpeners(t *testing.T) { Name: []byte("t"), Parameters: message.Parameters{message.VarintParam(0x3E, 1)}, }) }}, + {"duplicate inside FILL_PARAMETERS", func(c *session.Session) { + _, _ = c.Subscribe(t.Context(), &message.Subscribe{ + Name: []byte("t"), + Parameters: message.Parameters{message.FillParametersParam(message.Parameters{ + message.GroupOrderParam(message.GroupOrderAscending), + message.GroupOrderParam(message.GroupOrderDescending), + })}, + }) + }}, + {"EXPIRES in SUBSCRIBE_TRACKS", func(c *session.Session) { + _, _ = c.SubscribeTracks(t.Context(), &message.SubscribeTracks{ + TrackNamespacePrefix: videoNS, Parameters: message.Parameters{message.ExpiresParam(time.Second)}, + }) + }}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -72,18 +82,6 @@ func TestParamScopeOpeners(t *testing.T) { } } -// answerWith replies to the first request server receives with resp. -func answerWith(t *testing.T, server *session.Session, resp message.Message) { - t.Helper() - go func() { - r, err := server.AcceptRequest(t.Context()) - if err != nil { - return - } - _ = message.Marshal(r.Stream, resp) - }() -} - // TestParamScopeResponses: a response carrying a parameter its message form // does not define closes the requester. REQUEST_OK's forms are told apart by // the request they answer (§10.5). @@ -133,21 +131,8 @@ func TestParamScopeResponses(t *testing.T) { // subscription it closes the receiver. func TestParamScopeRequestUpdate(t *testing.T) { client, server := openPair(t) - go func() { - r, err := server.AcceptRequest(t.Context()) - if err != nil { - return - } - pub, err := r.AcceptSubscribe(nil) - if err != nil { - return - } - _ = pub.Broker().Serve(t.Context(), nil) - }() - sub, err := client.Subscribe(t.Context(), &message.Subscribe{Name: []byte("t")}) - if err != nil { - t.Fatalf("Subscribe: %v", err) - } + sub, pub := subscribePair(t, client, server) + go func() { _ = pub.Broker().Serve(t.Context(), nil) }() go func() { _, _ = sub.Update(t.Context(), message.Parameters{message.TrackNamespacePrefixParam(videoNS)}) }() @@ -158,25 +143,12 @@ func TestParamScopeRequestUpdate(t *testing.T) { // REQUEST_UPDATE_OK; a response carrying it closes the requester. func TestParamScopeRequestUpdateOK(t *testing.T) { client, server := openPair(t) - go func() { - r, err := server.AcceptRequest(t.Context()) - if err != nil { - return - } - pub, err := r.AcceptSubscribe(nil) - if err != nil { - return - } - b := pub.Broker() - b.HandleUpdates(func(*message.RequestUpdate) (*message.RequestOK, error) { - return &message.RequestOK{Parameters: message.Parameters{message.ForwardParam(true)}}, nil - }) - _ = b.Serve(t.Context(), nil) - }() - sub, err := client.Subscribe(t.Context(), &message.Subscribe{Name: []byte("t")}) - if err != nil { - t.Fatalf("Subscribe: %v", err) - } + sub, pub := subscribePair(t, client, server) + b := pub.Broker() + b.HandleUpdates(func(*message.RequestUpdate) (*message.RequestOK, error) { + return &message.RequestOK{Parameters: message.Parameters{message.ForwardParam(true)}}, nil + }) + go func() { _ = b.Serve(t.Context(), nil) }() _, _ = sub.Update(t.Context(), message.Parameters{message.ForwardParam(false)}) requireClosedProtocolViolation(t, client) } @@ -185,31 +157,18 @@ func TestParamScopeRequestUpdateOK(t *testing.T) { // PUBLISH_STATE_NOTIFY (§10.2.8); the subscriber closes on it. func TestParamScopePublishStateNotify(t *testing.T) { client, server := openPair(t) + sub, pub := subscribePair(t, client, server) go func() { - r, err := server.AcceptRequest(t.Context()) - if err != nil { - return - } - pub, err := r.AcceptSubscribe(nil) - if err != nil { - return - } _ = message.Marshal(pub.Stream, &message.PublishStateNotify{ Parameters: message.Parameters{message.GroupOrderParam(message.GroupOrderAscending)}, }) }() - sub, err := client.Subscribe(t.Context(), &message.Subscribe{Name: []byte("t")}) - if err != nil { - t.Fatalf("Subscribe: %v", err) - } go func() { _ = sub.Broker().Serve(t.Context(), nil) }() requireClosedProtocolViolation(t, client) } -// TestParamScopeRepeatedAuthorizationTokenAccepted: "The AUTHORIZATION TOKEN -// parameter MAY be repeated within a message as long as the combination of -// Token Type and Token Value are unique after resolving any aliases" -// (§10.2.2), so it is exempt from the duplicate rule. +// TestParamScopeRepeatedAuthorizationTokenAccepted: AUTHORIZATION TOKEN may +// repeat with distinct Type and Value (§10.2.2), exempt from the duplicate rule. func TestParamScopeRepeatedAuthorizationTokenAccepted(t *testing.T) { client, server := openPair(t) tok := func(v string) message.Parameter { @@ -227,28 +186,9 @@ func TestParamScopeRepeatedAuthorizationTokenAccepted(t *testing.T) { } } -// TestParamScopeDuplicateInsideFillParameters: FILL_PARAMETERS is "encoded as -// if they were Parameters for a separate message" (§10.2.15), so §10.2's -// duplicate rule applies inside it. -func TestParamScopeDuplicateInsideFillParameters(t *testing.T) { - client, server := openPair(t) - go func() { - _, _ = client.Subscribe(t.Context(), &message.Subscribe{ - Name: []byte("t"), - Parameters: message.Parameters{message.FillParametersParam(message.Parameters{ - message.GroupOrderParam(message.GroupOrderAscending), - message.GroupOrderParam(message.GroupOrderDescending), - })}, - }) - }() - _, _ = server.AcceptRequest(t.Context()) - requireClosedProtocolViolation(t, server) -} - -// TestParamScopeSubscribeTracksTakesSubscribeParameters: "Any Parameter that -// can be specified on a Subscription (ie: in SUBSCRIBE) is valid in -// SUBSCRIBE_TRACKS, unless otherwise specified" (§10.20.1) — including a -// Location Filter and FILL_PARAMETERS, which it names. +// TestParamScopeSubscribeTracksTakesSubscribeParameters: SUBSCRIBE's +// parameters are valid in SUBSCRIBE_TRACKS (§10.20.1), including a Location +// Filter and FILL_PARAMETERS. func TestParamScopeSubscribeTracksTakesSubscribeParameters(t *testing.T) { for _, p := range []message.Parameter{ message.SubgroupDeliveryTimeoutParam(time.Second), @@ -270,23 +210,8 @@ func TestParamScopeSubscribeTracksTakesSubscribeParameters(t *testing.T) { } } -// TestParamScopeSubscribeTracksStillScoped: §10.20.1 widens SUBSCRIBE_TRACKS by -// SUBSCRIBE's parameters only; EXPIRES, a response parameter, still closes. -func TestParamScopeSubscribeTracksStillScoped(t *testing.T) { - client, server := openPair(t) - go func() { - _, _ = client.SubscribeTracks(t.Context(), &message.SubscribeTracks{ - TrackNamespacePrefix: videoNS, Parameters: message.Parameters{message.ExpiresParam(time.Second)}, - }) - }() - _, _ = server.AcceptRequest(t.Context()) - requireClosedProtocolViolation(t, server) -} - -// TestIncludePropertiesOutOfRangeCloses: §10.2.21 "The allowed values are 0 -// (do not send Properties) or 1 (send Properties) [...] If an endpoint -// receives a value outside this range, it MUST close the session with -// PROTOCOL_VIOLATION." Checked for each message that may carry it. +// TestIncludePropertiesOutOfRangeCloses: INCLUDE_PROPERTIES other than 0 or 1 +// is a PROTOCOL_VIOLATION (§10.2.21), in each message that may carry it. func TestIncludePropertiesOutOfRangeCloses(t *testing.T) { t.Parallel() bad := message.Parameters{message.ByteParam(message.ParamIncludeProperties, 2)} diff --git a/pkg/moqt/session/priority_test.go b/pkg/moqt/session/priority_test.go index 7348cfd6..1b27447c 100644 --- a/pkg/moqt/session/priority_test.go +++ b/pkg/moqt/session/priority_test.go @@ -9,33 +9,25 @@ import ( "github.com/floatdrop/moq-go/pkg/moqt/session" ) -// prioritizedFakeStream extends the test-only fakeSendStream pattern with a -// SetSendPriority method that records every priority key the relay pushes -// through. It satisfies both [session.SendStream] and -// [session.PrioritizedSendStream]. +// plainFakeStream is a [session.SendStream] into a buffer, with no optional capabilities. +type plainFakeStream struct{ buf bytes.Buffer } + +func (s *plainFakeStream) Write(p []byte) (int, error) { return s.buf.Write(p) } +func (s *plainFakeStream) Close() error { return nil } +func (s *plainFakeStream) CancelWrite(uint64) {} +func (s *plainFakeStream) Context() context.Context { return context.Background() } + +// prioritizedFakeStream is a [session.PrioritizedSendStream] recording every priority it is given. type prioritizedFakeStream struct { - buf *bytes.Buffer + plainFakeStream + priorities []session.StreamPriority } -func (s *prioritizedFakeStream) Write(p []byte) (int, error) { return s.buf.Write(p) } -func (s *prioritizedFakeStream) Close() error { return nil } -func (s *prioritizedFakeStream) CancelWrite(uint64) {} -func (s *prioritizedFakeStream) Context() context.Context { return context.Background() } - func (s *prioritizedFakeStream) SetSendPriority(p session.StreamPriority) { s.priorities = append(s.priorities, p) } -// plainFakeStream satisfies SendStream but not PrioritizedSendStream — used -// to verify the silent no-op fallback. -type plainFakeStream struct{ buf *bytes.Buffer } - -func (s *plainFakeStream) Write(p []byte) (int, error) { return s.buf.Write(p) } -func (s *plainFakeStream) Close() error { return nil } -func (s *plainFakeStream) CancelWrite(uint64) {} -func (s *plainFakeStream) Context() context.Context { return context.Background() } - // TestOutgoingSubgroupStream_SetSendPriority_ForwardsWhenSupported pins // the forwarding contract: when the inner SendStream implements // [session.PrioritizedSendStream], OutgoingSubgroupStream.SetSendPriority @@ -43,7 +35,7 @@ func (s *plainFakeStream) Context() context.Context { return context.Backgrou func TestOutgoingSubgroupStream_SetSendPriority_ForwardsWhenSupported(t *testing.T) { t.Parallel() - inner := &prioritizedFakeStream{buf: &bytes.Buffer{}} + inner := &prioritizedFakeStream{} out := session.NewOutgoingSubgroupStream(inner) p0 := session.StreamPriority{Subscriber: 0} // highest @@ -66,25 +58,21 @@ func TestOutgoingSubgroupStream_SetSendPriority_ForwardsWhenSupported(t *testing func TestOutgoingSubgroupStream_SetSendPriority_NoopWhenUnsupported(t *testing.T) { t.Parallel() - inner := &plainFakeStream{buf: &bytes.Buffer{}} + inner := &plainFakeStream{} out := session.NewOutgoingSubgroupStream(inner) // Must not panic. out.SetSendPriority(session.StreamPriority{Subscriber: 42}) } -// reliableSpyStream satisfies [session.SendStream] and -// [session.ReliableResetStream], counting SetReliableBoundary calls. +// reliableSpyStream is a [session.ReliableResetStream] counting SetReliableBoundary calls. type reliableSpyStream struct { - buf *bytes.Buffer + plainFakeStream + marks int } -func (s *reliableSpyStream) Write(p []byte) (int, error) { return s.buf.Write(p) } -func (s *reliableSpyStream) Close() error { return nil } -func (s *reliableSpyStream) CancelWrite(uint64) {} -func (s *reliableSpyStream) Context() context.Context { return context.Background() } -func (s *reliableSpyStream) SetReliableBoundary() { s.marks++ } +func (s *reliableSpyStream) SetReliableBoundary() { s.marks++ } // TestOutgoingSubgroupStream_MarkReliable pins the §11.4.3 RESET_STREAM_AT // plumbing: MarkReliable forwards to the underlying stream when it implements @@ -92,7 +80,7 @@ func (s *reliableSpyStream) SetReliableBoundary() { s.marks++ } func TestOutgoingSubgroupStream_MarkReliable(t *testing.T) { t.Parallel() - supported := &reliableSpyStream{buf: &bytes.Buffer{}} + supported := &reliableSpyStream{} out := session.NewOutgoingSubgroupStream(supported) out.MarkReliable() out.MarkReliable() @@ -101,6 +89,6 @@ func TestOutgoingSubgroupStream_MarkReliable(t *testing.T) { } // Must not panic when the underlying stream lacks the extension. - plain := &plainFakeStream{buf: &bytes.Buffer{}} + plain := &plainFakeStream{} session.NewOutgoingSubgroupStream(plain).MarkReliable() } diff --git a/pkg/moqt/session/publication_update_test.go b/pkg/moqt/session/publication_update_test.go index 4a05fb8c..338e1752 100644 --- a/pkg/moqt/session/publication_update_test.go +++ b/pkg/moqt/session/publication_update_test.go @@ -11,45 +11,24 @@ import ( "github.com/floatdrop/moq-go/pkg/moqt/session" ) -// §10.9: "The receiver of a REQUEST_UPDATE MUST respond with exactly one -// REQUEST_OK or REQUEST_ERROR message indicating if the update was -// successful". §5.1: "The publisher does not send Objects if the Forward State -// is 0." §10.9.1: "The REQUEST_UPDATE_OK will include the LARGEST_OBJECT -// parameter", and "When a REQUEST_UPDATE is unsuccessful, the publisher MUST -// also terminate the subscription by sending a PUBLISH_DONE with error code -// UPDATE_FAILED." +// A Publication answering REQUEST_UPDATE (§10.9): exactly one REQUEST_OK or +// REQUEST_ERROR, no Objects at Forward State 0 (§5.1), LARGEST_OBJECT in the +// OK, and PUBLISH_DONE UPDATE_FAILED after a declined update (§10.9.1). -// servedPublication subscribes a client to a track the server answers with a -// Publication whose broker is serving, and returns the client session and both -// handles. +// servedPublication subscribes a client to a server Publication whose broker is serving. func servedPublication(t *testing.T) (*session.Session, *session.Subscription, *session.Publication) { t.Helper() return servedPublicationWith(t, nil) } -// servedPublicationWith is servedPublication with onUpdate, when non-nil, -// deciding the subscriber's REQUEST_UPDATEs in place of the built-in handling. +// servedPublicationWith is servedPublication with onUpdate, if non-nil, replacing the built-in update handling. func servedPublicationWith( t *testing.T, onUpdate session.UpdateHandler, ) (*session.Session, *session.Subscription, *session.Publication) { t.Helper() client, server := openPair(t) - pubs := make(chan *session.Publication, 1) - go func() { - r, err := server.AcceptRequest(t.Context()) - if err != nil { - return - } - p, err := r.AcceptSubscribe(nil) - if err != nil { - return - } - pubs <- p - }() - sub, err := client.Subscribe(t.Context(), &message.Subscribe{Name: []byte("t")}) - must(t, err) - pub := <-pubs + sub, pub := subscribePair(t, client, server) b := pub.Broker() if onUpdate != nil { b.HandleUpdates(onUpdate) @@ -137,18 +116,7 @@ func TestPublicationDeclinesUnsupportedUpdate(t *testing.T) { // built-in one, and can still reuse it through ApplyUpdate. func TestPublicationCustomUpdateHandler(t *testing.T) { client, server := openPair(t) - pubs := make(chan *session.Publication, 1) - go func() { - r, err := server.AcceptRequest(t.Context()) - if err != nil { - return - } - p, _ := r.AcceptSubscribe(nil) - pubs <- p - }() - sub, err := client.Subscribe(t.Context(), &message.Subscribe{Name: []byte("t")}) - must(t, err) - pub := <-pubs + sub, pub := subscribePair(t, client, server) b := pub.Broker() var seen []message.ParamID b.HandleUpdates(func(upd *message.RequestUpdate) (*message.RequestOK, error) { @@ -178,56 +146,22 @@ func TestPublicationCustomUpdateHandler(t *testing.T) { } } -// TestBrokerWithoutHandlerDeclines: a broker nobody taught to handle updates -// answers REQUEST_ERROR NOT_SUPPORTED — declining complies with §10.9, while -// acknowledging and ignoring an update does not. +// TestBrokerWithoutHandlerDeclines: a broker without an update handler +// declines with REQUEST_ERROR NOT_SUPPORTED rather than ignoring it (§10.9). func TestBrokerWithoutHandlerDeclines(t *testing.T) { client, server := openPair(t) - streams := make(chan session.Stream, 1) - go func() { - r, err := server.AcceptRequest(t.Context()) - if err != nil { - return - } - if _, err := r.AcceptSubscribe(nil); err != nil { - return - } - streams <- r.Stream - }() - sub, err := client.Subscribe(t.Context(), &message.Subscribe{Name: []byte("t")}) - must(t, err) - b := server.NewRequestBroker(<-streams) + sub, pub := subscribePair(t, client, server) + b := server.NewRequestBroker(pub.Stream) go func() { _ = b.Serve(t.Context(), nil) }() - _, err = sub.Update(t.Context(), message.Parameters{message.ForwardParam(false)}) + _, err := sub.Update(t.Context(), message.Parameters{message.ForwardParam(false)}) if rej, ok := errors.AsType[*session.RequestRejectedError](err); !ok || rej.Code != moqt.RequestNotSupported { t.Fatalf("Update = %v, want REQUEST_ERROR NOT_SUPPORTED", err) } } -// drainOneSubgroup accepts and drains one subgroup stream on the subscriber, -// so the publisher's writes complete on the unbuffered test pipe. -func drainOneSubgroup(t *testing.T, client *session.Session) { - ds, err := client.AcceptDataStream(t.Context()) - if err != nil { - return - } - sg, ok := ds.(*session.IncomingSubgroupStream) - if !ok { - return - } - for { - if _, err := sg.ReadObject(); err != nil { - return - } - } -} - -// TestPublishOKForwardClosesSession: draft-20 moved subscription parameters -// out of PUBLISH_OK ("Subscription parameters appear in REQUEST_UPDATE, not -// PUBLISH_OK", #1790). FORWARD may appear in PUBLISH, not PUBLISH_OK -// (§10.2.18), so one there is a parameter outside its scope: "the receiving -// endpoint MUST close the connection with a PROTOCOL_VIOLATION" (§10.2.1). +// TestPublishOKForwardClosesSession: FORWARD is not defined for PUBLISH_OK +// (§10.2.18), so receiving it there is a PROTOCOL_VIOLATION (§10.2.1). func TestPublishOKForwardClosesSession(t *testing.T) { client, server := openPair(t) go func() { @@ -243,9 +177,8 @@ func TestPublishOKForwardClosesSession(t *testing.T) { requireClosedProtocolViolation(t, client) } -// TestForwardPauseResetsOpenSubgroup: FORWARD=0 stops objects on subgroups -// already open, too. §11.4.3 lists "Omitting a Subgroup Object due to the -// subscriber's Forward State" among the reasons the sender resets the stream. +// TestForwardPauseResetsOpenSubgroup: FORWARD=0 also resets subgroups already +// open (§11.4.3). func TestForwardPauseResetsOpenSubgroup(t *testing.T) { client, sub, pub := servedPublication(t) peer := make(chan session.DataStream, 1) @@ -295,26 +228,14 @@ func TestDeclinedUpdateEndsPublication(t *testing.T) { } } -// TestInvalidForwardClosesSession pins §10.2.18: a FORWARD value other than 0 -// or 1 "MUST close the session with PROTOCOL_VIOLATION", in a SUBSCRIBE and in -// a REQUEST_UPDATE alike. +// TestInvalidForwardClosesSession: FORWARD other than 0 or 1 closes the session +// with PROTOCOL_VIOLATION (§10.2.18), in SUBSCRIBE and REQUEST_UPDATE alike. func TestInvalidForwardClosesSession(t *testing.T) { bad := message.ByteParam(message.ParamForward, 2) t.Run("REQUEST_UPDATE", func(t *testing.T) { client, server := openPair(t) - go func() { - r, err := server.AcceptRequest(t.Context()) - if err != nil { - return - } - p, err := r.AcceptSubscribe(nil) - if err != nil { - return - } - _ = p.Broker().Serve(t.Context(), nil) - }() - sub, err := client.Subscribe(t.Context(), &message.Subscribe{Name: []byte("t")}) - must(t, err) + sub, pub := subscribePair(t, client, server) + go func() { _ = pub.Broker().Serve(t.Context(), nil) }() go func() { _, _ = sub.Update(t.Context(), message.Parameters{bad}) }() requireClosedProtocolViolation(t, server) }) diff --git a/pkg/moqt/session/request_ok_properties_test.go b/pkg/moqt/session/request_ok_properties_test.go index fe69a0d7..1d29a0d0 100644 --- a/pkg/moqt/session/request_ok_properties_test.go +++ b/pkg/moqt/session/request_ok_properties_test.go @@ -2,24 +2,47 @@ package session_test import ( "context" + "errors" "testing" + "time" + "github.com/floatdrop/moq-go/pkg/moqt" "github.com/floatdrop/moq-go/pkg/moqt/message" "github.com/floatdrop/moq-go/pkg/moqt/session" "github.com/floatdrop/moq-go/pkg/moqt/wire" ) -// §10.5 REQUEST_OK: "Track Properties are populated in TRACK_STATUS_OK; they -// are empty in PUBLISH_OK, REQUEST_UPDATE_OK, SUBSCRIBE_NAMESPACE_OK and -// PUBLISH_NAMESPACE_OK. If an endpoint receives Track Properties in one of -// these messages it MUST close the session with a PROTOCOL_VIOLATION." +// §10.5 REQUEST_OK: Track Properties belong in TRACK_STATUS_OK and are empty +// in PUBLISH_OK, REQUEST_UPDATE_OK, SUBSCRIBE_NAMESPACE_OK and +// PUBLISH_NAMESPACE_OK; receiving them there is a PROTOCOL_VIOLATION. The +// session also refuses to send them there. var trackProps = message.AppendTrackProperties([]wire.KVPair{ {Type: message.PropertyDefaultPublisherPriority, IntVal: 1}, }) -// replyOKWithProperties accepts the next request on server and answers it -// with a REQUEST_OK carrying Track Properties. +// emptyPropertiesOKs are the requests whose first answer is a REQUEST_OK that must carry no Track Properties. +var emptyPropertiesOKs = []struct { + name string + send func(context.Context, *session.Session) error +}{ + {"PUBLISH_OK", func(ctx context.Context, c *session.Session) error { + _, err := c.Publish(ctx, &message.Publish{Namespace: wire.TrackNamespace{[]byte("ns")}, Name: []byte("t")}) + return err + }}, + {"PUBLISH_NAMESPACE_OK", func(ctx context.Context, c *session.Session) error { + _, err := c.PublishNamespace(ctx, &message.PublishNamespace{Namespace: wire.TrackNamespace{[]byte("ns")}}) + return err + }}, + {"SUBSCRIBE_NAMESPACE_OK", func(ctx context.Context, c *session.Session) error { + _, err := c.SubscribeNamespace(ctx, &message.SubscribeNamespace{ + TrackNamespacePrefix: wire.TrackNamespace{[]byte("ns")}, + }) + return err + }}, +} + +// replyOKWithProperties answers the next request on server with a REQUEST_OK carrying Track Properties. func replyOKWithProperties(t *testing.T, server *session.Session) { t.Helper() go func() { @@ -31,25 +54,12 @@ func replyOKWithProperties(t *testing.T, server *session.Session) { }() } +// --------------------------------------------------------------------------- +// Receive side +// --------------------------------------------------------------------------- + func TestRequestOKWithTrackPropertiesClosesSession(t *testing.T) { - ns := wire.TrackNamespace{[]byte("ns")} - for _, tc := range []struct { - name string - send func(context.Context, *session.Session) error - }{ - {"PUBLISH_OK", func(ctx context.Context, c *session.Session) error { - _, err := c.Publish(ctx, &message.Publish{Namespace: ns, Name: []byte("t")}) - return err - }}, - {"PUBLISH_NAMESPACE_OK", func(ctx context.Context, c *session.Session) error { - _, err := c.PublishNamespace(ctx, &message.PublishNamespace{Namespace: ns}) - return err - }}, - {"SUBSCRIBE_NAMESPACE_OK", func(ctx context.Context, c *session.Session) error { - _, err := c.SubscribeNamespace(ctx, &message.SubscribeNamespace{TrackNamespacePrefix: ns}) - return err - }}, - } { + for _, tc := range emptyPropertiesOKs { t.Run(tc.name, func(t *testing.T) { client, server := openPair(t) replyOKWithProperties(t, server) @@ -62,20 +72,49 @@ func TestRequestOKWithTrackPropertiesClosesSession(t *testing.T) { } // TestRequestUpdateOKWithTrackPropertiesClosesSession covers REQUEST_UPDATE_OK, -// which arrives on an established request stream rather than as its first -// response — read directly by Update, or routed by a RequestBroker's Serve loop. +// read directly by Update or routed by a RequestBroker's Serve loop. func TestRequestUpdateOKWithTrackPropertiesClosesSession(t *testing.T) { for _, viaBroker := range []bool{false, true} { name := "direct" if viaBroker { name = "broker" } - t.Run(name, func(t *testing.T) { testRequestUpdateOKWithTrackProperties(t, viaBroker) }) + t.Run(name, func(t *testing.T) { + client, server := openPair(t) + go func() { + req, err := server.AcceptRequest(t.Context()) + if err != nil { + return + } + _ = req.Reply(&message.SubscribeOK{TrackAlias: 1}) + if _, err := message.Parse(req.Stream); err != nil { // the REQUEST_UPDATE + return + } + _ = message.Marshal(req.Stream, &message.RequestOK{TrackProperties: trackProps}) + }() + + sub, err := client.Subscribe(t.Context(), &message.Subscribe{Name: []byte("t")}) + if err != nil { + t.Fatalf("Subscribe: %v", err) + } + if viaBroker { + b := sub.Broker() + go func() { _ = b.Serve(t.Context(), nil) }() + } + if _, err := sub.Update(t.Context(), message.Parameters{message.ForwardParam(false)}); err == nil { + t.Fatal("Update succeeded despite Track Properties in REQUEST_UPDATE_OK") + } + requireClosedProtocolViolation(t, client) + }) } } -func testRequestUpdateOKWithTrackProperties(t *testing.T, viaBroker bool) { +// TestLateRequestUpdateOKWithTrackPropertiesClosesSession: a REQUEST_UPDATE_OK +// arriving after its Update gave up reaches Serve as unsolicited, and §10.5 +// still applies. +func TestLateRequestUpdateOKWithTrackPropertiesClosesSession(t *testing.T) { client, server := openPair(t) + updateSeen := make(chan session.Stream, 1) go func() { req, err := server.AcceptRequest(t.Context()) if err != nil { @@ -85,25 +124,28 @@ func testRequestUpdateOKWithTrackProperties(t *testing.T, viaBroker bool) { if _, err := message.Parse(req.Stream); err != nil { // the REQUEST_UPDATE return } - _ = message.Marshal(req.Stream, &message.RequestOK{TrackProperties: trackProps}) + updateSeen <- req.Stream }() sub, err := client.Subscribe(t.Context(), &message.Subscribe{Name: []byte("t")}) if err != nil { t.Fatalf("Subscribe: %v", err) } - if viaBroker { - b := sub.Broker() - go func() { _ = b.Serve(t.Context(), nil) }() - } - if _, err := sub.Update(t.Context(), message.Parameters{message.ForwardParam(false)}); err == nil { - t.Fatal("Update succeeded despite Track Properties in REQUEST_UPDATE_OK") - } + b := sub.Broker() + go func() { _ = b.Serve(t.Context(), func(message.Message) bool { return true }) }() + + ctx, cancel := context.WithCancel(t.Context()) + go func() { + stream := <-updateSeen + cancel() // the Update gives up before the answer arrives + _ = message.Marshal(stream, &message.RequestOK{TrackProperties: trackProps}) + }() + _, _ = sub.Update(ctx, message.Parameters{message.ForwardParam(false)}) requireClosedProtocolViolation(t, client) } -// TestTrackStatusOKKeepsTrackProperties is the other side of the rule: -// TRACK_STATUS_OK is where Track Properties belong. +// TestTrackStatusOKKeepsTrackProperties: TRACK_STATUS_OK is where Track +// Properties belong. func TestTrackStatusOKKeepsTrackProperties(t *testing.T) { client, server := openPair(t) replyOKWithProperties(t, server) @@ -121,38 +163,165 @@ func TestTrackStatusOKKeepsTrackProperties(t *testing.T) { } } -// TestLateRequestUpdateOKWithTrackPropertiesClosesSession: a REQUEST_UPDATE_OK -// that arrives after its Update gave up reaches the broker's Serve loop as an -// unsolicited response. It is still a REQUEST_UPDATE_OK, and §10.5 still -// applies. -func TestLateRequestUpdateOKWithTrackPropertiesClosesSession(t *testing.T) { +// --------------------------------------------------------------------------- +// Send side: the session refuses, sends nothing, and the peer stays up +// --------------------------------------------------------------------------- + +// TestReplyRefusesTrackPropertiesWhereEmpty: Reply returns +// ErrTrackPropertiesNotAllowed, and an empty REQUEST_OK can still be sent. +func TestReplyRefusesTrackPropertiesWhereEmpty(t *testing.T) { + for _, tc := range emptyPropertiesOKs { + t.Run(tc.name, func(t *testing.T) { + client, server := openPair(t) + replied := make(chan error, 1) + go func() { + req, err := server.AcceptRequest(t.Context()) + if err != nil { + replied <- err + return + } + err = req.Reply(&message.RequestOK{TrackProperties: trackProps}) + replied <- err + if err != nil { + _ = req.Reply(&message.RequestOK{}) + } + }() + if err := tc.send(t.Context(), client); err != nil { + t.Fatalf("request failed: %v", err) + } + if err := <-replied; !errors.Is(err, session.ErrTrackPropertiesNotAllowed) { + t.Fatalf("Reply with Track Properties = %v, want ErrTrackPropertiesNotAllowed", err) + } + requireStaysOpen(t, client, 50*time.Millisecond) + }) + } +} + +// updater is a client request handle that can send REQUEST_UPDATE. +type updater interface { + Update(ctx context.Context, params message.Parameters) (*message.RequestOK, error) +} + +// streamUpdater updates a request whose handle has no Update method. +type streamUpdater struct { + s *session.Session + stream session.Stream +} + +func (u streamUpdater) Update(ctx context.Context, params message.Parameters) (*message.RequestOK, error) { + return u.s.UpdateRequest(ctx, u.stream, params) +} + +// TestReplyRefusesUpdateOKTrackProperties: a REQUEST_OK after a SUBSCRIBE_OK +// or FETCH_OK is a REQUEST_UPDATE_OK, whose Track Properties are empty (§10.5); +// so is every SUBSCRIBE_TRACKS REQUEST_OK after the first. +func TestReplyRefusesUpdateOKTrackProperties(t *testing.T) { + for _, tc := range []struct { + name string + // answer sends the request's first response; open sends the request + // from the client and returns the handle to update it with. + answer func(*session.Request) error + open func(context.Context, *session.Session) (updater, error) + }{ + { + "SUBSCRIBE", + func(r *session.Request) error { return r.Reply(&message.SubscribeOK{TrackAlias: 1}) }, + func(ctx context.Context, c *session.Session) (updater, error) { + return c.Subscribe(ctx, &message.Subscribe{Name: []byte("t")}) + }, + }, + { + // §10.5 does not name the SUBSCRIBE_TRACKS OK, so its first + // REQUEST_OK may carry Track Properties. + "SUBSCRIBE_TRACKS", + func(r *session.Request) error { return r.Reply(&message.RequestOK{TrackProperties: trackProps}) }, + func(ctx context.Context, c *session.Session) (updater, error) { + ts, err := c.SubscribeTracks(ctx, &message.SubscribeTracks{ + TrackNamespacePrefix: wire.TrackNamespace{[]byte("ns")}, + }) + if err != nil { + return nil, err + } + return streamUpdater{c, ts.Stream}, nil + }, + }, + { + "FETCH", + func(r *session.Request) error { _, err := r.AcceptFetch(nil); return err }, + func(ctx context.Context, c *session.Session) (updater, error) { + return c.Fetch(ctx, &message.Fetch{Name: []byte("t")}) + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + client, server := openPair(t) + replied := make(chan error, 1) + go func() { + req, err := server.AcceptRequest(t.Context()) + if err != nil { + replied <- err + return + } + if err := tc.answer(req); err != nil { + replied <- err + return + } + if _, err := message.Parse(req.Stream); err != nil { // the REQUEST_UPDATE + replied <- err + return + } + err = req.Reply(&message.RequestOK{TrackProperties: trackProps}) + replied <- err + if err != nil { + _ = req.Reply(&message.RequestOK{}) + } + }() + h, err := tc.open(t.Context(), client) + must(t, err) + if _, err := h.Update(t.Context(), message.Parameters{message.ForwardParam(true)}); err != nil { + t.Fatalf("Update: %v", err) + } + if err := <-replied; !errors.Is(err, session.ErrTrackPropertiesNotAllowed) { + t.Fatalf("Reply with Track Properties = %v, want ErrTrackPropertiesNotAllowed", err) + } + requireStaysOpen(t, client, 50*time.Millisecond) + }) + } +} + +// TestReplyKeepsTrackStatusProperties: Reply sends Track Properties in a +// TRACK_STATUS_OK. +func TestReplyKeepsTrackStatusProperties(t *testing.T) { client, server := openPair(t) - updateSeen := make(chan session.Stream, 1) + replied := make(chan error, 1) go func() { req, err := server.AcceptRequest(t.Context()) if err != nil { + replied <- err return } - _ = req.Reply(&message.SubscribeOK{TrackAlias: 1}) - if _, err := message.Parse(req.Stream); err != nil { // the REQUEST_UPDATE - return - } - updateSeen <- req.Stream + replied <- req.Reply(&message.RequestOK{TrackProperties: trackProps}) }() - - sub, err := client.Subscribe(t.Context(), &message.Subscribe{Name: []byte("t")}) + ts, err := client.TrackStatus(t.Context(), &message.TrackStatus{Name: []byte("t")}) if err != nil { - t.Fatalf("Subscribe: %v", err) + t.Fatalf("TrackStatus: %v", err) } - b := sub.Broker() - go func() { _ = b.Serve(t.Context(), func(message.Message) bool { return true }) }() + must(t, <-replied) + if len(ts.OK.TrackProperties) == 0 { + t.Error("TRACK_STATUS_OK lost its Track Properties") + } +} - ctx, cancel := context.WithCancel(t.Context()) - go func() { - stream := <-updateSeen - cancel() // the Update gives up before the answer arrives - _ = message.Marshal(stream, &message.RequestOK{TrackProperties: trackProps}) - }() - _, _ = sub.Update(ctx, message.Parameters{message.ForwardParam(false)}) - requireClosedProtocolViolation(t, client) +// TestUpdateHandlerTrackPropertiesRefused: an update handler returning Track +// Properties gets REQUEST_ERROR INTERNAL_ERROR sent instead; the session stays up. +func TestUpdateHandlerTrackPropertiesRefused(t *testing.T) { + client, sub, _ := servedPublicationWith(t, func(*message.RequestUpdate) (*message.RequestOK, error) { + return &message.RequestOK{TrackProperties: trackProps}, nil + }) + _, err := sub.Update(t.Context(), message.Parameters{message.ForwardParam(true)}) + rej, ok := errors.AsType[*session.RequestRejectedError](err) + if !ok || rej.Code != moqt.RequestInternalError { + t.Fatalf("Update = %v, want REQUEST_ERROR INTERNAL_ERROR", err) + } + requireStaysOpen(t, client, 50*time.Millisecond) } diff --git a/pkg/moqt/session/request_ok_props_test.go b/pkg/moqt/session/request_ok_props_test.go deleted file mode 100644 index 1ce0a3d6..00000000 --- a/pkg/moqt/session/request_ok_props_test.go +++ /dev/null @@ -1,210 +0,0 @@ -package session_test - -import ( - "context" - "errors" - "testing" - "time" - - "github.com/floatdrop/moq-go/pkg/moqt" - "github.com/floatdrop/moq-go/pkg/moqt/message" - "github.com/floatdrop/moq-go/pkg/moqt/session" - "github.com/floatdrop/moq-go/pkg/moqt/wire" -) - -// The send side of §10.5 (request_ok_properties_test.go has the receive -// side): the session refuses to send Track Properties in a REQUEST_OK the -// peer would have to close the session over, and sends nothing. - -// TestReplyRefusesTrackPropertiesWhereEmpty: Request.Reply returns -// ErrTrackPropertiesNotAllowed, the peer's session stays up, and the request -// can still be answered with an empty REQUEST_OK. -func TestReplyRefusesTrackPropertiesWhereEmpty(t *testing.T) { - ns := wire.TrackNamespace{[]byte("ns")} - for _, tc := range []struct { - name string - send func(context.Context, *session.Session) error - }{ - {"PUBLISH_OK", func(ctx context.Context, c *session.Session) error { - _, err := c.Publish(ctx, &message.Publish{Namespace: ns, Name: []byte("t")}) - return err - }}, - {"PUBLISH_NAMESPACE_OK", func(ctx context.Context, c *session.Session) error { - _, err := c.PublishNamespace(ctx, &message.PublishNamespace{Namespace: ns}) - return err - }}, - {"SUBSCRIBE_NAMESPACE_OK", func(ctx context.Context, c *session.Session) error { - _, err := c.SubscribeNamespace(ctx, &message.SubscribeNamespace{TrackNamespacePrefix: ns}) - return err - }}, - } { - t.Run(tc.name, func(t *testing.T) { - client, server := openPair(t) - replied := make(chan error, 1) - go func() { - req, err := server.AcceptRequest(t.Context()) - if err != nil { - replied <- err - return - } - err = req.Reply(&message.RequestOK{TrackProperties: trackProps}) - replied <- err - if err != nil { - _ = req.Reply(&message.RequestOK{}) - } - }() - if err := tc.send(t.Context(), client); err != nil { - t.Fatalf("request failed: %v", err) - } - if err := <-replied; !errors.Is(err, session.ErrTrackPropertiesNotAllowed) { - t.Fatalf("Reply with Track Properties = %v, want ErrTrackPropertiesNotAllowed", err) - } - select { - case <-client.Done(): - t.Fatalf("the peer closed the session: %v", client.Err()) - case <-time.After(50 * time.Millisecond): - } - }) - } -} - -// updater is a client request handle that can send REQUEST_UPDATE. -type updater interface { - Update(ctx context.Context, params message.Parameters) (*message.RequestOK, error) -} - -// streamUpdater updates a request whose handle has no Update method. -type streamUpdater struct { - s *session.Session - stream session.Stream -} - -func (u streamUpdater) Update(ctx context.Context, params message.Parameters) (*message.RequestOK, error) { - return u.s.UpdateRequest(ctx, u.stream, params) -} - -// TestReplyRefusesUpdateOKTrackProperties: on a SUBSCRIBE or FETCH stream the -// first answer is SUBSCRIBE_OK or FETCH_OK, so a REQUEST_OK written with -// Reply there is a REQUEST_UPDATE_OK, and §10.5 says its Track Properties are -// empty too. On a SUBSCRIBE_TRACKS stream so is every REQUEST_OK after the -// first. -func TestReplyRefusesUpdateOKTrackProperties(t *testing.T) { - for _, tc := range []struct { - name string - // answer sends the request's first response; open sends the request - // from the client and returns the handle to update it with. - answer func(*session.Request) error - open func(context.Context, *session.Session) (updater, error) - }{ - { - "SUBSCRIBE", - func(r *session.Request) error { return r.Reply(&message.SubscribeOK{TrackAlias: 1}) }, - func(ctx context.Context, c *session.Session) (updater, error) { - return c.Subscribe(ctx, &message.Subscribe{Name: []byte("t")}) - }, - }, - { - // §10.5 does not name the SUBSCRIBE_TRACKS OK, so its first - // REQUEST_OK may carry Track Properties; the ones after it answer - // REQUEST_UPDATEs. - "SUBSCRIBE_TRACKS", - func(r *session.Request) error { return r.Reply(&message.RequestOK{TrackProperties: trackProps}) }, - func(ctx context.Context, c *session.Session) (updater, error) { - ts, err := c.SubscribeTracks(ctx, &message.SubscribeTracks{ - TrackNamespacePrefix: wire.TrackNamespace{[]byte("ns")}, - }) - if err != nil { - return nil, err - } - return streamUpdater{c, ts.Stream}, nil - }, - }, - { - "FETCH", - func(r *session.Request) error { _, err := r.AcceptFetch(nil); return err }, - func(ctx context.Context, c *session.Session) (updater, error) { - return c.Fetch(ctx, &message.Fetch{Name: []byte("t")}) - }, - }, - } { - t.Run(tc.name, func(t *testing.T) { - client, server := openPair(t) - replied := make(chan error, 1) - go func() { - req, err := server.AcceptRequest(t.Context()) - if err != nil { - replied <- err - return - } - if err := tc.answer(req); err != nil { - replied <- err - return - } - if _, err := message.Parse(req.Stream); err != nil { // the REQUEST_UPDATE - replied <- err - return - } - err = req.Reply(&message.RequestOK{TrackProperties: trackProps}) - replied <- err - if err != nil { - _ = req.Reply(&message.RequestOK{}) - } - }() - h, err := tc.open(t.Context(), client) - must(t, err) - if _, err := h.Update(t.Context(), message.Parameters{message.ForwardParam(true)}); err != nil { - t.Fatalf("Update: %v", err) - } - if err := <-replied; !errors.Is(err, session.ErrTrackPropertiesNotAllowed) { - t.Fatalf("Reply with Track Properties = %v, want ErrTrackPropertiesNotAllowed", err) - } - select { - case <-client.Done(): - t.Fatalf("the peer closed the session: %v", client.Err()) - case <-time.After(50 * time.Millisecond): - } - }) - } -} - -// TestReplyKeepsTrackStatusProperties: TRACK_STATUS_OK is where Track -// Properties belong, so Reply sends them. -func TestReplyKeepsTrackStatusProperties(t *testing.T) { - client, server := openPair(t) - replied := make(chan error, 1) - go func() { - req, err := server.AcceptRequest(t.Context()) - if err != nil { - replied <- err - return - } - replied <- req.Reply(&message.RequestOK{TrackProperties: trackProps}) - }() - ts, err := client.TrackStatus(t.Context(), &message.TrackStatus{Name: []byte("t")}) - if err != nil { - t.Fatalf("TrackStatus: %v", err) - } - must(t, <-replied) - if len(ts.OK.TrackProperties) == 0 { - t.Error("TRACK_STATUS_OK lost its Track Properties") - } -} - -// TestUpdateHandlerTrackPropertiesRefused: an update handler that returns a -// REQUEST_UPDATE_OK with Track Properties gets a REQUEST_ERROR -// (INTERNAL_ERROR) sent in its place, and the session stays up. -func TestUpdateHandlerTrackPropertiesRefused(t *testing.T) { - client, sub, _ := servedPublicationWith(t, func(*message.RequestUpdate) (*message.RequestOK, error) { - return &message.RequestOK{TrackProperties: trackProps}, nil - }) - _, err := sub.Update(t.Context(), message.Parameters{message.ForwardParam(true)}) - rej, ok := errors.AsType[*session.RequestRejectedError](err) - if !ok || rej.Code != moqt.RequestInternalError { - t.Fatalf("Update = %v, want REQUEST_ERROR INTERNAL_ERROR", err) - } - select { - case <-client.Done(): - t.Fatalf("the session closed: %v", client.Err()) - case <-time.After(50 * time.Millisecond): - } -} diff --git a/pkg/moqt/session/request_reject_fault_test.go b/pkg/moqt/session/request_reject_fault_test.go deleted file mode 100644 index 711ea815..00000000 --- a/pkg/moqt/session/request_reject_fault_test.go +++ /dev/null @@ -1,100 +0,0 @@ -package session_test - -import ( - "context" - "errors" - "testing" - "time" - - "github.com/floatdrop/moq-go/pkg/moqt" - "github.com/floatdrop/moq-go/pkg/moqt/message" - "github.com/floatdrop/moq-go/pkg/moqt/session" - "github.com/floatdrop/moq-go/pkg/moqt/session/sessiontest" - "github.com/floatdrop/moq-go/pkg/moqt/wire" -) - -var errRejectWrite = errors.New("transport gone") - -// TestRejectError_ResetsTheStreamWhenTheErrorCannotBeSent pins what -// [session.Request.RejectError] must do when it cannot deliver the -// REQUEST_ERROR itself. -// -// §3.3.3 gives the responder two ways out of a request: send REQUEST_ERROR and -// FIN, or — "Receivers cancel requests if they are unable to or choose not to -// respond" — cancel the stream. A responder whose REQUEST_ERROR write fails has -// done neither unless it resets, and the requester is left waiting on a -// response that can never arrive. Only the session dying eventually frees it, -// which on a long-lived relay session is never. -// -// The write is faulted on the responder's side, so this is unreachable without -// [sessiontest.Faulty]: an in-process pipe never fails a write. -func TestRejectError_ResetsTheStreamWhenTheErrorCannotBeSent(t *testing.T) { - t.Parallel() - - // Responder-side ordinals: 1 and 2 are the inbound and outbound halves of - // the unidirectional control stream, so 3 is the first bidi request stream - // — the one the REQUEST_ERROR below is written to. - const firstRequestStream = 3 - rawClient, rawServer := sessiontest.NewConnPair() - serverConn := sessiontest.Faulty(rawServer, func(f sessiontest.FaultOp) error { - if f.Op == sessiontest.OpStreamWrite && f.Stream == firstRequestStream { - return errRejectWrite - } - return nil - }) - - ctx, cancel := context.WithCancel(t.Context()) - defer cancel() - - var client, server *session.Session - done := make(chan struct{}) - go func() { - defer close(done) - var err error - client, err = session.Client(ctx, rawClient) - if err != nil { - t.Errorf("session.Client: %v", err) - } - }() - var err error - if server, err = session.Server(ctx, serverConn); err != nil { - t.Fatalf("session.Server: %v", err) - } - <-done - if client == nil { - t.Fatal("client session not established") - } - t.Cleanup(func() { - _ = client.Close(0, "") - _ = server.Close(0, "") - }) - - // The requester. It must come back with an error rather than block: the - // responder cannot tell it why, but it must tell it *something*. - subErr := make(chan error, 1) - go func() { - _, err := client.Subscribe(ctx, &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, - Name: []byte("cam1"), - }) - subErr <- err - }() - - req, err := server.AcceptRequest(ctx) - if err != nil { - t.Fatalf("AcceptRequest: %v", err) - } - if err := req.RejectError(moqt.RequestDoesNotExist, "no such track"); !errors.Is(err, errRejectWrite) { - t.Fatalf("RejectError err = %v, want the faulted write error", err) - } - - select { - case err := <-subErr: - if err == nil { - t.Fatal("Subscribe succeeded, but its REQUEST_ERROR was never delivered") - } - case <-time.After(2 * time.Second): - t.Fatal("Subscribe is still waiting: RejectError left the request stream open " + - "after failing to write the REQUEST_ERROR, so the peer can never learn the request failed") - } -} diff --git a/pkg/moqt/session/request_rejected_test.go b/pkg/moqt/session/request_reject_test.go similarity index 52% rename from pkg/moqt/session/request_rejected_test.go rename to pkg/moqt/session/request_reject_test.go index b7fe161e..b0e0283e 100644 --- a/pkg/moqt/session/request_rejected_test.go +++ b/pkg/moqt/session/request_reject_test.go @@ -9,12 +9,12 @@ import ( "github.com/floatdrop/moq-go/pkg/moqt" "github.com/floatdrop/moq-go/pkg/moqt/message" "github.com/floatdrop/moq-go/pkg/moqt/session" + "github.com/floatdrop/moq-go/pkg/moqt/session/sessiontest" + "github.com/floatdrop/moq-go/pkg/moqt/wire" ) -// TestRequestRejectedErrorCarriesRetryInterval: §10.6.2 "Retry Interval: The -// minimum time (in milliseconds) before the request SHOULD be sent again, -// plus one. If the value is 0, the request SHOULD NOT be retried." The -// requester can only honor it if the rejection reports it. +// TestRequestRejectedErrorCarriesRetryInterval: REQUEST_ERROR's Retry Interval +// is the minimum retry delay plus one, 0 meaning do not retry (§10.6.2). func TestRequestRejectedErrorCarriesRetryInterval(t *testing.T) { for _, tc := range []struct { interval uint64 @@ -26,7 +26,7 @@ func TestRequestRejectedErrorCarriesRetryInterval(t *testing.T) { {501, 500 * time.Millisecond, true}, {math.MaxUint64, time.Duration(math.MaxInt64), true}, // largest varint (§1.4.1): clamped } { - client, server := openTokenPair(t) + client, server := openPair(t) go func() { r, err := server.AcceptRequest(t.Context()) if err != nil { @@ -55,46 +55,30 @@ func TestRequestRejectedErrorCarriesRetryInterval(t *testing.T) { } } -// TestUpdateHandlerRejectionKeepsRetryInterval: a *RequestRejectedError an -// UpdateHandler returns goes out as REQUEST_ERROR with its Retry Interval -// intact; dropping it would turn "retry later" into "SHOULD NOT be retried" -// (§10.6.2). +// TestUpdateHandlerRejectionKeepsRetryInterval: an UpdateHandler's +// *RequestRejectedError goes out with its Retry Interval intact (§10.6.2). func TestUpdateHandlerRejectionKeepsRetryInterval(t *testing.T) { - client, server := openTokenPair(t) - go func() { - r, err := server.AcceptRequest(t.Context()) - if err != nil { - return - } - pub, err := r.AcceptSubscribe(nil) - if err != nil { - return + client, server := openPair(t) + sub, pub := subscribePair(t, client, server) + b := pub.Broker() + b.HandleUpdates(func(*message.RequestUpdate) (*message.RequestOK, error) { + return nil, &session.RequestRejectedError{ + Code: moqt.RequestExcessiveLoad, + Reason: "busy", + RetryInterval: 5001, } - b := pub.Broker() - b.HandleUpdates(func(*message.RequestUpdate) (*message.RequestOK, error) { - return nil, &session.RequestRejectedError{ - Code: moqt.RequestExcessiveLoad, - Reason: "busy", - RetryInterval: 5001, - } - }) - _ = b.Serve(t.Context(), nil) - }() - sub, err := client.Subscribe(t.Context(), &message.Subscribe{Name: []byte("t")}) - if err != nil { - t.Fatalf("Subscribe: %v", err) - } - _, err = sub.Update(t.Context(), message.Parameters{message.ForwardParam(false)}) + }) + go func() { _ = b.Serve(t.Context(), nil) }() + + _, err := sub.Update(t.Context(), message.Parameters{message.ForwardParam(false)}) rej, ok := errors.AsType[*session.RequestRejectedError](err) if !ok || rej.RetryInterval != 5001 { t.Fatalf("Update = %v, want REQUEST_ERROR with Retry Interval 5001", err) } } -// TestRejectSendsRetryInterval: Request.Reject puts the rejection's Retry -// Interval on the wire (§10.6.2: "If a request is retryable with the same -// parameters at a later time, the sender of REQUEST_ERROR includes a non-zero -// Retry Interval"), which RejectError cannot express. +// TestRejectSendsRetryInterval: Request.Reject puts the Retry Interval on the +// wire (§10.6.2), which RejectError cannot express. func TestRejectSendsRetryInterval(t *testing.T) { client, server := openPair(t) go func() { @@ -116,11 +100,9 @@ func TestRejectSendsRetryInterval(t *testing.T) { } } -// TestRejectRefusesRedirect: §10.6.2 says the Redirect structure is "Present -// only when Error Code is REDIRECT", and RequestRejectedError has no way to -// carry one. A REDIRECT sent without it is malformed, and the peer closes the -// session over it. Reject refuses without writing, so the request can still -// be refused another way. +// TestRejectRefusesRedirect: a REDIRECT needs a Redirect structure (§10.6.2) +// that RequestRejectedError cannot carry, so Reject refuses without writing +// and the request can still be refused another way. func TestRejectRefusesRedirect(t *testing.T) { client, server := openPair(t) refused := make(chan error, 1) @@ -140,9 +122,53 @@ func TestRejectRefusesRedirect(t *testing.T) { if err := <-refused; err == nil { t.Fatal("Reject sent a REDIRECT without a Redirect structure") } + requireStaysOpen(t, client, 50*time.Millisecond) +} + +var errRejectWrite = errors.New("transport gone") + +// TestRejectError_ResetsTheStreamWhenTheErrorCannotBeSent: if the +// REQUEST_ERROR write fails, RejectError cancels the stream (§3.3.3) so the +// requester is not left waiting for a response that never comes. +func TestRejectError_ResetsTheStreamWhenTheErrorCannotBeSent(t *testing.T) { + t.Parallel() + + // Responder-side stream ordinals: 1 and 2 are the control streams, so 3 + // is the first bidi request stream, where the REQUEST_ERROR is written. + const firstRequestStream = 3 + rawClient, rawServer := sessiontest.NewConnPair() + serverConn := sessiontest.Faulty(rawServer, func(f sessiontest.FaultOp) error { + if f.Op == sessiontest.OpStreamWrite && f.Stream == firstRequestStream { + return errRejectWrite + } + return nil + }) + client, server := openSessions(t, rawClient, serverConn, nil, nil) + + subErr := make(chan error, 1) + go func() { + _, err := client.Subscribe(t.Context(), &message.Subscribe{ + Namespace: wire.TrackNamespace{[]byte("video")}, + Name: []byte("cam1"), + }) + subErr <- err + }() + + req, err := server.AcceptRequest(t.Context()) + if err != nil { + t.Fatalf("AcceptRequest: %v", err) + } + if err := req.RejectError(moqt.RequestDoesNotExist, "no such track"); !errors.Is(err, errRejectWrite) { + t.Fatalf("RejectError err = %v, want the faulted write error", err) + } + select { - case <-client.Done(): - t.Fatalf("the peer closed the session: %v", client.Err()) - case <-time.After(50 * time.Millisecond): + case err := <-subErr: + if err == nil { + t.Fatal("Subscribe succeeded, but its REQUEST_ERROR was never delivered") + } + case <-time.After(2 * time.Second): + t.Fatal("Subscribe is still waiting: RejectError left the request stream open " + + "after failing to write the REQUEST_ERROR, so the peer can never learn the request failed") } } diff --git a/pkg/moqt/session/request_test.go b/pkg/moqt/session/request_test.go index e5d7253a..8358b78c 100644 --- a/pkg/moqt/session/request_test.go +++ b/pkg/moqt/session/request_test.go @@ -170,10 +170,8 @@ func TestAcceptRequestUnblocksOnSessionClose(t *testing.T) { } } -// TestAcceptRequestDuplicateID verifies that when the peer sends a second -// request with the same Request ID, AcceptRequest returns ErrDuplicateRequestID -// per §10.1 (on "a duplicate Request ID, it MUST close the session with -// INVALID_REQUEST_ID"). +// TestAcceptRequestDuplicateID: a reused Request ID makes AcceptRequest return +// ErrDuplicateRequestID, for an INVALID_REQUEST_ID close (§10.1). func TestAcceptRequestDuplicateID(t *testing.T) { ctx := t.Context() client, server := openPair(t) diff --git a/pkg/moqt/session/request_update_limit_test.go b/pkg/moqt/session/request_update_limit_test.go index 0f2b4888..d64a8137 100644 --- a/pkg/moqt/session/request_update_limit_test.go +++ b/pkg/moqt/session/request_update_limit_test.go @@ -104,12 +104,9 @@ func TestAcceptRequestRejectsStrayRequestUpdate(t *testing.T) { } } -// TestAcceptRequestNonFirstOpenerClosesSession pins draft-20 Table 5: -// "Messages marked "First" MUST be the first message on a new request -// stream." Only SUBSCRIBE, PUBLISH, FETCH, TRACK_STATUS, PUBLISH_NAMESPACE, -// SUBSCRIBE_NAMESPACE and SUBSCRIBE_TRACKS may open one. Anything else is a -// PROTOCOL_VIOLATION that AcceptRequest closes the session on itself, rather -// than handing it to the application or trusting every caller to close. +// TestAcceptRequestNonFirstOpenerClosesSession: only the "First" messages of +// §10 Table 5 may open a request stream; AcceptRequest closes the session with +// PROTOCOL_VIOLATION on anything else. func TestAcceptRequestNonFirstOpenerClosesSession(t *testing.T) { t.Parallel() for _, first := range []message.Message{ diff --git a/pkg/moqt/session/session_bench_test.go b/pkg/moqt/session/session_bench_test.go index ccb81259..116610ef 100644 --- a/pkg/moqt/session/session_bench_test.go +++ b/pkg/moqt/session/session_bench_test.go @@ -31,6 +31,7 @@ var ( benchSinkU64 uint64 ) +// benchPayload returns n bytes of a repeating pattern. func benchPayload(n int) []byte { b := make([]byte, n) for i := range b { diff --git a/pkg/moqt/session/session_test.go b/pkg/moqt/session/session_test.go index 77189a34..f97f656b 100644 --- a/pkg/moqt/session/session_test.go +++ b/pkg/moqt/session/session_test.go @@ -16,333 +16,6 @@ import ( "github.com/floatdrop/moq-go/pkg/moqt/wire" ) -func openPair(t *testing.T) (*session.Session, *session.Session) { - t.Helper() - ctx := t.Context() - aConn, bConn := sessiontest.NewConnPair() - - var ( - wg sync.WaitGroup - aSess, bSess *session.Session - aErr, bErr error - ) - wg.Go(func() { - aSess, aErr = session.Client(ctx, aConn, - session.WithImplementation("mediamesh-test/client"), - ) - }) - wg.Go(func() { - bSess, bErr = session.Server(ctx, bConn, - session.WithImplementation("mediamesh-test/server"), - ) - }) - wg.Wait() - if aErr != nil { - t.Fatalf("client Open: %v", aErr) - } - if bErr != nil { - t.Fatalf("server Open: %v", bErr) - } - - // Close is idempotent (sync.Once), so tests are free to call it - // explicitly; this cleanup just guarantees we don't leak sessions on - // any code path. - t.Cleanup(func() { - if err := aSess.Close(moqt.SessionNoError, "test cleanup"); err != nil { - t.Errorf("client cleanup Close: %v", err) - } - if err := bSess.Close(moqt.SessionNoError, "test cleanup"); err != nil { - t.Errorf("server cleanup Close: %v", err) - } - }) - - return aSess, bSess -} - -// openPairWithLimits performs the SETUP handshake over a credit-capped conn -// pair. aBidiLimit caps the client's outbound bidi-stream credit (the SETUP -// control stream is unidirectional, so it is unaffected by the cap). A -// negative limit means unlimited. -func openPairWithLimits(t *testing.T, aBidiLimit int) (*session.Session, *session.Session) { - t.Helper() - ctx := t.Context() - aConn, bConn := sessiontest.NewConnPairWithLimits(aBidiLimit, -1) - - var ( - wg sync.WaitGroup - aSess, bSess *session.Session - aErr, bErr error - ) - wg.Go(func() { aSess, aErr = session.Client(ctx, aConn) }) - wg.Go(func() { bSess, bErr = session.Server(ctx, bConn) }) - wg.Wait() - if aErr != nil { - t.Fatalf("client Open: %v", aErr) - } - if bErr != nil { - t.Fatalf("server Open: %v", bErr) - } - t.Cleanup(func() { - _ = aSess.Close(moqt.SessionNoError, "test cleanup") - _ = bSess.Close(moqt.SessionNoError, "test cleanup") - }) - return aSess, bSess -} - -// TestClientSendsPathAndAuthority covers WithPath and WithAuthority, which had -// no test of any kind despite internal/dial putting AUTHORITY on every -// native-QUIC connection this repo makes. -// -// It asserts the option arrives under the right §15.4 codepoint, not merely -// that some option arrived: the value travelling under the wrong key is the -// failure a peer actually suffers, and §10.3 has it ignore the unrecognized -// option silently rather than error. Byte-level encoding is pinned separately -// in message.TestSetupOptionGoldenBytes. -func TestClientSendsPathAndAuthority(t *testing.T) { - ctx := t.Context() - clientConn, serverConn := sessiontest.NewConnPair() - - var ( - wg sync.WaitGroup - clientSess, serverSess *session.Session - clientErr, serverErr error - ) - wg.Go(func() { - clientSess, clientErr = session.Client(ctx, clientConn, - session.WithPath("/relay?room=1"), - session.WithAuthority("relay.example:4433"), - ) - }) - wg.Go(func() { - serverSess, serverErr = session.Server(ctx, serverConn) - }) - wg.Wait() - - if clientErr != nil { - t.Fatalf("client Open: %v", clientErr) - } - if serverErr != nil { - t.Fatalf("server Open: %v", serverErr) - } - t.Cleanup(func() { - _ = clientSess.Close(moqt.SessionNoError, "test cleanup") - _ = serverSess.Close(moqt.SessionNoError, "test cleanup") - }) - - want := map[uint64]string{ - uint64(message.SetupOptionPath): "/relay?room=1", - uint64(message.SetupOptionAuthority): "relay.example:4433", - } - got := make(map[uint64]string) - for _, opt := range serverSess.PeerOptions() { - got[opt.Type] = string(opt.ByteVal) - } - for typ, val := range want { - if got[typ] != val { - t.Errorf("server saw option 0x%02X = %q, want %q (all: %+v)", - typ, got[typ], val, serverSess.PeerOptions()) - } - } -} - -// TestClientRejectsServerSentPathAndAuthority covers the receive side of -// §10.3.1.1/§10.3.1.2. PATH and AUTHORITY are client-to-server only: each -// section says the option MUST NOT be used by the server and that a session -// receiving one from a server MUST be closed, with INVALID_PATH and -// INVALID_AUTHORITY respectively — 0x8 and 0x19 in the §3.5 registry. -// -// Until this landed the client stored whatever the server sent and inspected -// none of it, so a server could hand a client a PATH and the session carried on -// — the four §3.5 codes for these two options sat in errors.go with no -// reference anywhere, which is how the omission stayed invisible. -// -// The offending peer is hand-rolled rather than built from session.Server, -// because a moq-go server now refuses to send these at all (see -// TestRefusesToSendPathOrAuthority). That is the honest shape anyway: the -// scenario under test is a non-conforming third-party server. -func TestClientRejectsServerSentPathAndAuthority(t *testing.T) { - tests := []struct { - name string - opt wire.KVPair - }{ - {"PATH", message.PathOption("/relay")}, - {"AUTHORITY", message.AuthorityOption("relay.example:4433")}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Bounded so a failure inside the hand-rolled peer below surfaces - // as a one-second deadline rather than parking session.Client until - // the package-wide 10-minute panic. - ctx, cancel := context.WithTimeout(t.Context(), time.Second) - t.Cleanup(cancel) - clientConn, serverConn := sessiontest.NewConnPair() - - var wg sync.WaitGroup - wg.Go(func() { - // Both sides of SETUP are symmetric uni-streams: send ours, - // then drain theirs so the client's send half never blocks. - send, err := serverConn.OpenUniStream() - if err != nil { - t.Errorf("hand-rolled server: OpenUniStream: %v", err) - return - } - if err := message.Marshal(send, &message.Setup{Options: []wire.KVPair{tt.opt}}); err != nil { - t.Errorf("hand-rolled server: Marshal SETUP: %v", err) - return - } - if recv, err := serverConn.AcceptUniStream(ctx); err == nil { - _, _ = message.Parse(recv) - } - }) - - clientSess, clientErr := session.Client(ctx, clientConn) - wg.Wait() - - if clientErr == nil { - _ = clientSess.Close(moqt.SessionNoError, "test cleanup") - t.Fatalf("client accepted a server-sent %s option; want the session refused", tt.name) - } - if clientSess != nil { - t.Errorf("client returned a session alongside the error: %+v", clientSess) - } - // The reason travels to the peer, so it should name the offending - // option rather than being a bare "protocol violation". - if !strings.Contains(clientErr.Error(), tt.name) { - t.Errorf("error %q does not name the %s option", clientErr, tt.name) - } - }) - } -} - -// webTransportConn makes a sessiontest pipe claim to be WebTransport, which is -// all the session layer's optional-capability assertion looks for. A real -// WebTransport session is not needed to test the guard, and wiring one up here -// would test webtransport-go rather than this rule. -type webTransportConn struct{ session.Conn } - -func (webTransportConn) IsWebTransport() bool { return true } - -// TestServerRejectsPathOrAuthorityOverWebTransport covers the second of the -// three conditions §10.3.1.1/§10.3.1.2 name: an option "received while -// WebTransport is used" MUST close the session, whichever side received it. -// -// A server ignores these over native QUIC because receiving them there is what -// they are for, so the role gate alone is not enough — without the transport -// check the relay's WebTransport listener accepts a session an interop peer -// expects to be rejected, and nothing logs it. -func TestServerRejectsPathOrAuthorityOverWebTransport(t *testing.T) { - tests := []struct { - name string - opt wire.KVPair - }{ - {"PATH", message.PathOption("/relay")}, - {"AUTHORITY", message.AuthorityOption("relay.example:4433")}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - ctx, cancel := context.WithTimeout(t.Context(), time.Second) - t.Cleanup(cancel) - clientConn, serverConn := sessiontest.NewConnPair() - - var wg sync.WaitGroup - wg.Go(func() { - send, err := clientConn.OpenUniStream() - if err != nil { - t.Errorf("hand-rolled client: OpenUniStream: %v", err) - return - } - if err := message.Marshal(send, &message.Setup{Options: []wire.KVPair{tt.opt}}); err != nil { - t.Errorf("hand-rolled client: Marshal SETUP: %v", err) - return - } - if recv, err := clientConn.AcceptUniStream(ctx); err == nil { - _, _ = message.Parse(recv) - } - }) - - sess, err := session.Server(ctx, webTransportConn{serverConn}) - wg.Wait() - - if err == nil { - _ = sess.Close(moqt.SessionNoError, "test cleanup") - t.Fatalf( - "server accepted a %s option over WebTransport; §10.3.1 requires the session be closed", - tt.name) - } - if !strings.Contains(err.Error(), "WebTransport") { - t.Errorf("error %q does not explain the WebTransport restriction", err) - } - }) - } -} - -// TestRefusesToSendPathOrAuthority covers the send side of §10.3.1.1/§10.3.1.2: -// each says PATH and AUTHORITY "MUST NOT be used by the server, or when -// WebTransport is used". Before this, nothing stopped either — a WebTransport -// client calling WithAuthority produced a session a strict server would close, -// and the only thing preventing it was cmd/interop-client remembering not to. -// -// The open fails rather than dropping the option, so a caller cannot end up -// believing it requested an authority the peer never saw. -func TestRefusesToSendPathOrAuthority(t *testing.T) { - opts := map[string]session.Option{ - "PATH": session.WithPath("/relay"), - "AUTHORITY": session.WithAuthority("relay.example:4433"), - } - - // The guard rejects before any I/O, so a correct implementation returns - // instantly. The deadline exists so that if the guard is ever removed the - // open fails in a second with a deadline error instead of blocking forever - // on a handshake with a peer that does not exist. - openCtx := func(t *testing.T) context.Context { - t.Helper() - ctx, cancel := context.WithTimeout(t.Context(), time.Second) - t.Cleanup(cancel) - return ctx - } - - for name, opt := range opts { - t.Run(name+"/server", func(t *testing.T) { - _, serverConn := sessiontest.NewConnPair() - sess, err := session.Server(openCtx(t), serverConn, opt) - if err == nil { - _ = sess.Close(moqt.SessionNoError, "test cleanup") - t.Fatalf("server sent a %s option; §10.3.1 says it MUST NOT", name) - } - if !strings.Contains(err.Error(), name) { - t.Errorf("error %q does not name the %s option", err, name) - } - }) - - t.Run(name+"/webtransport", func(t *testing.T) { - clientConn, _ := sessiontest.NewConnPair() - sess, err := session.Client(openCtx(t), webTransportConn{clientConn}, opt) - if err == nil { - _ = sess.Close(moqt.SessionNoError, "test cleanup") - t.Fatalf("client sent a %s option over WebTransport; §10.3.1 says it MUST NOT", name) - } - if !strings.Contains(err.Error(), "WebTransport") { - t.Errorf("error %q does not explain the WebTransport restriction", err) - } - }) - } -} - -func TestHandshakeExchangesPeerOptions(t *testing.T) { - client, server := openPair(t) - - clientSawServer := client.PeerOptions() - if len(clientSawServer) != 1 || string(clientSawServer[0].ByteVal) != "mediamesh-test/server" { - t.Fatalf("client received wrong peer options: %+v", clientSawServer) - } - serverSawClient := server.PeerOptions() - if len(serverSawClient) != 1 || string(serverSawClient[0].ByteVal) != "mediamesh-test/client" { - t.Fatalf("server received wrong peer options: %+v", serverSawClient) - } -} - func TestRequestIDParity(t *testing.T) { client, server := openPair(t) @@ -509,10 +182,8 @@ func TestDuplicateGoawayClosesPeerSession(t *testing.T) { } } -// TestSendGoawayClientRejectsURI verifies that a client-side session rejects -// SendGoaway with a non-empty URI per §10.4: "A client MUST send a -// zero-length New Session URI in any GOAWAY". The server side must still be -// allowed to include one. +// TestSendGoawayClientRejectsURI: a client's GOAWAY carries an empty New +// Session URI (§10.4); a server's may carry one. func TestSendGoawayClientRejectsURI(t *testing.T) { client, server := openPair(t) @@ -533,40 +204,6 @@ func TestSendGoawayClientRejectsURI(t *testing.T) { } } -// failOpenConn wraps a working session.Conn but fails OpenUniStream -// immediately, simulating a peer that drops before the SETUP send half can -// finish. AcceptUniStream still delegates to the embedded conn and would -// block forever — exactly the situation that hung the old handshake. -type failOpenConn struct{ session.Conn } - -func (c *failOpenConn) OpenUniStream() (session.SendStream, error) { - return nil, errors.New("synthetic open failure") -} - -// TestHandshakeFailFastCancelsSibling verifies that if one side of the -// handshake fails fast, the other returns promptly via errgroup's derived -// context — i.e. we don't deadlock waiting on a stream the peer will never -// open. -func TestHandshakeFailFastCancelsSibling(t *testing.T) { - a, _ := sessiontest.NewConnPair() // b is unused so AcceptUniStream blocks - conn := &failOpenConn{Conn: a} - - done := make(chan error, 1) - go func() { - _, err := session.Client(t.Context(), conn) - done <- err - }() - - select { - case err := <-done: - if err == nil { - t.Fatal("expected handshake error, got nil") - } - case <-time.After(500 * time.Millisecond): - t.Fatal("handshake hung; errgroup did not cancel sibling") - } -} - func TestCloseTerminatesBothSides(t *testing.T) { client, server := openPair(t) @@ -594,162 +231,65 @@ func TestCloseTerminatesBothSides(t *testing.T) { } } -// TestGreaseRoundTrip verifies that a GREASE SETUP option injected via -// WithGrease() survives the handshake: the peer receives it in PeerOptions() -// and the handshake completes without error. This exercises the requirement -// from §14 that recipients MUST ignore unknown SETUP option types. -func TestGreaseRoundTrip(t *testing.T) { - ctx := t.Context() - aConn, bConn := sessiontest.NewConnPair() - - var ( - wg sync.WaitGroup - aSess, bSess *session.Session - aErr, bErr error - ) - wg.Go(func() { - aSess, aErr = session.Client(ctx, aConn, - session.WithImplementation("grease-test/client"), - session.WithGrease(), - ) - }) - wg.Go(func() { - bSess, bErr = session.Server(ctx, bConn, - session.WithImplementation("grease-test/server"), - session.WithGrease(), - ) - }) - wg.Wait() - if aErr != nil { - t.Fatalf("client handshake: %v", aErr) - } - if bErr != nil { - t.Fatalf("server handshake: %v", bErr) - } - t.Cleanup(func() { - aSess.Close(moqt.SessionNoError, "test cleanup") - bSess.Close(moqt.SessionNoError, "test cleanup") - }) - - // The server should see the client's GREASE option among peer options. - assertHasGrease := func(name string, opts []wire.KVPair) { - t.Helper() - var found bool - for _, kv := range opts { - if kv.Type >= 0x9D && (kv.Type-0x9D)%0x7F == 0 /* GREASE pattern */ { - found = true - break - } - } - if !found { - t.Errorf("%s: no GREASE option in PeerOptions %+v", name, opts) - } - } - assertHasGrease("server saw client GREASE", bSess.PeerOptions()) - assertHasGrease("client saw server GREASE", aSess.PeerOptions()) -} - -// closeRecordingConn records the code the session closes its conn with. -type closeRecordingConn struct { - session.Conn - - code chan uint64 -} - -func (c closeRecordingConn) CloseWithError(code uint64, reason string) error { - select { - case c.code <- code: - default: +// TestControlStreamViolationsCloseTheSession: after SETUP only GOAWAY is valid +// on the control stream (§10, Table 5); anything else closes the session with +// PROTOCOL_VIOLATION (§3.5) and a reason naming the rule. +func TestControlStreamViolationsCloseTheSession(t *testing.T) { + tests := []struct { + name string + offending message.Message + wantReason string + }{ + { + name: "a second SETUP", + offending: &message.Setup{}, + wantReason: "duplicate SETUP", + }, + { + // SUBSCRIBE is legal, but only as the first message of a request + // stream — never on the control stream. + name: "a request-stream message", + offending: &message.Subscribe{Namespace: wire.Namespace("demo"), Name: []byte("cam")}, + wantReason: "unexpected", + }, } - return c.Conn.CloseWithError(code, reason) -} -// TestServerClosesMalformedPathOrAuthority covers the syntax rule of -// §10.3.1.1/§10.3.1.2: "If an AUTHORITY option does not conform to these -// rules, the session MUST be closed with MALFORMED_AUTHORITY", and likewise -// PATH with MALFORMED_PATH. A well-formed pair still opens. The client is -// hand-rolled: a moq-go client refuses to send a malformed value (see -// TestRefusesToSendMalformedPathOrAuthority). -func TestServerClosesMalformedPathOrAuthority(t *testing.T) { - for _, tc := range []struct { - name string - opts []wire.KVPair - want moqt.SessionErrorCode // SessionNoError: the session opens - }{ - {"well-formed", []wire.KVPair{ - message.AuthorityOption("relay.example:4433"), message.PathOption("/relay?room=1"), - }, moqt.SessionNoError}, - {"AUTHORITY", []wire.KVPair{message.AuthorityOption("relay example")}, moqt.SessionMalformedAuthority}, - {"AUTHORITY empty host", []wire.KVPair{message.AuthorityOption(":4433")}, moqt.SessionMalformedAuthority}, - {"PATH", []wire.KVPair{message.PathOption("relay")}, moqt.SessionMalformedPath}, - } { - t.Run(tc.name, func(t *testing.T) { - ctx, cancel := context.WithTimeout(t.Context(), time.Second) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) t.Cleanup(cancel) - clientConn, serverConn := sessiontest.NewConnPair() - rec := closeRecordingConn{Conn: serverConn, code: make(chan uint64, 1)} + ourConn, peerConn := sessiontest.NewConnPair() + // Complete SETUP, then send the offending message on the control stream. var wg sync.WaitGroup wg.Go(func() { - send, err := clientConn.OpenUniStream() - if err != nil { - t.Errorf("hand-rolled client: OpenUniStream: %v", err) - return - } - if err := message.Marshal(send, &message.Setup{Options: tc.opts}); err != nil { - t.Errorf("hand-rolled client: Marshal SETUP: %v", err) - return - } - if recv, err := clientConn.AcceptUniStream(ctx); err == nil { - _, _ = message.Parse(recv) + if send := handRolledSetup(ctx, t, peerConn); send != nil { + _ = message.Marshal(send, tt.offending) } }) - sess, err := session.Server(ctx, rec) - wg.Wait() - if tc.want == moqt.SessionNoError { - if err != nil { - t.Fatalf("server refused well-formed PATH/AUTHORITY: %v", err) - } - _ = sess.Close(moqt.SessionNoError, "test cleanup") - return - } - if err == nil { - _ = sess.Close(moqt.SessionNoError, "test cleanup") - t.Fatalf("server accepted a malformed %s", tc.name) + + sess, err := session.Client(ctx, ourConn) + if err != nil { + t.Fatalf("Client: %v", err) } + wg.Wait() + select { - case code := <-rec.code: - if code != uint64(tc.want) { - t.Fatalf("closed with %#x, want %#x", code, uint64(tc.want)) - } - default: - t.Fatalf("server returned %v without closing the conn", err) + case <-sess.Done(): + case <-time.After(5 * time.Second): + t.Fatal("session stayed open after a control-stream violation") } - }) - } -} -// TestRefusesToSendMalformedPathOrAuthority: the send side of the syntax rule. -// A server closes the session with MALFORMED_PATH / MALFORMED_AUTHORITY on a -// value that is not RFC 3986 (§10.3.1.1–2), so the client refuses to open -// with one rather than send it. The values are ones uri.Parse, which goes -// through net/url, lets through. -func TestRefusesToSendMalformedPathOrAuthority(t *testing.T) { - for name, opt := range map[string]session.Option{ - "PATH": session.WithPath("/room?tags=[a,b]"), - "AUTHORITY": session.WithAuthority("[fe80::1%en0]:4433"), - } { - t.Run(name, func(t *testing.T) { - ctx, cancel := context.WithTimeout(t.Context(), time.Second) - t.Cleanup(cancel) - clientConn, _ := sessiontest.NewConnPair() - sess, err := session.Client(ctx, clientConn, opt) - if err == nil { - _ = sess.Close(moqt.SessionNoError, "test cleanup") - t.Fatalf("client sent a malformed %s option", name) + closed, ok := errors.AsType[*session.ClosedError](sess.Err()) + if !ok { + t.Fatalf("Err() = %v, want a *session.ClosedError", sess.Err()) + } + if closed.Code != moqt.SessionProtocolViolation { + t.Errorf("closed with code %#x, want PROTOCOL_VIOLATION (%#x)", + uint64(closed.Code), uint64(moqt.SessionProtocolViolation)) } - if !strings.Contains(err.Error(), name) { - t.Errorf("error %q does not name the %s option", err, name) + if !strings.Contains(closed.Reason, tt.wantReason) { + t.Errorf("reason %q does not mention %q", closed.Reason, tt.wantReason) } }) } diff --git a/pkg/moqt/session/setup_token_send_test.go b/pkg/moqt/session/setup_token_send_test.go deleted file mode 100644 index e9a5de88..00000000 --- a/pkg/moqt/session/setup_token_send_test.go +++ /dev/null @@ -1,99 +0,0 @@ -package session_test - -import ( - "context" - "slices" - "strings" - "testing" - "time" - - "github.com/floatdrop/moq-go/pkg/moqt" - "github.com/floatdrop/moq-go/pkg/moqt/message" - "github.com/floatdrop/moq-go/pkg/moqt/session" - "github.com/floatdrop/moq-go/pkg/moqt/session/sessiontest" -) - -// §10.3.1.4, the send side: "The endpoint can specify one or more tokens in -// SETUP that the peer can use to authorize MOQT session establishment." A -// REGISTER the peer's MAX_AUTH_TOKEN_CACHE_SIZE cannot hold is treated by the -// peer as USE_VALUE, and "the sender MUST handle registration failures of this -// kind by purging any Token Aliases that failed to register based on the -// peer's MAX_AUTH_TOKEN_CACHE_SIZE option in SETUP (or the default value of -// 0)." - -func register(alias uint64, value string) message.Token { - return message.Token{ - AliasType: message.AliasTypeRegister, - TokenAlias: alias, - TokenType: 1, - TokenValue: []byte(value), - } -} - -// TestSetupTokensSent: the tokens reach the peer, and the sender learns which -// REGISTERs the peer holds — those that fit its cache, in SETUP order. -func TestSetupTokensSent(t *testing.T) { - small := register(1, "0123456789") // 16+10 = 26 bytes - large := register(2, strings.Repeat("x", 100)) // 116 bytes: does not fit - tiny := register(3, "ab") // 18 bytes: fits after 1 - value := message.Token{AliasType: message.AliasTypeUseValue, TokenType: 1, TokenValue: []byte("v")} - client, server := openPairWithOpts(t, - []session.Option{ - session.WithSetupToken(small), session.WithSetupToken(large), - session.WithSetupToken(tiny), session.WithSetupToken(value), - }, - []session.Option{session.WithMaxAuthTokenCacheSize(26 + 18)}, - ) - - if got := client.SetupTokenAliases(); !slices.Equal(got, []uint64{1, 3}) { - t.Fatalf("SetupTokenAliases() = %v, want [1 3]: alias 2 did not fit the peer's cache", got) - } - if got := len(server.SetupTokens()); got != 4 { - t.Fatalf("server received %d setup tokens, want 4", got) - } - for alias, want := range map[uint64]bool{1: true, 2: false, 3: true} { - _, _, err := server.TokenCache().Resolve(alias) - if (err == nil) != want { - t.Errorf("server cache holds alias %d: %v, want %v", alias, err == nil, want) - } - } -} - -// TestSetupTokensDefaultCacheIsZero: a peer that advertises no cache size has -// the default 0, so no REGISTER is held. -func TestSetupTokensDefaultCacheIsZero(t *testing.T) { - client, _ := openPairWithOpts(t, []session.Option{session.WithSetupToken(register(1, "v"))}, nil) - if got := client.SetupTokenAliases(); len(got) != 0 { - t.Fatalf("SetupTokenAliases() = %v, want none: the peer's cache size defaults to 0", got) - } -} - -// TestSetupTokenRefused: DELETE and USE_ALIAS have no meaning in SETUP (a -// server that receives one "MUST close the session with a -// PROTOCOL_VIOLATION", §10.2.2), and a repeated REGISTER alias would close it -// with DUPLICATE_AUTH_TOKEN_ALIAS. The open fails before anything is sent. -func TestSetupTokenRefused(t *testing.T) { - for name, toks := range map[string][]message.Token{ - "DELETE": {{AliasType: message.AliasTypeDelete, TokenAlias: 1}}, - "USE_ALIAS": {{AliasType: message.AliasTypeUseAlias, TokenAlias: 1}}, - "duplicate alias": {register(1, "a"), register(1, "b")}, - } { - t.Run(name, func(t *testing.T) { - ctx, cancel := context.WithTimeout(t.Context(), time.Second) - defer cancel() - var opts []session.Option - for _, tok := range toks { - opts = append(opts, session.WithSetupToken(tok)) - } - clientConn, _ := sessiontest.NewConnPair() - sess, err := session.Client(ctx, clientConn, opts...) - if err == nil { - _ = sess.Close(moqt.SessionNoError, "test cleanup") - t.Fatal("the client opened with a setup token the peer must refuse") - } - if !strings.Contains(err.Error(), "AUTHORIZATION TOKEN") { - t.Errorf("error %q does not name the AUTHORIZATION TOKEN option", err) - } - }) - } -} diff --git a/pkg/moqt/session/setup_token_test.go b/pkg/moqt/session/setup_token_test.go index 8e4b4118..c5ca384d 100644 --- a/pkg/moqt/session/setup_token_test.go +++ b/pkg/moqt/session/setup_token_test.go @@ -1,9 +1,11 @@ package session_test import ( + "context" + "slices" + "strings" "sync" "testing" - "time" "github.com/floatdrop/moq-go/pkg/moqt" "github.com/floatdrop/moq-go/pkg/moqt/message" @@ -12,34 +14,16 @@ import ( "github.com/floatdrop/moq-go/pkg/moqt/wire" ) -// §10.3.1.4: the AUTHORIZATION TOKEN setup option is "functionally equivalent -// to the AUTHORIZATION TOKEN message parameter" (§10.2.2), carrying tokens -// "that the peer can use to authorize MOQT session establishment". +// AUTHORIZATION TOKEN setup option (§10.3.1.4), which carries tokens like the +// message parameter of the same name (§10.2.2). // tokenOption encodes tok as an AUTHORIZATION TOKEN setup option. func tokenOption(tok message.Token) wire.KVPair { return wire.KVPair{Type: uint64(message.SetupOptionAuthorizationToken), ByteVal: tok.Bytes()} } -// closeRecorder records the code its session closes the connection with, -// which sessiontest does not pass to the peer. -type closeRecorder struct { - session.Conn - - code chan uint64 -} - -func (c *closeRecorder) CloseWithError(code uint64, reason string) error { - select { - case c.code <- code: - default: - } - return c.Conn.CloseWithError(code, reason) -} - -// openWithClientOptions opens a pair whose client sends extra SETUP options, -// and returns the server's open error alongside the sessions and the code the -// server closed its connection with, if it did. +// openWithClientOptions opens a pair whose client sends extra raw SETUP options, returning the +// server's open error and the code its conn closed with, if any. func openWithClientOptions( t *testing.T, serverOpts []session.Option, @@ -47,7 +31,7 @@ func openWithClientOptions( ) (cli, srv *session.Session, srvClose <-chan uint64, srvErr error) { t.Helper() cliConn, rawSrv := sessiontest.NewConnPair() - srvConn := &closeRecorder{Conn: rawSrv, code: make(chan uint64, 1)} + srvConn := newCloseRecorder(rawSrv) cliOpts := make([]session.Option, 0, len(extra)) for _, kv := range extra { cliOpts = append(cliOpts, session.WithSetupOptionForTest(kv)) @@ -71,19 +55,6 @@ func openWithClientOptions( return cli, srv, srvConn.code, srvErr } -// requireClosedWith checks the code a connection was closed with. -func requireClosedWith(t *testing.T, closed <-chan uint64, want moqt.SessionErrorCode) { - t.Helper() - select { - case code := <-closed: - if code != uint64(want) { - t.Fatalf("closed with code %#x, want %#x", code, uint64(want)) - } - case <-time.After(2 * time.Second): - t.Fatalf("connection stayed open; want close with %#x", uint64(want)) - } -} - func TestSetupTokens(t *testing.T) { t.Parallel() const tokType = 7 @@ -107,14 +78,9 @@ func TestSetupTokens(t *testing.T) { cli, srv, _, err := openWithClientOptions( t, []session.Option{session.WithMaxAuthTokenCacheSize(1024)}, - tokenOption( - message.Token{ - AliasType: message.AliasTypeRegister, - TokenAlias: 3, - TokenType: tokType, - TokenValue: value, - }, - ), + tokenOption(message.Token{ + AliasType: message.AliasTypeRegister, TokenAlias: 3, TokenType: tokType, TokenValue: value, + }), ) if err != nil { t.Fatalf("server open: %v", err) @@ -141,21 +107,14 @@ func TestSetupTokens(t *testing.T) { t.Run("REGISTER over the cache size is used as a value", func(t *testing.T) { t.Parallel() - // No MAX_AUTH_TOKEN_CACHE_SIZE: the default 0 prohibits aliases. - // §10.3.1.4: the receiver "MUST NOT fail the session with - // AUTH_TOKEN_CACHE_OVERFLOW. Instead, it MUST treat the option as - // Alias Type USE_VALUE." + // No MAX_AUTH_TOKEN_CACHE_SIZE: the default 0 prohibits aliases, so + // the option is treated as USE_VALUE, not a cache overflow (§10.3.1.4). _, srv, _, err := openWithClientOptions( t, nil, - tokenOption( - message.Token{ - AliasType: message.AliasTypeRegister, - TokenAlias: 3, - TokenType: tokType, - TokenValue: value, - }, - ), + tokenOption(message.Token{ + AliasType: message.AliasTypeRegister, TokenAlias: 3, TokenType: tokType, TokenValue: value, + }), ) if err != nil { t.Fatalf("server open: %v", err) @@ -169,7 +128,7 @@ func TestSetupTokens(t *testing.T) { }) } -// TestSetupTokenViolations: tokens in SETUP that close the session. +// TestSetupTokenViolations: tokens in a received SETUP that close the session. func TestSetupTokenViolations(t *testing.T) { t.Parallel() cases := []struct { @@ -177,42 +136,17 @@ func TestSetupTokenViolations(t *testing.T) { opts []wire.KVPair want moqt.SessionErrorCode }{ - // §10.2.2: "If a server receives Alias Type DELETE (0x0) or USE_ALIAS - // (0x2) in a SETUP message, it MUST close the session with a - // PROTOCOL_VIOLATION." + // §10.2.2: DELETE or USE_ALIAS in SETUP is a PROTOCOL_VIOLATION. {"DELETE", []wire.KVPair{tokenOption(message.Token{AliasType: message.AliasTypeDelete, TokenAlias: 1})}, moqt.SessionProtocolViolation}, {"USE_ALIAS", []wire.KVPair{tokenOption(message.Token{AliasType: message.AliasTypeUseAlias, TokenAlias: 1})}, moqt.SessionProtocolViolation}, - // "If the Token structure cannot be decoded, the receiver MUST close - // the Session with KEY_VALUE_FORMATTING_ERROR." + // §10.2.2: an undecodable Token is a KEY_VALUE_FORMATTING_ERROR. {"undecodable", []wire.KVPair{{Type: uint64(message.SetupOptionAuthorizationToken), ByteVal: []byte{0x09}}}, moqt.SessionKeyValueFormattingError}, - // "The receiver of a message attempting to register an Alias which is - // already registered MUST close the Session with - // DUPLICATE_AUTH_TOKEN_ALIAS." - { - "duplicate REGISTER", - []wire.KVPair{ - tokenOption( - message.Token{ - AliasType: message.AliasTypeRegister, - TokenAlias: 1, - TokenType: 1, - TokenValue: []byte("a"), - }, - ), - tokenOption( - message.Token{ - AliasType: message.AliasTypeRegister, - TokenAlias: 1, - TokenType: 1, - TokenValue: []byte("b"), - }, - ), - }, - moqt.SessionDuplicateAuthTokenAlias, - }, + // §10.2.2: registering an Alias twice is DUPLICATE_AUTH_TOKEN_ALIAS. + {"duplicate REGISTER", []wire.KVPair{tokenOption(register(1, "a")), tokenOption(register(1, "b"))}, + moqt.SessionDuplicateAuthTokenAlias}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -226,3 +160,77 @@ func TestSetupTokenViolations(t *testing.T) { }) } } + +// --------------------------------------------------------------------------- +// Send side: a REGISTER the peer's MAX_AUTH_TOKEN_CACHE_SIZE cannot hold is +// used as a value, so the sender purges it (§10.3.1.4). +// --------------------------------------------------------------------------- + +// register builds a REGISTER token of Token Type 1. +func register(alias uint64, value string) message.Token { + return message.Token{ + AliasType: message.AliasTypeRegister, + TokenAlias: alias, + TokenType: 1, + TokenValue: []byte(value), + } +} + +// TestSetupTokensSent: the tokens reach the peer, and the sender learns which +// REGISTERs the peer holds — those that fit its cache, in SETUP order. +func TestSetupTokensSent(t *testing.T) { + small := register(1, "0123456789") // 16+10 = 26 bytes + large := register(2, strings.Repeat("x", 100)) // 116 bytes: does not fit + tiny := register(3, "ab") // 18 bytes: fits after 1 + value := message.Token{AliasType: message.AliasTypeUseValue, TokenType: 1, TokenValue: []byte("v")} + client, server := openPairWithOpts(t, + []session.Option{ + session.WithSetupToken(small), session.WithSetupToken(large), + session.WithSetupToken(tiny), session.WithSetupToken(value), + }, + []session.Option{session.WithMaxAuthTokenCacheSize(26 + 18)}, + ) + + if got := client.SetupTokenAliases(); !slices.Equal(got, []uint64{1, 3}) { + t.Fatalf("SetupTokenAliases() = %v, want [1 3]: alias 2 did not fit the peer's cache", got) + } + if got := len(server.SetupTokens()); got != 4 { + t.Fatalf("server received %d setup tokens, want 4", got) + } + for alias, want := range map[uint64]bool{1: true, 2: false, 3: true} { + _, _, err := server.TokenCache().Resolve(alias) + if (err == nil) != want { + t.Errorf("server cache holds alias %d: %v, want %v", alias, err == nil, want) + } + } +} + +// TestSetupTokensDefaultCacheIsZero: a peer that advertises no cache size has +// the default 0, so no REGISTER is held. +func TestSetupTokensDefaultCacheIsZero(t *testing.T) { + client, _ := openPairWithOpts(t, []session.Option{session.WithSetupToken(register(1, "v"))}, nil) + if got := client.SetupTokenAliases(); len(got) != 0 { + t.Fatalf("SetupTokenAliases() = %v, want none: the peer's cache size defaults to 0", got) + } +} + +// TestSetupTokenRefused: the client refuses to send a SETUP token the server +// must close on (DELETE, USE_ALIAS, a repeated alias; §10.2.2). +func TestSetupTokenRefused(t *testing.T) { + for name, toks := range map[string][]message.Token{ + "DELETE": {{AliasType: message.AliasTypeDelete, TokenAlias: 1}}, + "USE_ALIAS": {{AliasType: message.AliasTypeUseAlias, TokenAlias: 1}}, + "duplicate alias": {register(1, "a"), register(1, "b")}, + } { + t.Run(name, func(t *testing.T) { + var opts []session.Option + for _, tok := range toks { + opts = append(opts, session.WithSetupToken(tok)) + } + requireRefusedOpen(t, "AUTHORIZATION TOKEN", func(ctx context.Context) (*session.Session, error) { + clientConn, _ := sessiontest.NewConnPair() + return session.Client(ctx, clientConn, opts...) + }) + }) + } +} diff --git a/pkg/moqt/session/subscribe_test.go b/pkg/moqt/session/subscribe_test.go index 4a7aa5fe..1438e273 100644 --- a/pkg/moqt/session/subscribe_test.go +++ b/pkg/moqt/session/subscribe_test.go @@ -644,10 +644,9 @@ func TestIncomingSubgroupStreamTrackKey(t *testing.T) { }) } -// TestSharedTrackAliasTakesLatestProperties: when a second subscription shares -// an alias (§5.1), its Track Properties replace the first's (§2.5: "the most -// recent set SHOULD replace any cached values"), so its DEFAULT_PUBLISHER_PRIORITY -// is what inheriting subgroups get. +// TestSharedTrackAliasTakesLatestProperties: a second subscription sharing an +// alias (§5.1) replaces the cached Track Properties (§2.5), so its +// DEFAULT_PUBLISHER_PRIORITY is what inheriting subgroups get. func TestSharedTrackAliasTakesLatestProperties(t *testing.T) { cli, _ := openPair(t) key := track.NewKey(wire.TrackNamespace{[]byte("ns")}, []byte("t")) diff --git a/pkg/moqt/session/token_verify_test.go b/pkg/moqt/session/token_verify_test.go index b85993c4..308d309e 100644 --- a/pkg/moqt/session/token_verify_test.go +++ b/pkg/moqt/session/token_verify_test.go @@ -4,59 +4,14 @@ import ( "context" "errors" "fmt" - "sync" "testing" "github.com/floatdrop/moq-go/pkg/moqt" "github.com/floatdrop/moq-go/pkg/moqt/message" "github.com/floatdrop/moq-go/pkg/moqt/session" - "github.com/floatdrop/moq-go/pkg/moqt/session/sessiontest" ) -// openTokenPair opens a client/server pair where the server is configured -// with the given options (typically WithMaxAuthTokenCacheSize and/or -// WithTokenVerifier). The client is plain. Both sessions are closed on -// cleanup. -func openTokenPair(t *testing.T, serverOpts ...session.Option) (client, server *session.Session) { - t.Helper() - ctx := t.Context() - aConn, bConn := sessiontest.NewConnPair() - - var ( - wg sync.WaitGroup - aErr, bErr error - ) - wg.Go(func() { - client, aErr = session.Client(ctx, aConn, - session.WithImplementation("mediamesh-test/client"), - ) - }) - wg.Go(func() { - server, bErr = session.Server(ctx, bConn, - append([]session.Option{session.WithImplementation("mediamesh-test/server")}, serverOpts...)..., - ) - }) - wg.Wait() - if aErr != nil { - t.Fatalf("client Open: %v", aErr) - } - if bErr != nil { - t.Fatalf("server Open: %v", bErr) - } - t.Cleanup(func() { - _ = client.Close(moqt.SessionNoError, "test cleanup") - _ = server.Close(moqt.SessionNoError, "test cleanup") - }) - return client, server -} - -// sendSubscribeWithTokens opens a request stream from client carrying a -// SUBSCRIBE with the given tokens as AUTHORIZATION_TOKEN parameters. The open -// runs in a background goroutine because OpenRequest writes the SUBSCRIBE -// synchronously and the pipe-backed write blocks until the peer accepts the -// stream and reads it — the caller is expected to invoke AcceptRequest on the -// server side. The opened stream is cancelled on cleanup so neither side blocks -// after the test. +// sendSubscribeWithTokens is sendSubscribeWithParams with toks as AUTHORIZATION TOKEN parameters. func sendSubscribeWithTokens(t *testing.T, client *session.Session, toks ...message.Token) { t.Helper() var ps message.Parameters @@ -66,6 +21,7 @@ func sendSubscribeWithTokens(t *testing.T, client *session.Session, toks ...mess sendSubscribeWithParams(t, client, ps) } +// sendSubscribeWithParams opens a SUBSCRIBE with ps in the background for the caller to accept. func sendSubscribeWithParams(t *testing.T, client *session.Session, ps message.Parameters) { t.Helper() sub := &message.Subscribe{ @@ -96,7 +52,7 @@ func sendSubscribeWithParams(t *testing.T, client *session.Session, ps message.P // and that a later USE_ALIAS on a separate request resolves to the same // (Type, Value). func TestAcceptRequestResolvesRegisterToken(t *testing.T) { - client, server := openTokenPair(t, session.WithMaxAuthTokenCacheSize(4096)) + client, server := openPair(t, session.WithMaxAuthTokenCacheSize(4096)) // Request 1: REGISTER alias 7 → (type 9, "secret"). sendSubscribeWithTokens(t, client, message.Token{ @@ -137,7 +93,7 @@ func TestAcceptRequestResolvesRegisterToken(t *testing.T) { // directly without touching the cache (so it works even with aliasing // prohibited, maxSize=0). func TestAcceptRequestUseValueToken(t *testing.T) { - client, server := openTokenPair(t) // no cache budget: aliasing prohibited + client, server := openPair(t) // no cache budget: aliasing prohibited sendSubscribeWithTokens(t, client, message.Token{ AliasType: message.AliasTypeUseValue, @@ -160,7 +116,7 @@ func TestAcceptRequestUseValueToken(t *testing.T) { // same alias twice is a session-level fault carrying // SessionDuplicateAuthTokenAlias. func TestAcceptRequestDuplicateAliasIsSessionError(t *testing.T) { - client, server := openTokenPair(t, session.WithMaxAuthTokenCacheSize(4096)) + client, server := openPair(t, session.WithMaxAuthTokenCacheSize(4096)) reg := message.Token{AliasType: message.AliasTypeRegister, TokenAlias: 1, TokenType: 1, TokenValue: []byte("a")} sendSubscribeWithTokens(t, client, reg) @@ -186,7 +142,7 @@ func TestAcceptRequestDuplicateAliasIsSessionError(t *testing.T) { // TestAcceptRequestUnknownAliasIsSessionError verifies that USE_ALIAS for an // unregistered alias yields SessionUnknownAuthTokenAlias. func TestAcceptRequestUnknownAliasIsSessionError(t *testing.T) { - client, server := openTokenPair(t, session.WithMaxAuthTokenCacheSize(4096)) + client, server := openPair(t, session.WithMaxAuthTokenCacheSize(4096)) sendSubscribeWithTokens(t, client, message.Token{ AliasType: message.AliasTypeUseAlias, @@ -209,7 +165,7 @@ func TestAcceptRequestUnknownAliasIsSessionError(t *testing.T) { // no negotiated cache budget (maxSize=0) a REGISTER is an overflow fault per // §10.3.1.3. func TestAcceptRequestRegisterProhibitedIsOverflow(t *testing.T) { - client, server := openTokenPair(t) // maxSize 0 + client, server := openPair(t) // maxSize 0 sendSubscribeWithTokens(t, client, message.Token{ AliasType: message.AliasTypeRegister, @@ -238,7 +194,7 @@ func TestVerifyRequestTokensAllow(t *testing.T) { seen = tok return nil }) - client, server := openTokenPair(t, session.WithTokenVerifier(verifier)) + client, server := openPair(t, session.WithTokenVerifier(verifier)) sendSubscribeWithTokens(t, client, message.Token{ AliasType: message.AliasTypeUseValue, @@ -263,7 +219,7 @@ func TestVerifyRequestTokensDeny(t *testing.T) { verifier := session.TokenVerifierFunc(func(_ context.Context, _ *session.Session, _ session.ResolvedToken) error { return session.DenyToken(moqt.RequestExpiredAuthToken, "token expired") }) - client, server := openTokenPair(t, session.WithTokenVerifier(verifier)) + client, server := openPair(t, session.WithTokenVerifier(verifier)) sendSubscribeWithTokens(t, client, message.Token{ AliasType: message.AliasTypeUseValue, @@ -293,7 +249,7 @@ func TestVerifyRequestTokensWrapsPlainError(t *testing.T) { verifier := session.TokenVerifierFunc(func(_ context.Context, _ *session.Session, _ session.ResolvedToken) error { return errors.New("bad signature") }) - client, server := openTokenPair(t, session.WithTokenVerifier(verifier)) + client, server := openPair(t, session.WithTokenVerifier(verifier)) sendSubscribeWithTokens(t, client, message.Token{ AliasType: message.AliasTypeUseValue, @@ -318,7 +274,7 @@ func TestVerifyRequestTokensWrapsPlainError(t *testing.T) { // verifier, VerifyRequestTokens is a no-op (nil) even for token-bearing // requests. func TestVerifyRequestTokensNoVerifier(t *testing.T) { - client, server := openTokenPair(t) // no verifier + client, server := openPair(t) // no verifier sendSubscribeWithTokens(t, client, message.Token{ AliasType: message.AliasTypeUseValue, @@ -337,7 +293,7 @@ func TestVerifyRequestTokensNoVerifier(t *testing.T) { // TestTokenCacheAccessor verifies that the session exposes a non-nil cache // sized from WithMaxAuthTokenCacheSize. func TestTokenCacheAccessor(t *testing.T) { - _, server := openTokenPair(t, session.WithMaxAuthTokenCacheSize(2048)) + _, server := openPair(t, session.WithMaxAuthTokenCacheSize(2048)) cache := server.TokenCache() if cache == nil { t.Fatal("TokenCache() = nil") @@ -354,7 +310,7 @@ func TestTokenCacheAccessor(t *testing.T) { // USE_ALIAS on a fresh request resolves instead of killing the session with // UNKNOWN_AUTH_TOKEN_ALIAS. func TestProcessFollowupTokensRegistersAlias(t *testing.T) { - client, server := openTokenPair(t, session.WithMaxAuthTokenCacheSize(4096)) + client, server := openPair(t, session.WithMaxAuthTokenCacheSize(4096)) // Request 1: a plain SUBSCRIBE establishing the stream the update rides. // Opened inline (not via sendSubscribeWithTokens) because the client @@ -439,25 +395,13 @@ func TestProcessFollowupTokensRegistersAlias(t *testing.T) { // defines and its callers match on. var errVerifierPolicy = errors.New("policy: subject not permitted") -// TestVerifyRequestTokensPreservesVerifierSentinel covers the one thing -// TokenDeniedError.Unwrap exists for. -// -// VerifyRequestTokens normalises a plain verifier error into a -// *TokenDeniedError so the request layer has a REQUEST_ERROR code to send. That -// normalisation is also where a third-party verifier's own sentinel would be -// lost: the caller receives our type, not theirs. Unwrap is the documented -// survival path, and nothing exercised it — the sibling test above asserts the -// wrapping happens but never matches through it, which is why Unwrap sat at 0%. -// -// An authorization policy that cannot tell "denied because expired" from -// "denied because forbidden" after the error crosses this boundary is a real -// loss for anyone implementing TokenVerifier, and it would break silently: -// errors.Is simply starts returning false. +// TestVerifyRequestTokensPreservesVerifierSentinel: a verifier's own error stays +// matchable with errors.Is through the *TokenDeniedError wrapping (Unwrap). func TestVerifyRequestTokensPreservesVerifierSentinel(t *testing.T) { verifier := session.TokenVerifierFunc(func(_ context.Context, _ *session.Session, _ session.ResolvedToken) error { return fmt.Errorf("checking subject: %w", errVerifierPolicy) }) - client, server := openTokenPair(t, session.WithTokenVerifier(verifier)) + client, server := openPair(t, session.WithTokenVerifier(verifier)) sendSubscribeWithTokens(t, client, message.Token{ AliasType: message.AliasTypeUseValue, @@ -479,10 +423,8 @@ func TestVerifyRequestTokensPreservesVerifierSentinel(t *testing.T) { } } -// TestAcceptRequestUndecodableTokenIsKeyValueFormattingError pins §10.2.2: "If -// the Token structure cannot be decoded, the receiver MUST close the Session -// with KEY_VALUE_FORMATTING_ERROR." An unknown Alias Type is undecodable too: -// the Alias Type is what says which fields follow. +// TestAcceptRequestUndecodableTokenIsKeyValueFormattingError: an undecodable +// Token, including an unknown Alias Type, is KEY_VALUE_FORMATTING_ERROR (§10.2.2). func TestAcceptRequestUndecodableTokenIsKeyValueFormattingError(t *testing.T) { for _, tc := range []struct { name string @@ -495,7 +437,7 @@ func TestAcceptRequestUndecodableTokenIsKeyValueFormattingError(t *testing.T) { {"USE_ALIAS with trailing bytes", []byte{byte(message.AliasTypeUseAlias), 0x05, 0xFF}}, } { t.Run(tc.name, func(t *testing.T) { - client, server := openTokenPair(t) + client, server := openPair(t) sendSubscribeWithParams(t, client, message.Parameters{ message.BytesParam(message.ParamAuthorizationToken, tc.raw), }) diff --git a/pkg/moqt/session/track_properties_test.go b/pkg/moqt/session/track_properties_test.go index 6c6355f9..4c58f702 100644 --- a/pkg/moqt/session/track_properties_test.go +++ b/pkg/moqt/session/track_properties_test.go @@ -9,43 +9,9 @@ import ( "github.com/floatdrop/moq-go/pkg/moqt" "github.com/floatdrop/moq-go/pkg/moqt/message" "github.com/floatdrop/moq-go/pkg/moqt/session" - "github.com/floatdrop/moq-go/pkg/moqt/session/sessiontest" "github.com/floatdrop/moq-go/pkg/moqt/wire" ) -// openPairWithOpts creates a client/server session pair where each side can -// receive custom session.Option values. This is needed to test -// WithKnownMandatoryTrackProperties. -func openPairWithOpts(t *testing.T, clientOpts, serverOpts []session.Option) (*session.Session, *session.Session) { - t.Helper() - ctx := t.Context() - aConn, bConn := sessiontest.NewConnPair() - - var ( - wg sync.WaitGroup - aSess, bSess *session.Session - aErr, bErr error - ) - wg.Go(func() { - aSess, aErr = session.Client(ctx, aConn, clientOpts...) - }) - wg.Go(func() { - bSess, bErr = session.Server(ctx, bConn, serverOpts...) - }) - wg.Wait() - if aErr != nil { - t.Fatalf("client Open: %v", aErr) - } - if bErr != nil { - t.Fatalf("server Open: %v", bErr) - } - t.Cleanup(func() { - aSess.Close(moqt.SessionNoError, "test cleanup") - bSess.Close(moqt.SessionNoError, "test cleanup") - }) - return aSess, bSess -} - // mandatoryTrackProps builds raw Track Properties bytes containing a single // mandatory track property with the given type and varint value. func mandatoryTrackProps(propType message.PropertyType, val uint64) []byte { @@ -109,17 +75,9 @@ func TestValidateTrackProperties_Empty(t *testing.T) { } } -// TestValidateTrackProperties_MalformedBytes covers the parse-failure branch. -// A truncated pair must surface as a parse error rather than as -// *ErrUnsupportedMandatoryTrackProperty: §2.5.1 attaches specific remedies to -// the latter — REQUEST_ERROR / UNSUPPORTED_EXTENSION when it arrives on -// PUBLISH, cancelling the subscription or fetch (§3.3.3) when it arrives on -// SUBSCRIBE_OK or FETCH_OK — none of which fit Track Properties that simply -// would not decode. -// -// 0x02 is a one-byte Delta Type varint (§1.4.3). It resolves to Type 2 because -// this is the first pair and the running type total starts at zero, and an even -// Type carries a varint value — truncated off the end here. +// TestValidateTrackProperties_MalformedBytes: a truncated pair is a parse error, +// not *ErrUnsupportedMandatoryTrackProperty, whose §2.5.1 remedies do not fit. +// 0x02 is Delta Type 2 (§1.4.3), an even Type whose varint value is missing. func TestValidateTrackProperties_MalformedBytes(t *testing.T) { pairs, err := session.ValidateTrackProperties([]byte{0x02}, nil, "SUBSCRIBE_OK") if err == nil { @@ -137,12 +95,9 @@ func TestValidateTrackProperties_MalformedBytes(t *testing.T) { } } -// TestValidateTrackProperties_InsideImmutable: §12.7 "When looking for the -// value of a property, processors MUST search both the mutable properties and -// the contents of Immutable Properties", so a Mandatory Track Property there -// is screened like one in the mutable list (§2.5.1), and Immutable -// Properties whose contents do not parse make the Track Properties malformed -// ("A Key-Value-Pair cannot be parsed"). +// TestValidateTrackProperties_InsideImmutable: Immutable Properties are searched +// too (§12.7), so a Mandatory Track Property inside is screened (§2.5.1) and +// unparseable contents make the Track Properties malformed. func TestValidateTrackProperties_InsideImmutable(t *testing.T) { wrap := func(nested []byte) []byte { return message.AppendTrackProperties([]wire.KVPair{ @@ -164,17 +119,8 @@ func TestValidateTrackProperties_InsideImmutable(t *testing.T) { } } -// TestErrUnsupportedMandatoryTrackPropertyError pins the rendered message of -// the exported error, which nothing else formats — the other tests all match it -// by type, so the string itself went unchecked despite being public and -// log-facing. -// -// It asserts the parts a reader depends on rather than the whole line: the -// package prefix, the offending type in hex, the caller's context, and the -// §2.5.1 reference. Full-string equality was the first cut and is worse — it -// breaks on any behaviour-neutral rewording, and it cannot catch the stale -// section number it appears to guard, since the expected text is a copy of the -// format string and a renumbering would edit both together. +// TestErrUnsupportedMandatoryTrackPropertyError pins the parts of the error text +// a reader depends on: package prefix, type in hex, context, and §2.5.1. func TestErrUnsupportedMandatoryTrackPropertyError(t *testing.T) { err := &session.ErrUnsupportedMandatoryTrackProperty{ PropertyType: 0x5000, diff --git a/pkg/moqt/session/track_status_test.go b/pkg/moqt/session/track_status_test.go index c13188c6..65efbb9d 100644 --- a/pkg/moqt/session/track_status_test.go +++ b/pkg/moqt/session/track_status_test.go @@ -119,14 +119,9 @@ func TestTrackStatusRejected(t *testing.T) { wg.Wait() } -// §10.15: "The bidi stream is closed with a FIN after TRACK_STATUS_OK or -// REQUEST_ERROR are sent" — "the subscriber cannot send REQUEST_UPDATE". And -// §3.3.2: a requester "MAY FIN immediately after sending a message if it will -// not send a REQUEST_UPDATE". - -// TestTrackStatusStreamClosesBothWays: after TRACK_STATUS_OK the responder -// FINs its side, and the requester, which can never send a REQUEST_UPDATE, -// FINs its own. +// TestTrackStatusStreamClosesBothWays: the responder FINs after +// TRACK_STATUS_OK (§10.15), and the requester, which cannot send +// REQUEST_UPDATE, FINs its side too (§3.3.2). func TestTrackStatusStreamClosesBothWays(t *testing.T) { client, server := openPair(t) accepted := make(chan session.Stream, 1) @@ -151,87 +146,51 @@ func TestTrackStatusStreamClosesBothWays(t *testing.T) { } } -// TestTrackStatusRequestUpdateClosesSession pins §10.9: "An endpoint that -// receives a REQUEST_UPDATE other than in the two cases above MUST close the -// session with a PROTOCOL_VIOLATION." TRACK_STATUS is not one of them. -func TestTrackStatusRequestUpdateClosesSession(t *testing.T) { - client, server := openPair(t) - go func() { - req, err := server.AcceptRequest(t.Context()) - if err != nil { - return - } - _ = req.AcceptTrackStatus(nil) - }() - - stream, err := session.OpenRequestForTest(client, &message.TrackStatus{ - RequestID: client.AllocRequestID(), - Name: []byte("t"), - }) - if err != nil { - t.Fatalf("open TRACK_STATUS: %v", err) - } - if _, err := message.Parse(stream); err != nil { // TRACK_STATUS_OK - t.Fatalf("read TRACK_STATUS_OK: %v", err) - } - // From a goroutine: on the unbuffered test pipe the write only completes - // if the server reads it. - go func() { _ = message.Marshal(stream, &message.RequestUpdate{RequestID: client.AllocRequestID()}) }() - requireClosedProtocolViolation(t, server) -} - -// readWithin parses one message from s, failing the wait after d. -func readWithin(s session.Stream, d time.Duration) (message.Message, error) { - type result struct { - msg message.Message - err error - } - got := make(chan result, 1) - go func() { - msg, err := message.Parse(s) - got <- result{msg, err} - }() - select { - case r := <-got: - return r.msg, r.err - case <-time.After(d): - return nil, errors.New("timed out: the stream is still open") - } -} - -// TestTrackStatusMalformedFollowupClosesSession: bytes after TRACK_STATUS -// that do not even parse — an unknown type — close the session too. "An -// endpoint that receives an unknown message type MUST close the session" -// (§10); only the requester's FIN, a reset or session close end quietly. -func TestTrackStatusMalformedFollowupClosesSession(t *testing.T) { - client, server := openPair(t) - go func() { - req, err := server.AcceptRequest(t.Context()) - if err != nil { - return - } - _ = req.AcceptTrackStatus(nil) - }() +// TestTrackStatusFollowupClosesSession: anything the requester sends after +// TRACK_STATUS closes the session with PROTOCOL_VIOLATION — a REQUEST_UPDATE +// (§10.9) or an unknown message type (§10). +func TestTrackStatusFollowupClosesSession(t *testing.T) { + for _, tc := range []struct { + name string + write func(session.Stream, *session.Session) error + }{ + {"REQUEST_UPDATE", func(s session.Stream, client *session.Session) error { + return message.Marshal(s, &message.RequestUpdate{RequestID: client.AllocRequestID()}) + }}, + {"unknown message type", func(s session.Stream, _ *session.Session) error { + return wire.WriteFrame(s, 0x3F00, nil) // unassigned type + }}, + } { + t.Run(tc.name, func(t *testing.T) { + client, server := openPair(t) + go func() { + req, err := server.AcceptRequest(t.Context()) + if err != nil { + return + } + _ = req.AcceptTrackStatus(nil) + }() - stream, err := session.OpenRequestForTest(client, &message.TrackStatus{ - RequestID: client.AllocRequestID(), - Name: []byte("t"), - }) - if err != nil { - t.Fatalf("open TRACK_STATUS: %v", err) - } - if _, err := message.Parse(stream); err != nil { - t.Fatalf("read TRACK_STATUS_OK: %v", err) + stream, err := session.OpenRequestForTest(client, &message.TrackStatus{ + RequestID: client.AllocRequestID(), + Name: []byte("t"), + }) + if err != nil { + t.Fatalf("open TRACK_STATUS: %v", err) + } + if _, err := message.Parse(stream); err != nil { + t.Fatalf("read TRACK_STATUS_OK: %v", err) + } + // From a goroutine: the write completes only as the server reads. + go func() { _ = tc.write(stream, client) }() + requireClosedProtocolViolation(t, server) + }) } - go func() { _ = wire.WriteFrame(stream, 0x3F00, nil) }() // unassigned type - requireClosedProtocolViolation(t, server) } -// TestAcceptPublishTrackPropertiesRejected pins §2.5.1 for the session's -// PUBLISH receiver: "For PUBLISH messages: the subscriber MUST respond with -// REQUEST_ERROR with error code UNSUPPORTED_EXTENSION" when the Track -// Properties carry a Mandatory Track Property it does not understand. Track -// Properties that do not parse are refused as MALFORMED_TRACK. +// TestAcceptPublishTrackPropertiesRejected: a PUBLISH with an unknown +// Mandatory Track Property is refused with UNSUPPORTED_EXTENSION (§2.5.1), and +// unparseable Track Properties with MALFORMED_TRACK. func TestAcceptPublishTrackPropertiesRejected(t *testing.T) { for _, tc := range []struct { name string @@ -244,7 +203,7 @@ func TestAcceptPublishTrackPropertiesRejected(t *testing.T) { {"malformed", []byte{0x01}, moqt.RequestMalformedTrack}, } { t.Run(tc.name, func(t *testing.T) { - client, server := openTokenPair(t, + client, server := openPair(t, session.WithKnownMandatoryTrackProperties(map[message.PropertyType]struct{}{})) accepted := make(chan error, 1) go func() { From 9abdcc4227248539b41addb48e67d15e136f7d63 Mon Sep 17 00:00:00 2001 From: Vsevolod Strukchinsky Date: Sat, 26 Sep 2026 10:10:28 +0500 Subject: [PATCH 05/12] test(message): fold one-fix property and range-filter tests into topical files Move the immutable-search and object-properties tests into properties_test.go and the range-filter update tests into rangefilter_eval_test.go, share the property builders, and trim history from test comments. Test-only. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/moqt/message/goaway_test.go | 5 +- pkg/moqt/message/immutable_search_test.go | 57 --------- pkg/moqt/message/message_bench_test.go | 1 + pkg/moqt/message/object_properties_test.go | 80 ------------ pkg/moqt/message/properties_test.go | 129 ++++++++++++++++---- pkg/moqt/message/rangefilter_eval_test.go | 111 +++++++++++++++++ pkg/moqt/message/rangefilter_test.go | 1 + pkg/moqt/message/rangefilter_update_test.go | 108 ---------------- pkg/moqt/message/setup_test.go | 22 +--- pkg/moqt/message/subgroup_object_test.go | 12 +- 10 files changed, 226 insertions(+), 300 deletions(-) delete mode 100644 pkg/moqt/message/immutable_search_test.go delete mode 100644 pkg/moqt/message/object_properties_test.go delete mode 100644 pkg/moqt/message/rangefilter_update_test.go diff --git a/pkg/moqt/message/goaway_test.go b/pkg/moqt/message/goaway_test.go index efe0f743..41984603 100644 --- a/pkg/moqt/message/goaway_test.go +++ b/pkg/moqt/message/goaway_test.go @@ -32,9 +32,8 @@ func TestGoawayOversizedURIRejected(t *testing.T) { } } -// TestGoawayHugeURILengthRejected feeds a GOAWAY whose New Session URI length -// is 2^63 — a valid draft-20 varint (§1.4.1) that overflows int. Parse must -// return an error rather than panic, since any peer can send this frame. +// TestGoawayHugeURILengthRejected: a 2^63 URI length is a valid varint +// (§1.4.1) that overflows int; Parse must error, not panic. func TestGoawayHugeURILengthRejected(t *testing.T) { w := wire.NewWriter(nil) w.Varint(1 << 63) diff --git a/pkg/moqt/message/immutable_search_test.go b/pkg/moqt/message/immutable_search_test.go deleted file mode 100644 index 4bde0234..00000000 --- a/pkg/moqt/message/immutable_search_test.go +++ /dev/null @@ -1,57 +0,0 @@ -package message - -import ( - "testing" - "time" - - "github.com/floatdrop/moq-go/pkg/moqt/wire" -) - -// §12.7: "When looking for the value of a property, processors MUST search -// both the mutable properties and the contents of Immutable Properties." - -// immutable wraps pairs in an Immutable Properties property. -func immutable(pairs ...wire.KVPair) wire.KVPair { - return wire.KVPair{Type: PropertyImmutableProperties, ByteVal: AppendTrackProperties(pairs)} -} - -func TestApplyObjectPropertiesSearchesImmutable(t *testing.T) { - track := DeliveryTimeouts{Object: 5 * time.Second, Subgroup: 5 * time.Second} - raw := AppendTrackProperties([]wire.KVPair{immutable( - wire.KVPair{Type: PropertyObjectDeliveryTimeout, IntVal: 2000}, - wire.KVPair{Type: PropertySubgroupDeliveryTimeout, IntVal: 3000}, - )}) - want := DeliveryTimeouts{Object: 2 * time.Second, Subgroup: 3 * time.Second} - if got := track.ApplyObjectProperties(raw); got != want { - t.Errorf("got %+v, want %+v from inside Immutable Properties", got, want) - } - - // Present in both: the mutable value wins, as for - // TrackDefaultPublisherPriority. - raw = AppendTrackProperties([]wire.KVPair{ - {Type: PropertyObjectDeliveryTimeout, IntVal: 1000}, - immutable(wire.KVPair{Type: PropertyObjectDeliveryTimeout, IntVal: 2000}), - }) - if got := track.ApplyObjectProperties(raw); got.Object != time.Second { - t.Errorf("Object = %v, want the mutable 1s over the immutable 2s", got.Object) - } -} - -func TestRangeFiltersSearchImmutable(t *testing.T) { - inImmutable := func(typ PropertyType, val uint64) []byte { - return AppendTrackProperties([]wire.KVPair{immutable(wire.KVPair{Type: typ, IntVal: val})}) - } - track := mustSet(t, - RangeFilter{Type: ParamTrackPropertyFilter, PropertyType: 0x40, Ranges: []Range{{Start: 1, End: 5}}}) - if !track.MatchesTrack(inImmutable(0x40, 3)) { - t.Error("MatchesTrack: 0x40=3 inside Immutable Properties should match [1,5]") - } - if pass := track.TrackPassPerGroup(inImmutable(0x40, 3)); len(pass) != 1 || !pass[0] { - t.Errorf("TrackPassPerGroup = %v, want [true]", pass) - } - obj := mustSet(t, - RangeFilter{Type: ParamObjectPropertyFilter, PropertyType: 0x3C, Ranges: []Range{{Start: 40, End: 50}}}) - if !obj.MatchesObject(0, 0, 0, inImmutable(0x3C, 42)) { - t.Error("MatchesObject: 0x3C=42 inside Immutable Properties should match [40,50]") - } -} diff --git a/pkg/moqt/message/message_bench_test.go b/pkg/moqt/message/message_bench_test.go index 6d4a7d4b..cd346cbe 100644 --- a/pkg/moqt/message/message_bench_test.go +++ b/pkg/moqt/message/message_bench_test.go @@ -136,6 +136,7 @@ func (b *rwBuffer) Reset() { // import collision with the rest of the package's test files. var errEOF = rwEOF{} +// rwEOF is the error type behind errEOF. type rwEOF struct{} func (rwEOF) Error() string { return "EOF" } diff --git a/pkg/moqt/message/object_properties_test.go b/pkg/moqt/message/object_properties_test.go deleted file mode 100644 index 72dd54e5..00000000 --- a/pkg/moqt/message/object_properties_test.go +++ /dev/null @@ -1,80 +0,0 @@ -package message - -import ( - "testing" - - "github.com/floatdrop/moq-go/pkg/moqt/wire" -) - -// TestCheckObjectProperties pins the per-Object malformed-track conditions of -// §12.7–§12.9 and §2.5.1. Conditions that need state across Objects (a gap -// covering an Object already received, differing gaps within a Group) are -// not checked here. -func TestCheckObjectProperties(t *testing.T) { - kv := func(typ PropertyType, v uint64) wire.KVPair { return wire.KVPair{Type: typ, IntVal: v} } - imm := func(pairs ...wire.KVPair) wire.KVPair { - return wire.KVPair{Type: PropertyImmutableProperties, ByteVal: AppendTrackProperties(pairs)} - } - const group, object = 10, 5 - for _, tc := range []struct { - name string - raw []byte - valid bool - }{ - {"empty", nil, true}, - {"ordinary properties", AppendTrackProperties([]wire.KVPair{kv(0x40, 1), kv(0x42, 2)}), true}, - {"gaps within range", AppendTrackProperties([]wire.KVPair{ - kv(PropertyPriorGroupIDGap, group), kv(PropertyPriorObjectIDGap, object), - }), true}, - {"gap inside Immutable", AppendTrackProperties([]wire.KVPair{imm(kv(PropertyPriorObjectIDGap, 2))}), true}, - {"same type in both lists", AppendTrackProperties([]wire.KVPair{kv(0x40, 1), imm(kv(0x40, 2))}), true}, - - // "A Key-Value-Pair cannot be parsed" (§12.7) - {"unparseable", []byte{0x02}, false}, - {"unparseable inside Immutable", AppendTrackProperties([]wire.KVPair{ - {Type: PropertyImmutableProperties, ByteVal: []byte{0x02}}, - }), false}, - // §12.7: nested Immutable, and "An Object MUST NOT contain more than - // one instance of this property" - {"Immutable inside Immutable", AppendTrackProperties([]wire.KVPair{imm(imm(kv(0x40, 1)))}), false}, - {"two Immutable", AppendTrackProperties([]wire.KVPair{imm(kv(0x40, 1)), imm(kv(0x42, 1))}), false}, - // §12.8 / §12.9: more than one instance, or larger than the ID - {"two Prior Group ID Gaps", AppendTrackProperties([]wire.KVPair{ - kv(PropertyPriorGroupIDGap, 1), kv(PropertyPriorGroupIDGap, 2), - }), false}, - {"Prior Group ID Gap in both lists", AppendTrackProperties([]wire.KVPair{ - kv(PropertyPriorGroupIDGap, 1), imm(kv(PropertyPriorGroupIDGap, 1)), - }), false}, - {"Prior Group ID Gap > Group ID", AppendTrackProperties([]wire.KVPair{ - kv(PropertyPriorGroupIDGap, group+1), - }), false}, - {"two Prior Object ID Gaps", AppendTrackProperties([]wire.KVPair{ - kv(PropertyPriorObjectIDGap, 1), kv(PropertyPriorObjectIDGap, 1), - }), false}, - {"Prior Object ID Gap > Object ID", AppendTrackProperties([]wire.KVPair{ - imm(kv(PropertyPriorObjectIDGap, object+1)), - }), false}, - // §2.5.1: "An Object received with a Mandatory Track Property as an - // Object Property is malformed" - {"Mandatory Track Property", AppendTrackProperties([]wire.KVPair{kv(0x4000, 1)}), false}, - {"Mandatory inside Immutable", AppendTrackProperties([]wire.KVPair{imm(kv(0x7FFE, 1))}), false}, - } { - err := CheckObjectProperties(tc.raw, group, object) - if (err == nil) != tc.valid { - t.Errorf("%s: CheckObjectProperties = %v, want valid=%v", tc.name, err, tc.valid) - } - } -} - -func BenchmarkCheckObjectProperties(b *testing.B) { - raw := AppendTrackProperties([]wire.KVPair{ - {Type: 0x40, IntVal: 1}, {Type: PropertyPriorObjectIDGap, IntVal: 1}, - {Type: PropertyImmutableProperties, ByteVal: AppendTrackProperties([]wire.KVPair{{Type: 0x42, IntVal: 7}})}, - }) - b.ReportAllocs() - for b.Loop() { - if err := CheckObjectProperties(raw, 10, 5); err != nil { - b.Fatal(err) - } - } -} diff --git a/pkg/moqt/message/properties_test.go b/pkg/moqt/message/properties_test.go index 601fc098..16e5ddd7 100644 --- a/pkg/moqt/message/properties_test.go +++ b/pkg/moqt/message/properties_test.go @@ -2,17 +2,18 @@ package message import ( "testing" + "time" "github.com/floatdrop/moq-go/pkg/moqt/wire" ) -// --------------------------------------------------------------------------- -// ObjectProperties round-trip -// --------------------------------------------------------------------------- +// immutable wraps pairs in an Immutable Properties property (§12.7). +func immutable(pairs ...wire.KVPair) wire.KVPair { + return wire.KVPair{Type: PropertyImmutableProperties, ByteVal: AppendTrackProperties(pairs)} +} -// --------------------------------------------------------------------------- -// ObjectProperties.ValidateObjectScope -// --------------------------------------------------------------------------- +// kv builds a varint-valued property. +func kv(typ PropertyType, v uint64) wire.KVPair { return wire.KVPair{Type: typ, IntVal: v} } // --------------------------------------------------------------------------- // IsMandatoryTrackProperty @@ -179,24 +180,13 @@ func TestFirstUnknownMandatoryTrackProperty(t *testing.T) { } } -// --------------------------------------------------------------------------- -// PropertyScopeOf -// --------------------------------------------------------------------------- - -// --------------------------------------------------------------------------- -// Wire format: ObjectProperties length prefix correctness -// --------------------------------------------------------------------------- - -// TestTrackDefaultPublisherPriority pins §12.4: an omitted property is 128, and -// an invalid (> 255) value or malformed block is read as omitted rather than -// truncated into a different priority. §12.7: the value is also found inside -// Immutable Properties. +// TestTrackDefaultPublisherPriority pins §12.4: omitted is 128, and an invalid +// (> 255) value or malformed block reads as omitted. §12.7: Immutable +// Properties are searched too. func TestTrackDefaultPublisherPriority(t *testing.T) { - prop := func(v uint64) []byte { - return AppendTrackProperties([]wire.KVPair{{Type: PropertyDefaultPublisherPriority, IntVal: v}}) - } - immutable := func(inner []byte) []byte { - return AppendTrackProperties([]wire.KVPair{{Type: PropertyImmutableProperties, ByteVal: inner}}) + prop := func(v uint64) []byte { return props(PropertyDefaultPublisherPriority, v) } + inImmutable := func(v uint64) []byte { + return AppendTrackProperties([]wire.KVPair{immutable(kv(PropertyDefaultPublisherPriority, v))}) } cases := []struct { name string @@ -208,8 +198,8 @@ func TestTrackDefaultPublisherPriority(t *testing.T) { {"max", prop(255), 255}, {"invalid 256", prop(256), DefaultPublisherPriority}, {"malformed block", []byte{0x0E}, DefaultPublisherPriority}, - {"inside Immutable Properties", immutable(prop(10)), 10}, - {"mutable before immutable", append(prop(20), immutable(prop(10))...), 20}, + {"inside Immutable Properties", inImmutable(10), 10}, + {"mutable before immutable", append(prop(20), inImmutable(10)...), 20}, } for _, tc := range cases { if got := TrackDefaultPublisherPriority(tc.props); got != tc.want { @@ -217,3 +207,92 @@ func TestTrackDefaultPublisherPriority(t *testing.T) { } } } + +// TestApplyObjectPropertiesSearchesImmutable: §12.7 processors search both the +// mutable properties and Immutable Properties; the mutable value wins. +func TestApplyObjectPropertiesSearchesImmutable(t *testing.T) { + track := DeliveryTimeouts{Object: 5 * time.Second, Subgroup: 5 * time.Second} + raw := AppendTrackProperties([]wire.KVPair{immutable( + kv(PropertyObjectDeliveryTimeout, 2000), + kv(PropertySubgroupDeliveryTimeout, 3000), + )}) + want := DeliveryTimeouts{Object: 2 * time.Second, Subgroup: 3 * time.Second} + if got := track.ApplyObjectProperties(raw); got != want { + t.Errorf("got %+v, want %+v from inside Immutable Properties", got, want) + } + + // Present in both: the mutable value wins, as for + // TrackDefaultPublisherPriority. + raw = AppendTrackProperties([]wire.KVPair{ + kv(PropertyObjectDeliveryTimeout, 1000), + immutable(kv(PropertyObjectDeliveryTimeout, 2000)), + }) + if got := track.ApplyObjectProperties(raw); got.Object != time.Second { + t.Errorf("Object = %v, want the mutable 1s over the immutable 2s", got.Object) + } +} + +// TestCheckObjectProperties pins the per-Object malformed-track conditions of +// §12.7–§12.9 and §2.5.1; conditions needing state across Objects are not +// checked here. +func TestCheckObjectProperties(t *testing.T) { + const group, object = 10, 5 + for _, tc := range []struct { + name string + raw []byte + valid bool + }{ + {"empty", nil, true}, + {"ordinary properties", AppendTrackProperties([]wire.KVPair{kv(0x40, 1), kv(0x42, 2)}), true}, + {"gaps within range", AppendTrackProperties([]wire.KVPair{ + kv(PropertyPriorGroupIDGap, group), kv(PropertyPriorObjectIDGap, object), + }), true}, + {"gap inside Immutable", AppendTrackProperties([]wire.KVPair{immutable(kv(PropertyPriorObjectIDGap, 2))}), true}, + {"same type in both lists", AppendTrackProperties([]wire.KVPair{kv(0x40, 1), immutable(kv(0x40, 2))}), true}, + + // §12.7: a Key-Value-Pair cannot be parsed + {"unparseable", []byte{0x02}, false}, + {"unparseable inside Immutable", AppendTrackProperties([]wire.KVPair{ + {Type: PropertyImmutableProperties, ByteVal: []byte{0x02}}, + }), false}, + // §12.7: nested Immutable, or more than one instance + {"Immutable inside Immutable", AppendTrackProperties([]wire.KVPair{immutable(immutable(kv(0x40, 1)))}), false}, + {"two Immutable", AppendTrackProperties([]wire.KVPair{immutable(kv(0x40, 1)), immutable(kv(0x42, 1))}), false}, + // §12.8 / §12.9: more than one instance, or larger than the ID + {"two Prior Group ID Gaps", AppendTrackProperties([]wire.KVPair{ + kv(PropertyPriorGroupIDGap, 1), kv(PropertyPriorGroupIDGap, 2), + }), false}, + {"Prior Group ID Gap in both lists", AppendTrackProperties([]wire.KVPair{ + kv(PropertyPriorGroupIDGap, 1), immutable(kv(PropertyPriorGroupIDGap, 1)), + }), false}, + {"Prior Group ID Gap > Group ID", AppendTrackProperties([]wire.KVPair{ + kv(PropertyPriorGroupIDGap, group+1), + }), false}, + {"two Prior Object ID Gaps", AppendTrackProperties([]wire.KVPair{ + kv(PropertyPriorObjectIDGap, 1), kv(PropertyPriorObjectIDGap, 1), + }), false}, + {"Prior Object ID Gap > Object ID", AppendTrackProperties([]wire.KVPair{ + immutable(kv(PropertyPriorObjectIDGap, object+1)), + }), false}, + // §2.5.1: a Mandatory Track Property as an Object Property + {"Mandatory Track Property", AppendTrackProperties([]wire.KVPair{kv(0x4000, 1)}), false}, + {"Mandatory inside Immutable", AppendTrackProperties([]wire.KVPair{immutable(kv(0x7FFE, 1))}), false}, + } { + err := CheckObjectProperties(tc.raw, group, object) + if (err == nil) != tc.valid { + t.Errorf("%s: CheckObjectProperties = %v, want valid=%v", tc.name, err, tc.valid) + } + } +} + +func BenchmarkCheckObjectProperties(b *testing.B) { + raw := AppendTrackProperties([]wire.KVPair{ + kv(0x40, 1), kv(PropertyPriorObjectIDGap, 1), immutable(kv(0x42, 7)), + }) + b.ReportAllocs() + for b.Loop() { + if err := CheckObjectProperties(raw, 10, 5); err != nil { + b.Fatal(err) + } + } +} diff --git a/pkg/moqt/message/rangefilter_eval_test.go b/pkg/moqt/message/rangefilter_eval_test.go index 672674fe..67d1225f 100644 --- a/pkg/moqt/message/rangefilter_eval_test.go +++ b/pkg/moqt/message/rangefilter_eval_test.go @@ -197,3 +197,114 @@ func TestMixedScopeSetID(t *testing.T) { t.Error("sanity: naive MatchesObject ignores the track filter and passes objectID 150") } } + +// TestRangeFiltersSearchImmutable: §12.7 filters see properties inside +// Immutable Properties. +func TestRangeFiltersSearchImmutable(t *testing.T) { + inImmutable := func(typ PropertyType, val uint64) []byte { + return AppendTrackProperties([]wire.KVPair{immutable(kv(typ, val))}) + } + track := mustSet(t, + RangeFilter{Type: ParamTrackPropertyFilter, PropertyType: 0x40, Ranges: []Range{{Start: 1, End: 5}}}) + if !track.MatchesTrack(inImmutable(0x40, 3)) { + t.Error("MatchesTrack: 0x40=3 inside Immutable Properties should match [1,5]") + } + if pass := track.TrackPassPerGroup(inImmutable(0x40, 3)); len(pass) != 1 || !pass[0] { + t.Errorf("TrackPassPerGroup = %v, want [true]", pass) + } + obj := mustSet(t, + RangeFilter{Type: ParamObjectPropertyFilter, PropertyType: 0x3C, Ranges: []Range{{Start: 40, End: 50}}}) + if !obj.MatchesObject(0, 0, 0, inImmutable(0x3C, 42)) { + t.Error("MatchesObject: 0x3C=42 inside Immutable Properties should match [40,50]") + } +} + +// rangeParam builds a one-range filter parameter of type typ. +func rangeParam(typ ParamID, lo, hi uint64) Parameter { + return RangeFilterParam(&RangeFilter{Type: typ, Ranges: []Range{{Start: lo, End: hi}}}) +} + +// removeFilter builds the zero-length filter parameter that removes typ. +func removeFilter(typ ParamID) Parameter { return BytesParam(typ, nil) } + +// TestRangeFiltersZeroLengthIsNoFilter: §5.1.4 outside REQUEST_UPDATE a +// zero-length Range Filter is no filter. +func TestRangeFiltersZeroLengthIsNoFilter(t *testing.T) { + t.Parallel() + set, err := RangeFiltersFromParams(Parameters{removeFilter(ParamSubgroupFilter)}) + if err != nil { + t.Fatalf("RangeFiltersFromParams(zero-length) = %v, want no filter", err) + } + if set != nil { + t.Fatalf("RangeFiltersFromParams(zero-length) = %+v, want nil (no filter)", set) + } +} + +// TestRangeFilterSetUpdate: §5.1.4 an update replaces or removes (Length 0) the +// filter types it names and leaves the others unchanged. +func TestRangeFilterSetUpdate(t *testing.T) { + t.Parallel() + base, err := RangeFiltersFromParams(Parameters{ + rangeParam(ParamSubgroupFilter, 1, 1), rangeParam(ParamPriorityFilter, 0, 10), + }) + if err != nil { + t.Fatalf("base: %v", err) + } + // passes reports whether an Object in subgroup sg at priority prio passes. + passes := func(s *RangeFilterSet, sg uint64, prio uint8) bool { + return s == nil || s.MatchesObject(sg, 0, prio, nil) + } + + t.Run("remove one type", func(t *testing.T) { + got, err := base.Update(Parameters{removeFilter(ParamSubgroupFilter)}) + if err != nil { + t.Fatalf("Update: %v", err) + } + if !passes(got, 7, 5) || passes(got, 7, 20) { + t.Fatal("after removing SUBGROUP_FILTER: want any subgroup, priority still 0..10") + } + }) + t.Run("replace one type", func(t *testing.T) { + got, err := base.Update(Parameters{rangeParam(ParamSubgroupFilter, 2, 2)}) + if err != nil { + t.Fatalf("Update: %v", err) + } + if passes(got, 1, 5) || !passes(got, 2, 5) || passes(got, 2, 20) { + t.Fatal("after replacing SUBGROUP_FILTER: want subgroup 2 only, priority still 0..10") + } + }) + t.Run("omitted types unchanged", func(t *testing.T) { + got, err := base.Update(Parameters{ForwardParam(true)}) + if err != nil { + t.Fatalf("Update: %v", err) + } + if !passes(got, 1, 5) || passes(got, 2, 5) || passes(got, 1, 20) { + t.Fatal("an update naming no filter changed the filters") + } + }) + t.Run("remove every type", func(t *testing.T) { + got, err := base.Update(Parameters{removeFilter(ParamSubgroupFilter), removeFilter(ParamPriorityFilter)}) + if err != nil { + t.Fatalf("Update: %v", err) + } + if got != nil { + t.Fatalf("Update removing every filter = %+v, want nil (no filter)", got) + } + }) + t.Run("from no filters", func(t *testing.T) { + var none *RangeFilterSet + got, err := none.Update(Parameters{rangeParam(ParamSubgroupFilter, 3, 3)}) + if err != nil { + t.Fatalf("Update: %v", err) + } + if passes(got, 1, 5) || !passes(got, 3, 5) { + t.Fatal("an update adding SUBGROUP_FILTER to no filters: want subgroup 3 only") + } + }) + t.Run("duplicate within the update", func(t *testing.T) { + dup := Parameters{rangeParam(ParamSubgroupFilter, 2, 2), rangeParam(ParamSubgroupFilter, 3, 3)} + if _, err := base.Update(dup); err == nil { + t.Fatal("an update repeating (Type, SetID) was accepted; §5.1.4 wants INVALID_FILTER") + } + }) +} diff --git a/pkg/moqt/message/rangefilter_test.go b/pkg/moqt/message/rangefilter_test.go index eec3c6b3..26d8310a 100644 --- a/pkg/moqt/message/rangefilter_test.go +++ b/pkg/moqt/message/rangefilter_test.go @@ -74,6 +74,7 @@ func TestRangeFilterRoundTrip(t *testing.T) { } } +// nilIfEmpty maps an empty slice to nil, so comparisons ignore the difference. func nilIfEmpty(r []Range) []Range { if len(r) == 0 { return nil diff --git a/pkg/moqt/message/rangefilter_update_test.go b/pkg/moqt/message/rangefilter_update_test.go deleted file mode 100644 index 56b3b35f..00000000 --- a/pkg/moqt/message/rangefilter_update_test.go +++ /dev/null @@ -1,108 +0,0 @@ -package message_test - -import ( - "testing" - - "github.com/floatdrop/moq-go/pkg/moqt/message" -) - -// §5.1.4: "When Length is 0, there is no filter and no further fields are -// present. This can be used in REQUEST_UPDATE to remove a filter." And "In -// REQUEST_UPDATE, Length of 0 removes the filter; non-zero replaces it -// entirely. If a filter parameter is omitted from REQUEST_UPDATE, it is -// unchanged. If omitted from other messages, the default is no filter." - -func subgroupFilter(lo, hi uint64) message.Parameter { - return message.RangeFilterParam(&message.RangeFilter{ - Type: message.ParamSubgroupFilter, Ranges: []message.Range{{Start: lo, End: hi}}, - }) -} - -func priorityFilter(lo, hi uint64) message.Parameter { - return message.RangeFilterParam(&message.RangeFilter{ - Type: message.ParamPriorityFilter, Ranges: []message.Range{{Start: lo, End: hi}}, - }) -} - -func removeFilter(t message.ParamID) message.Parameter { return message.BytesParam(t, nil) } - -// TestRangeFiltersZeroLengthIsNoFilter: outside REQUEST_UPDATE a zero-length -// Range Filter is simply no filter. -func TestRangeFiltersZeroLengthIsNoFilter(t *testing.T) { - t.Parallel() - set, err := message.RangeFiltersFromParams(message.Parameters{removeFilter(message.ParamSubgroupFilter)}) - if err != nil { - t.Fatalf("RangeFiltersFromParams(zero-length) = %v, want no filter", err) - } - if set != nil { - t.Fatalf("RangeFiltersFromParams(zero-length) = %+v, want nil (no filter)", set) - } -} - -// TestRangeFilterSetUpdate: an update replaces or removes the filter types it -// names and leaves the others as they were. -func TestRangeFilterSetUpdate(t *testing.T) { - t.Parallel() - base, err := message.RangeFiltersFromParams(message.Parameters{subgroupFilter(1, 1), priorityFilter(0, 10)}) - if err != nil { - t.Fatalf("base: %v", err) - } - // passes reports whether an Object in subgroup sg at priority prio passes. - passes := func(s *message.RangeFilterSet, sg uint64, prio uint8) bool { - return s == nil || s.MatchesObject(sg, 0, prio, nil) - } - - t.Run("remove one type", func(t *testing.T) { - got, err := base.Update(message.Parameters{removeFilter(message.ParamSubgroupFilter)}) - if err != nil { - t.Fatalf("Update: %v", err) - } - if !passes(got, 7, 5) || passes(got, 7, 20) { - t.Fatal("after removing SUBGROUP_FILTER: want any subgroup, priority still 0..10") - } - }) - t.Run("replace one type", func(t *testing.T) { - got, err := base.Update(message.Parameters{subgroupFilter(2, 2)}) - if err != nil { - t.Fatalf("Update: %v", err) - } - if passes(got, 1, 5) || !passes(got, 2, 5) || passes(got, 2, 20) { - t.Fatal("after replacing SUBGROUP_FILTER: want subgroup 2 only, priority still 0..10") - } - }) - t.Run("omitted types unchanged", func(t *testing.T) { - got, err := base.Update(message.Parameters{message.ForwardParam(true)}) - if err != nil { - t.Fatalf("Update: %v", err) - } - if !passes(got, 1, 5) || passes(got, 2, 5) || passes(got, 1, 20) { - t.Fatal("an update naming no filter changed the filters") - } - }) - t.Run("remove every type", func(t *testing.T) { - got, err := base.Update(message.Parameters{ - removeFilter(message.ParamSubgroupFilter), removeFilter(message.ParamPriorityFilter), - }) - if err != nil { - t.Fatalf("Update: %v", err) - } - if got != nil { - t.Fatalf("Update removing every filter = %+v, want nil (no filter)", got) - } - }) - t.Run("from no filters", func(t *testing.T) { - var none *message.RangeFilterSet - got, err := none.Update(message.Parameters{subgroupFilter(3, 3)}) - if err != nil { - t.Fatalf("Update: %v", err) - } - if passes(got, 1, 5) || !passes(got, 3, 5) { - t.Fatal("an update adding SUBGROUP_FILTER to no filters: want subgroup 3 only") - } - }) - t.Run("duplicate within the update", func(t *testing.T) { - if _, err := base.Update(message.Parameters{subgroupFilter(2, 2), subgroupFilter(3, 3)}); err == nil { - t.Fatal("an update repeating (Type, SetID) was accepted; §5.1.4 wants INVALID_FILTER") - } - }) -} diff --git a/pkg/moqt/message/setup_test.go b/pkg/moqt/message/setup_test.go index 584ff1e7..ff0e2cfc 100644 --- a/pkg/moqt/message/setup_test.go +++ b/pkg/moqt/message/setup_test.go @@ -8,25 +8,9 @@ import ( ) // TestSetupOptionGoldenBytes pins the §15.4 Table 10 codepoints to the exact -// bytes each option builder emits. -// -// This deliberately does not use the roundtrip helper, and that is the whole -// point: roundtrip is Marshal → Parse → DeepEqual through our own codec, so a -// wrong codepoint agrees with itself perfectly and passes. Every moq-go ↔ -// moq-go test would stay green while every third-party peer read a different -// option — or, since §10.3 requires a receiver to ignore options it does not -// recognize, read nothing at all and route the session on a default authority -// with no error raised anywhere. Only a byte-level assertion can catch that. -// -// AUTHORITY earns the attention: internal/dial puts it on every native-QUIC -// connection this repo makes, and until this test it had no coverage at all. -// -// The expected bytes are, per pair: the §1.4.3 type delta from the running -// previous type (zero for the first pair, so the delta is the codepoint -// itself), then — because both codepoints are odd — a §1.4.3 length-prefixed -// byte string. An even codepoint would encode a bare varint instead, so the -// parity of the constant and the builder's choice of ByteVal are coupled; the -// IsBytes assertion below pins that too. +// bytes each option builder emits: a codec round-trip cannot catch a wrong +// codepoint, since it agrees with itself. Per pair: the §1.4.3 type delta, +// then (odd codepoint) a length-prefixed byte string. func TestSetupOptionGoldenBytes(t *testing.T) { tests := []struct { name string diff --git a/pkg/moqt/message/subgroup_object_test.go b/pkg/moqt/message/subgroup_object_test.go index 8746d37e..cff6a3b2 100644 --- a/pkg/moqt/message/subgroup_object_test.go +++ b/pkg/moqt/message/subgroup_object_test.go @@ -277,11 +277,8 @@ func TestSubgroupObject_ParseErrors(t *testing.T) { } } -// TestSubgroupObject_StreamFINMidObject pins §11.4: a stream that ends with a -// FIN "in the middle of a serialized Object" is not a clean end. Only a FIN -// before an object's first byte may surface as io.EOF; every later truncation -// must be io.ErrUnexpectedEOF, or a caller reading to io.EOF takes a torn -// stream for a complete one. +// TestSubgroupObject_StreamFINMidObject pins §11.4: a FIN mid-Object is +// io.ErrUnexpectedEOF; only a FIN on an Object boundary is io.EOF. func TestSubgroupObject_StreamFINMidObject(t *testing.T) { tests := []struct { name string @@ -311,9 +308,8 @@ func TestSubgroupObject_StreamFINMidObject(t *testing.T) { } // TestSubgroupObject_ValidateRejectsPropertiesOnStatusObject pins §11.2.1.2: -// "If an endpoint receives properties on an Object with status that is not -// Normal, it MUST close the session with a PROTOCOL_VIOLATION." A Properties -// Length of 0 is no properties. +// properties on a non-Normal status Object are a PROTOCOL_VIOLATION; a +// Properties Length of 0 is no properties. func TestSubgroupObject_ValidateRejectsPropertiesOnStatusObject(t *testing.T) { props := []byte{0x02, 0x01} // one even-typed KV pair for _, status := range []uint64{ObjectStatusEndOfGroup, ObjectStatusEndOfTrack} { From 2a1cef551314fa4013babd96b2c4c346811f0be7 Mon Sep 17 00:00:00 2001 From: Vsevolod Strukchinsky Date: Sat, 26 Sep 2026 10:10:28 +0500 Subject: [PATCH 06/12] test(wire): trim history from test comments; document bench helpers Test-only. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/moqt/wire/varint_test.go | 6 +++--- pkg/moqt/wire/wire_bench_test.go | 2 ++ pkg/moqt/wire/wire_test.go | 5 ++--- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/pkg/moqt/wire/varint_test.go b/pkg/moqt/wire/varint_test.go index 09203db6..3e2a1d79 100644 --- a/pkg/moqt/wire/varint_test.go +++ b/pkg/moqt/wire/varint_test.go @@ -7,9 +7,9 @@ import ( ) // TestVarintSpecVectors pins the §1.4.1 leading-ones encoding against -// hand-computed byte vectors, including the message-type codes that exposed the -// QUIC-varint vs leading-ones interop bug (SETUP 0x2F00, SUBSCRIBE_NAMESPACE -// 0x50, PUBLISH_NAMESPACE 0x06). +// hand-computed byte vectors, including message-type codes whose QUIC-varint +// encoding differs (SETUP 0x2F00, SUBSCRIBE_NAMESPACE 0x50, PUBLISH_NAMESPACE +// 0x06). func TestVarintSpecVectors(t *testing.T) { cases := []struct { v uint64 diff --git a/pkg/moqt/wire/wire_bench_test.go b/pkg/moqt/wire/wire_bench_test.go index 0b04c86f..fee1288d 100644 --- a/pkg/moqt/wire/wire_bench_test.go +++ b/pkg/moqt/wire/wire_bench_test.go @@ -23,6 +23,7 @@ var ( sinkErr error ) +// makePayload returns n bytes of a repeating pattern. func makePayload(n int) []byte { b := make([]byte, n) for i := range b { @@ -31,6 +32,7 @@ func makePayload(n int) []byte { return b } +// makeKVPairs returns n key-value pairs for benchmarks. func makeKVPairs(n int) []KVPair { pairs := make([]KVPair, n) for i := range pairs { diff --git a/pkg/moqt/wire/wire_test.go b/pkg/moqt/wire/wire_test.go index e8031acb..04568aea 100644 --- a/pkg/moqt/wire/wire_test.go +++ b/pkg/moqt/wire/wire_test.go @@ -554,9 +554,8 @@ func TestTrackNamespace_HasPrefix(t *testing.T) { } } -// TestReaderHugeLengthRejected guards the draft-20 varint range (§1.4.1: up to -// 2^64-1). A peer-supplied length >= 2^63 does not fit an int; it must yield -// ErrShortBuffer, not a negative FixedBytes argument that panics in make. +// TestReaderHugeLengthRejected: a §1.4.1 length >= 2^63 does not fit an int and +// must yield ErrShortBuffer, not a panic. func TestReaderHugeLengthRejected(t *testing.T) { for _, n := range []uint64{1 << 63, 1<<64 - 1} { w := NewWriter(nil) From 2ea4f6ea466039134b4b352b1b8c894d763be12e Mon Sep 17 00:00:00 2001 From: Vsevolod Strukchinsky Date: Sat, 26 Sep 2026 10:10:28 +0500 Subject: [PATCH 07/12] test(registry): share stream stubs and fold the subscriber queue test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add helpers_test.go with ns() and stubStream, embed the stub in the test streams, factor the repeated answered-SUBSCRIBE setup, move the queue reset test into namespace_test.go, cite §5.1 for the single SUBSCRIBE response, and trim comments. Test-only. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/relay/internal/registry/broker_test.go | 25 +--- .../registry/downstream_write_test.go | 135 ++++++++---------- .../internal/registry/dynamic_groups_test.go | 7 +- pkg/relay/internal/registry/helpers_test.go | 26 ++++ pkg/relay/internal/registry/namespace_test.go | 56 ++++++-- .../registry/subscriber_queue_test.go | 60 -------- pkg/relay/internal/registry/track_test.go | 1 + 7 files changed, 138 insertions(+), 172 deletions(-) create mode 100644 pkg/relay/internal/registry/helpers_test.go delete mode 100644 pkg/relay/internal/registry/subscriber_queue_test.go diff --git a/pkg/relay/internal/registry/broker_test.go b/pkg/relay/internal/registry/broker_test.go index d9671110..0d7611c6 100644 --- a/pkg/relay/internal/registry/broker_test.go +++ b/pkg/relay/internal/registry/broker_test.go @@ -9,23 +9,9 @@ import ( "github.com/floatdrop/moq-go/pkg/relay/internal/registry" ) -// stubStream is a minimal session.Stream: writes vanish, reads never -// happen (the broker's reader lives outside the registry). -type stubStream struct{} - -func (stubStream) Write(p []byte) (int, error) { return len(p), nil } -func (stubStream) Close() error { return nil } -func (stubStream) CancelWrite(uint64) {} -func (stubStream) Read([]byte) (int, error) { return 0, nil } -func (stubStream) CancelRead(uint64) {} -func (stubStream) Context() context.Context { return context.Background() } - -// TestUpstreamSub_UpdateDelegatesToBroker pins the delegation contract after -// the §10.9 update broker moved into the session package: UpstreamSub.Update -// rides the sub's [session.RequestBroker], so once the broker is closed -// (CloseOnDemand / the Serve loop exiting) pending and future updates fail -// with [session.ErrRequestStreamClosed]. The broker's routing semantics -// themselves are pinned in the session package's broker tests. +// TestUpstreamSub_UpdateDelegatesToBroker: UpstreamSub.Update rides the sub's +// §10.9 [session.RequestBroker], so once CloseOnDemand closes it updates fail +// with [session.ErrRequestStreamClosed]. func TestUpstreamSub_UpdateDelegatesToBroker(t *testing.T) { t.Parallel() sub := registry.NewUpstreamSub(1, nil, stubStream{}, 0, 7, false) @@ -39,9 +25,8 @@ func TestUpstreamSub_UpdateDelegatesToBroker(t *testing.T) { } } -// TestUpstreamSub_NilBrokerFixtures pins the literal-construction escape -// hatch used by tests: an UpstreamSub built without NewUpstreamSub has no -// broker, and Update / WriteMessage fail fast instead of panicking. +// TestUpstreamSub_NilBrokerFixtures: an UpstreamSub built without +// NewUpstreamSub has no broker; Update and WriteMessage fail instead of panicking. func TestUpstreamSub_NilBrokerFixtures(t *testing.T) { t.Parallel() sub := ®istry.UpstreamSub{} diff --git a/pkg/relay/internal/registry/downstream_write_test.go b/pkg/relay/internal/registry/downstream_write_test.go index 71d19186..66c74535 100644 --- a/pkg/relay/internal/registry/downstream_write_test.go +++ b/pkg/relay/internal/registry/downstream_write_test.go @@ -2,7 +2,6 @@ package registry_test import ( "bytes" - "context" "sync" "sync/atomic" "testing" @@ -13,10 +12,11 @@ import ( "github.com/floatdrop/moq-go/pkg/relay/internal/registry" ) -// reentrancyStream fails the test if two Writes ever overlap — the exact -// hazard on a session.Stream, where one Marshal is multiple Writes and -// interleaved writers corrupt the control stream. +// reentrancyStream fails the test if two Writes overlap: one Marshal is +// several Writes, so interleaved writers corrupt the request stream. type reentrancyStream struct { + stubStream + t *testing.T inside atomic.Bool } @@ -29,16 +29,10 @@ func (s *reentrancyStream) Write(p []byte) (int, error) { defer s.inside.Store(false) return len(p), nil } -func (s *reentrancyStream) Close() error { return nil } -func (s *reentrancyStream) CancelWrite(uint64) {} -func (s *reentrancyStream) Read([]byte) (int, error) { return 0, nil } -func (s *reentrancyStream) CancelRead(uint64) {} -func (s *reentrancyStream) Context() context.Context { return context.Background() } // TestDownstreamSub_WritesSerialized pins the write lock shared by -// WriteMessage (SUBSCRIBE_OK / REQUEST_OK replies) and -// TerminateWithPublishDone (registry teardown): the two race for real when -// a publisher leaves while a subscriber's REQUEST_UPDATE is being answered. +// WriteMessage and TerminateWithPublishDone, which race when a publisher +// leaves while a REQUEST_UPDATE is being answered. func TestDownstreamSub_WritesSerialized(t *testing.T) { t.Parallel() @@ -58,17 +52,18 @@ func TestDownstreamSub_WritesSerialized(t *testing.T) { } } -// recordingStream buffers every write so a test can decode the exact -// control-message sequence the relay emitted on the request stream. The -// termination's answer is written on its own goroutine and ends with Close, -// which closed signals. +// recordingStream buffers every write for decoding; closed signals Close, +// which ends the termination's answer (written on its own goroutine). type recordingStream struct { + stubStream + mu sync.Mutex buf []byte closeOnce sync.Once closed chan struct{} } +// newRecordingStream returns an open recordingStream. func newRecordingStream() *recordingStream { return &recordingStream{closed: make(chan struct{})} } @@ -83,20 +78,27 @@ func (s *recordingStream) awaitClosed(t *testing.T) { } } +// requireOpenFor fails if the stream is closed within d. +func (s *recordingStream) requireOpenFor(t *testing.T, d time.Duration, why string) { + t.Helper() + select { + case <-s.closed: + t.Fatal(why) + case <-time.After(d): + } +} + func (s *recordingStream) Write(p []byte) (int, error) { s.mu.Lock() s.buf = append(s.buf, p...) s.mu.Unlock() return len(p), nil } + func (s *recordingStream) Close() error { s.closeOnce.Do(func() { close(s.closed) }) return nil } -func (s *recordingStream) CancelWrite(uint64) {} -func (s *recordingStream) Read([]byte) (int, error) { return 0, nil } -func (s *recordingStream) CancelRead(uint64) {} -func (s *recordingStream) Context() context.Context { return context.Background() } // messages decodes everything written so far. func (s *recordingStream) messages(t *testing.T) []message.Message { @@ -116,12 +118,21 @@ func (s *recordingStream) messages(t *testing.T) []message.Message { return out } -// TestDownstreamSub_TerminateBeforeOKAnswersWithRequestError pins the §10.7 -// response guarantee on the SUBSCRIBE_OK / termination race: the sub is -// registered (reachable by teardown) before the handler replies, and a -// terminator that wins must answer the still-unanswered SUBSCRIBE with -// REQUEST_ERROR — a bare PUBLISH_DONE is not a request response, and the -// handler's late OK must then be suppressed entirely. +// answeredDownstreamSub returns a DownstreamSub on a recordingStream that has +// already written its SUBSCRIBE_OK. +func answeredDownstreamSub(t *testing.T) (*registry.DownstreamSub, *recordingStream) { + t.Helper() + stream := newRecordingStream() + sub := registry.NewDownstreamSub(1, nil, stream, 7) + if err := sub.WriteSubscribeOK(&message.SubscribeOK{TrackAlias: 7}); err != nil { + t.Fatalf("WriteSubscribeOK: %v", err) + } + return sub, stream +} + +// TestDownstreamSub_TerminateBeforeOKAnswersWithRequestError: a termination +// that beats SUBSCRIBE_OK answers the SUBSCRIBE with REQUEST_ERROR (§5.1), +// and the late OK is suppressed. func TestDownstreamSub_TerminateBeforeOKAnswersWithRequestError(t *testing.T) { t.Parallel() @@ -147,18 +158,12 @@ func TestDownstreamSub_TerminateBeforeOKAnswersWithRequestError(t *testing.T) { } } -// TestDownstreamSub_TerminateAfterOKSendsPublishDone pins the normal §10.12 -// order: once SUBSCRIBE_OK is out, a termination follows up with -// PUBLISH_DONE on the same stream. +// TestDownstreamSub_TerminateAfterOKSendsPublishDone: once SUBSCRIBE_OK is out, +// a termination follows up with PUBLISH_DONE (§10.12). func TestDownstreamSub_TerminateAfterOKSendsPublishDone(t *testing.T) { t.Parallel() - stream := newRecordingStream() - sub := registry.NewDownstreamSub(1, nil, stream, 7) - - if err := sub.WriteSubscribeOK(&message.SubscribeOK{TrackAlias: 7}); err != nil { - t.Fatalf("WriteSubscribeOK: %v", err) - } + sub, stream := answeredDownstreamSub(t) sub.TerminateWithPublishDone(moqt.PublishDoneTrackEnded, "upstream gone") stream.awaitClosed(t) @@ -174,11 +179,9 @@ func TestDownstreamSub_TerminateAfterOKSendsPublishDone(t *testing.T) { } } -// TestDownstreamSub_SubscribeOKTerminateRace races WriteSubscribeOK against -// TerminateWithPublishDone and pins the §10.7 invariant on every -// interleaving: the wire carries exactly one request response — either -// SUBSCRIBE_OK (followed by PUBLISH_DONE) or REQUEST_ERROR — never a bare -// PUBLISH_DONE and never two responses. +// TestDownstreamSub_SubscribeOKTerminateRace: on every interleaving the wire +// carries exactly one request response (§5.1) — SUBSCRIBE_OK then +// PUBLISH_DONE, or REQUEST_ERROR. func TestDownstreamSub_SubscribeOKTerminateRace(t *testing.T) { t.Parallel() @@ -215,10 +218,8 @@ func TestDownstreamSub_SubscribeOKTerminateRace(t *testing.T) { } } -// TestDownstreamSub_PublishDoneStreamCount pins the §10.12 Stream Count: the -// number of streams opened for the subscription, exact even when an open is -// in flight at termination, since the PUBLISH_DONE waits for it to finish -// (and for the stream to close); no stream may begin after termination. +// TestDownstreamSub_PublishDoneStreamCount pins the §10.12 Stream Count: exact +// even with an open in flight at termination, and no stream begins after it. func TestDownstreamSub_PublishDoneStreamCount(t *testing.T) { t.Parallel() for _, tc := range []struct { @@ -235,11 +236,7 @@ func TestDownstreamSub_PublishDoneStreamCount(t *testing.T) { } { t.Run(tc.name, func(t *testing.T) { t.Parallel() - stream := newRecordingStream() - sub := registry.NewDownstreamSub(1, nil, stream, 7) - if err := sub.WriteSubscribeOK(&message.SubscribeOK{TrackAlias: 7}); err != nil { - t.Fatalf("WriteSubscribeOK: %v", err) - } + sub, stream := answeredDownstreamSub(t) for range tc.opened { sub.BeginStream() sub.EndStream(true) @@ -274,26 +271,17 @@ func TestDownstreamSub_PublishDoneStreamCount(t *testing.T) { } } -// TestDownstreamSub_PublishDoneWaitsForOpenStreams pins §10.12: "A sender MUST -// NOT send PUBLISH_DONE until it has closed all streams it will ever open". -// Terminated with a stream still open, the subscription answers only once -// that stream is reported closed. +// TestDownstreamSub_PublishDoneWaitsForOpenStreams: §10.12 PUBLISH_DONE waits +// until every stream of the subscription is closed. func TestDownstreamSub_PublishDoneWaitsForOpenStreams(t *testing.T) { t.Parallel() - stream := newRecordingStream() - sub := registry.NewDownstreamSub(1, nil, stream, 7) - if err := sub.WriteSubscribeOK(&message.SubscribeOK{TrackAlias: 7}); err != nil { - t.Fatalf("WriteSubscribeOK: %v", err) - } + sub, stream := answeredDownstreamSub(t) sub.BeginStream() sub.EndStream(true) sub.TerminateWithPublishDone(moqt.PublishDoneTrackEnded, "upstream gone") - select { - case <-stream.closed: - t.Fatal("PUBLISH_DONE went out while a stream of the subscription was open") - case <-time.After(50 * time.Millisecond): - } + stream.requireOpenFor(t, 50*time.Millisecond, + "PUBLISH_DONE went out while a stream of the subscription was open") sub.StreamClosed() stream.awaitClosed(t) @@ -303,16 +291,11 @@ func TestDownstreamSub_PublishDoneWaitsForOpenStreams(t *testing.T) { } } -// TestDownstreamSub_PublishDoneWaitsForDatagramSend pins the datagram half of -// §10.12: PUBLISH_DONE goes out only once the sender "has no further datagrams -// to send". A send in progress holds it, and none starts after termination. +// TestDownstreamSub_PublishDoneWaitsForDatagramSend: §10.12 PUBLISH_DONE waits +// for a datagram send in progress, and none starts after termination. func TestDownstreamSub_PublishDoneWaitsForDatagramSend(t *testing.T) { t.Parallel() - stream := newRecordingStream() - sub := registry.NewDownstreamSub(1, nil, stream, 7) - if err := sub.WriteSubscribeOK(&message.SubscribeOK{TrackAlias: 7}); err != nil { - t.Fatalf("WriteSubscribeOK: %v", err) - } + sub, stream := answeredDownstreamSub(t) if !sub.BeginDatagram() { t.Fatal("BeginDatagram on a live subscription = false") } @@ -320,18 +303,14 @@ func TestDownstreamSub_PublishDoneWaitsForDatagramSend(t *testing.T) { if sub.BeginDatagram() { t.Error("BeginDatagram after termination = true, want false") } - select { - case <-stream.closed: - t.Fatal("PUBLISH_DONE went out while a datagram was being sent") - case <-time.After(50 * time.Millisecond): - } + stream.requireOpenFor(t, 50*time.Millisecond, "PUBLISH_DONE went out while a datagram was being sent") sub.EndDatagram() stream.awaitClosed(t) } // TestDownstreamSub_RefusalCancelsPendingPublishDone: a forwarded PUBLISH the -// subscriber refuses after a termination began waiting on its streams gets -// no PUBLISH_DONE after the refusal. +// subscriber refuses while a termination waits on its streams gets no +// PUBLISH_DONE. func TestDownstreamSub_RefusalCancelsPendingPublishDone(t *testing.T) { t.Parallel() stream := newRecordingStream() diff --git a/pkg/relay/internal/registry/dynamic_groups_test.go b/pkg/relay/internal/registry/dynamic_groups_test.go index ec34a45a..5c198b32 100644 --- a/pkg/relay/internal/registry/dynamic_groups_test.go +++ b/pkg/relay/internal/registry/dynamic_groups_test.go @@ -9,6 +9,7 @@ import ( "github.com/floatdrop/moq-go/pkg/relay/internal/registry" ) +// dynamicGroupsProps encodes Track Properties carrying DYNAMIC_GROUPS = value. func dynamicGroupsProps(t *testing.T, value uint64) []byte { t.Helper() return message.AppendTrackProperties([]wire.KVPair{ @@ -143,10 +144,8 @@ func TestConsiderNewGroupRequest(t *testing.T) { }) } -// TestTrackEntry_PropertiesInsideImmutable: §12.7 "When looking for the value -// of a property, processors MUST search both the mutable properties and the -// contents of Immutable Properties." Every Track Property the relay decodes -// is found there too. +// TestTrackEntry_PropertiesInsideImmutable: every Track Property the relay +// decodes is also found inside Immutable Properties (§12.7). func TestTrackEntry_PropertiesInsideImmutable(t *testing.T) { t.Parallel() nested := message.AppendTrackProperties([]wire.KVPair{ diff --git a/pkg/relay/internal/registry/helpers_test.go b/pkg/relay/internal/registry/helpers_test.go new file mode 100644 index 00000000..921ce74e --- /dev/null +++ b/pkg/relay/internal/registry/helpers_test.go @@ -0,0 +1,26 @@ +package registry_test + +import ( + "context" + + "github.com/floatdrop/moq-go/pkg/moqt/wire" +) + +// ns builds a namespace from string fields. +func ns(parts ...string) wire.TrackNamespace { + out := make(wire.TrackNamespace, len(parts)) + for i, p := range parts { + out[i] = []byte(p) + } + return out +} + +// stubStream is a no-op session.Stream; the registry never reads from one. +type stubStream struct{} + +func (stubStream) Write(p []byte) (int, error) { return len(p), nil } +func (stubStream) Close() error { return nil } +func (stubStream) CancelWrite(uint64) {} +func (stubStream) Read([]byte) (int, error) { return 0, nil } +func (stubStream) CancelRead(uint64) {} +func (stubStream) Context() context.Context { return context.Background() } diff --git a/pkg/relay/internal/registry/namespace_test.go b/pkg/relay/internal/registry/namespace_test.go index 1c531b77..a356d939 100644 --- a/pkg/relay/internal/registry/namespace_test.go +++ b/pkg/relay/internal/registry/namespace_test.go @@ -4,23 +4,15 @@ import ( "strings" "sync" "testing" + "time" + "github.com/floatdrop/moq-go/pkg/moqt/message" "github.com/floatdrop/moq-go/pkg/moqt/session" "github.com/floatdrop/moq-go/pkg/moqt/wire" "github.com/floatdrop/moq-go/pkg/relay/internal/registry" "github.com/floatdrop/moq-go/pkg/relay/internal/relaytest" ) -// ns is a tiny constructor for namespaces from string components, used to -// keep the test tables readable. -func ns(parts ...string) wire.TrackNamespace { - out := make(wire.TrackNamespace, len(parts)) - for i, p := range parts { - out[i] = []byte(p) - } - return out -} - // TestNamespaceRegistry_RegisterUnregisterPublisher exercises the basic // happy path: register, snapshot, unregister, observe empty. func TestNamespaceRegistry_RegisterUnregisterPublisher(t *testing.T) { @@ -230,6 +222,48 @@ func TestNamespaceRegistry_ConcurrentRegisterMatch(t *testing.T) { } } +// stallingStream's first Write blocks until release and then succeeds, whatever +// CancelWrite said meanwhile; later Writes succeed at once. +type stallingStream struct { + stubStream + + started, release chan struct{} + writes int +} + +func (s *stallingStream) Write(p []byte) (int, error) { + if s.writes++; s.writes == 1 { + close(s.started) + <-s.release + } + return len(p), nil +} + +// TestSubscriberEntry_WriterEndsAfterQueueReset: when the §10.19 queue bound +// resets the stream while a write is in progress and that write completes +// anyway, the writer still ends rather than parking on the emptied queue. +func TestSubscriberEntry_WriterEndsAfterQueueReset(t *testing.T) { + st := &stallingStream{started: make(chan struct{}), release: make(chan struct{})} + r := registry.NewNamespaceRegistry() + e := r.RegisterSubscriber(ns("video"), nil, st, true, nil, nil) + go e.RunWriter() + + e.Enqueue(&message.RequestOK{}) + <-st.started // the writer is in its first write + for range 1024 { + e.Enqueue(&message.RequestOK{}) + } + time.Sleep(1100 * time.Millisecond) // the oldest unsent is over a second old + e.Finish(&message.RequestError{}) // this push resets the stream + close(st.release) // ...and the stalled write succeeds anyway + + select { + case <-e.WriterDone(): + case <-time.After(2 * time.Second): + t.Fatal("the writer parked on the emptied queue after the reset") + } +} + // ----- helpers --------------------------------------------------------- // sameSet reports whether a and b contain the same elements, ignoring @@ -250,6 +284,7 @@ func sameSet[T comparable](a, b []T) bool { return true } +// formatPublishers renders the publishers' namespaces for failure messages. func formatPublishers(s []*registry.PublisherEntry) string { var out strings.Builder out.WriteString("[") @@ -262,6 +297,7 @@ func formatPublishers(s []*registry.PublisherEntry) string { return out.String() + "]" } +// formatSubscribers renders the subscribers' prefixes for failure messages. func formatSubscribers(s []*registry.SubscriberEntry) string { var out strings.Builder out.WriteString("[") diff --git a/pkg/relay/internal/registry/subscriber_queue_test.go b/pkg/relay/internal/registry/subscriber_queue_test.go deleted file mode 100644 index 40f18e3c..00000000 --- a/pkg/relay/internal/registry/subscriber_queue_test.go +++ /dev/null @@ -1,60 +0,0 @@ -package registry_test - -import ( - "context" - "io" - "testing" - "time" - - "github.com/floatdrop/moq-go/pkg/moqt/message" - "github.com/floatdrop/moq-go/pkg/relay/internal/registry" -) - -// stallingStream's first Write waits for release, then succeeds whatever -// CancelWrite said meanwhile — the window where a write completes just as the -// queue bound resets the stream. Later writes succeed at once. It models only -// what the registry uses: its Context never ends, unlike a real stream's. -type stallingStream struct { - started, release chan struct{} - writes int -} - -func (s *stallingStream) Write(p []byte) (int, error) { - if s.writes++; s.writes == 1 { - close(s.started) - <-s.release - } - return len(p), nil -} -func (s *stallingStream) Close() error { return nil } -func (s *stallingStream) CancelWrite(uint64) {} -func (s *stallingStream) Context() context.Context { return context.Background() } -func (s *stallingStream) Read([]byte) (int, error) { return 0, io.EOF } -func (s *stallingStream) CancelRead(uint64) {} - -// TestSubscriberEntry_WriterEndsAfterQueueReset: when the queue bound resets -// the stream (§10.19) and the write in progress completes anyway, the writer -// still ends — its owner waits on WriterDone after a refused update, and a -// writer parked on an empty, stopped queue would hold the subscription and -// its prefix for the rest of the session. -func TestSubscriberEntry_WriterEndsAfterQueueReset(t *testing.T) { - st := &stallingStream{started: make(chan struct{}), release: make(chan struct{})} - r := registry.NewNamespaceRegistry() - e := r.RegisterSubscriber(ns("video"), nil, st, true, nil, nil) - go e.RunWriter() - - e.Enqueue(&message.RequestOK{}) - <-st.started // the writer is in its first write - for range 1024 { - e.Enqueue(&message.RequestOK{}) - } - time.Sleep(1100 * time.Millisecond) // the oldest unsent is over a second old - e.Finish(&message.RequestError{}) // this push resets the stream - close(st.release) // ...and the stalled write succeeds anyway - - select { - case <-e.WriterDone(): - case <-time.After(2 * time.Second): - t.Fatal("the writer parked on the emptied queue after the reset") - } -} diff --git a/pkg/relay/internal/registry/track_test.go b/pkg/relay/internal/registry/track_test.go index a302cca7..cf3a84d7 100644 --- a/pkg/relay/internal/registry/track_test.go +++ b/pkg/relay/internal/registry/track_test.go @@ -14,6 +14,7 @@ import ( "github.com/floatdrop/moq-go/pkg/relay/internal/registry" ) +// newTestTrackName returns a FullTrackName for name in a fixed test namespace. func newTestTrackName(name string) track.FullTrackName { return track.FullTrackName{ Namespace: wire.TrackNamespace{[]byte("test")}, From c2e44375e64837d0576490cab0dea10da608b0f4 Mon Sep 17 00:00:00 2001 From: Vsevolod Strukchinsky Date: Sat, 26 Sep 2026 10:10:28 +0500 Subject: [PATCH 08/12] test(cache): table-drive GetRange order tests and use slices.Equal The descending case now also inserts out of order. Test-only. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/relay/cache/cache_bench_test.go | 1 + pkg/relay/cache/cache_test.go | 124 ++++++++++------------------ 2 files changed, 45 insertions(+), 80 deletions(-) diff --git a/pkg/relay/cache/cache_bench_test.go b/pkg/relay/cache/cache_bench_test.go index 328c6811..ad1bd79f 100644 --- a/pkg/relay/cache/cache_bench_test.go +++ b/pkg/relay/cache/cache_bench_test.go @@ -25,6 +25,7 @@ var ( benchSinkOK bool ) +// benchCachePayload returns an n-byte payload. func benchCachePayload(n int) []byte { b := make([]byte, n) for i := range b { diff --git a/pkg/relay/cache/cache_test.go b/pkg/relay/cache/cache_test.go index b3f0adcf..846fc41f 100644 --- a/pkg/relay/cache/cache_test.go +++ b/pkg/relay/cache/cache_test.go @@ -10,15 +10,12 @@ import ( "github.com/floatdrop/moq-go/pkg/relay/cache" ) -// putAt is a one-liner Put for tests that don't care about payload / -// timestamps — they only assert which Locations come back out of -// GetRange in which order. +// putAt puts an empty Object at {group, object}. func putAt(c *cache.ObjectCache, group, object uint64) { c.Put(&cache.CachedObject{GroupID: group, ObjectID: object}) } -// locs projects a CachedObject slice to (group, object) tuples so -// failed assertions print readable diffs. +// locs projects cached Objects to their Locations for readable diffs. func locs(objs []*cache.CachedObject) []message.Location { out := make([]message.Location, len(objs)) for i, o := range objs { @@ -27,20 +24,6 @@ func locs(objs []*cache.CachedObject) []message.Location { return out } -// equalLocs is a tiny comparator kept local so the assertion code -// stays a single line. -func equalLocs(a, b []message.Location) bool { - if len(a) != len(b) { - return false - } - for i := range a { - if a[i] != b[i] { - return false - } - } - return true -} - // TestObjectCache_OldestRetained pins the eviction-floor accessor: false on an // empty cache, the minimum live Location otherwise, and an advancing floor as // size pressure evicts the oldest entries. @@ -71,62 +54,46 @@ func TestObjectCache_OldestRetained(t *testing.T) { } } -// TestObjectCache_GetRange_AscendingOrder pins the sort contract for -// ascending mode: groups asc, objects asc within each group. Insertion -// order is deliberately scrambled so the test fails if GetRange leaks -// FIFO order instead of sorting. -func TestObjectCache_GetRange_AscendingOrder(t *testing.T) { +// TestObjectCache_GetRange_Order pins GetRange's sort: groups in the requested +// order, Objects ascending within a group (§10.13). Inserts are scrambled so +// FIFO order cannot pass. +func TestObjectCache_GetRange_Order(t *testing.T) { t.Parallel() - - c := cache.NewObjectCache(0, 0) - for _, l := range []message.Location{ - {Group: 2, Object: 1}, {Group: 0, Object: 1}, {Group: 1, Object: 0}, - {Group: 2, Object: 0}, {Group: 0, Object: 0}, {Group: 1, Object: 1}, + for _, tc := range []struct { + name string + order message.GroupOrder + want []message.Location + }{ + {"ascending", message.GroupOrderAscending, []message.Location{ + {Group: 0, Object: 0}, {Group: 0, Object: 1}, + {Group: 1, Object: 0}, {Group: 1, Object: 1}, + {Group: 2, Object: 0}, {Group: 2, Object: 1}, + }}, + {"descending", message.GroupOrderDescending, []message.Location{ + {Group: 2, Object: 0}, {Group: 2, Object: 1}, + {Group: 1, Object: 0}, {Group: 1, Object: 1}, + {Group: 0, Object: 0}, {Group: 0, Object: 1}, + }}, } { - putAt(c, l.Group, l.Object) - } - - got := c.GetRange( - message.Location{Group: 0, Object: 0}, - message.Location{Group: 2, Object: 99}, - message.GroupOrderAscending, - ) - want := []message.Location{ - {Group: 0, Object: 0}, {Group: 0, Object: 1}, - {Group: 1, Object: 0}, {Group: 1, Object: 1}, - {Group: 2, Object: 0}, {Group: 2, Object: 1}, - } - if !equalLocs(locs(got), want) { - t.Fatalf("ascending GetRange = %+v, want %+v", locs(got), want) - } -} - -// TestObjectCache_GetRange_DescendingOrder pins the §11.4.3 rule that -// descending order applies to GROUPS only — objects within a group -// stay ascending. -func TestObjectCache_GetRange_DescendingOrder(t *testing.T) { - t.Parallel() - - c := cache.NewObjectCache(0, 0) - putAt(c, 0, 0) - putAt(c, 0, 1) - putAt(c, 1, 0) - putAt(c, 1, 1) - putAt(c, 2, 0) - putAt(c, 2, 1) - - got := c.GetRange( - message.Location{Group: 0, Object: 0}, - message.Location{Group: 2, Object: 99}, - message.GroupOrderDescending, - ) - want := []message.Location{ - {Group: 2, Object: 0}, {Group: 2, Object: 1}, - {Group: 1, Object: 0}, {Group: 1, Object: 1}, - {Group: 0, Object: 0}, {Group: 0, Object: 1}, - } - if !equalLocs(locs(got), want) { - t.Fatalf("descending GetRange = %+v, want %+v", locs(got), want) + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + c := cache.NewObjectCache(0, 0) + for _, l := range []message.Location{ + {Group: 2, Object: 1}, {Group: 0, Object: 1}, {Group: 1, Object: 0}, + {Group: 2, Object: 0}, {Group: 0, Object: 0}, {Group: 1, Object: 1}, + } { + putAt(c, l.Group, l.Object) + } + + got := c.GetRange( + message.Location{Group: 0, Object: 0}, + message.Location{Group: 2, Object: 99}, + tc.order, + ) + if !slices.Equal(locs(got), tc.want) { + t.Fatalf("%s GetRange = %+v, want %+v", tc.name, locs(got), tc.want) + } + }) } } @@ -155,7 +122,7 @@ func TestObjectCache_GetRange_StartEndFiltering(t *testing.T) { {Group: 1, Object: 2}, {Group: 1, Object: 3}, {Group: 2, Object: 0}, {Group: 2, Object: 1}, } - if !equalLocs(locs(got), want) { + if !slices.Equal(locs(got), want) { t.Fatalf("filtered GetRange = %+v, want %+v", locs(got), want) } } @@ -200,12 +167,9 @@ func TestObjectCache_Delete(t *testing.T) { c.Delete(0, 0) } -// TestPerObjectMaxCacheDuration: each Object carries the MAX_CACHE_DURATION of -// the upstream it arrived through (§12.3). An expired Object above the oldest -// served one reads as an End of Unknown Range marker in GetRange ("Once -// Objects have expired from cache, their state becomes unknown"); below it, -// the caller accounts for the span (see OldestRetained). A present 0 is never -// served; an absent value leaves only the relay's TTL. +// TestPerObjectMaxCacheDuration: each Object keeps its upstream's +// MAX_CACHE_DURATION (§12.3). Expired above the oldest served Object it reads +// as End of Unknown Range; a present 0 is never served; absent means relay TTL. func TestPerObjectMaxCacheDuration(t *testing.T) { c := cache.NewObjectCache(16, 0) put := func(group uint64, maxAge time.Duration, has bool) *cache.CachedObject { From 83386057dbe94e456f56daae61fccabbc0e4cb56 Mon Sep 17 00:00:00 2001 From: Vsevolod Strukchinsky Date: Sat, 26 Sep 2026 10:10:28 +0500 Subject: [PATCH 09/12] test(discovery): generic receive helper and trimmed watch test comments Test-only. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/relay/discovery/memory_test.go | 87 +++++++++++------------------- 1 file changed, 32 insertions(+), 55 deletions(-) diff --git a/pkg/relay/discovery/memory_test.go b/pkg/relay/discovery/memory_test.go index f041b826..a9d67d53 100644 --- a/pkg/relay/discovery/memory_test.go +++ b/pkg/relay/discovery/memory_test.go @@ -15,6 +15,7 @@ import ( "github.com/floatdrop/moq-go/pkg/relay/discovery" ) +// ns builds a namespace from string fields. func ns(parts ...string) wire.TrackNamespace { out := make(wire.TrackNamespace, len(parts)) for i, p := range parts { @@ -23,6 +24,7 @@ func ns(parts ...string) wire.TrackNamespace { return out } +// newKey builds a track key from namespace fields and a name. func newKey(parts []string, name string) track.Key { return track.NewKey(ns(parts...), []byte(name)) } @@ -253,7 +255,7 @@ func TestMemoryStore_WatchTracksReceivesEvents(t *testing.T) { _ = s.PublishTrack(ctx, discovery.TrackInfo{Key: key, RelayAddr: "relay-A"}) _ = s.UnpublishTrack(ctx, key, "relay-A") - first, ok := receiveTrack(ch, 2*time.Second) + first, ok := receive(ch, 2*time.Second) if !ok { t.Fatal("did not receive publish event") } @@ -264,7 +266,7 @@ func TestMemoryStore_WatchTracksReceivesEvents(t *testing.T) { t.Errorf("first event RelayAddr = %q, want relay-A", first.Info.RelayAddr) } - second, ok := receiveTrack(ch, 2*time.Second) + second, ok := receive(ch, 2*time.Second) if !ok { t.Fatal("did not receive unpublish event") } @@ -361,14 +363,14 @@ func TestMemoryStore_WatchNamespacesReceivesEvents(t *testing.T) { _ = s.PublishNamespace(ctx, discovery.NamespaceInfo{Prefix: ns("chat"), RelayAddr: "relay-A"}) _ = s.UnpublishNamespace(ctx, ns("chat"), "relay-A") - first, ok := receiveNamespace(ch, 2*time.Second) + first, ok := receive(ch, 2*time.Second) if !ok { t.Fatal("did not receive publish event") } if first.Op != discovery.OpPublish { t.Errorf("first Op = %v, want publish", first.Op) } - second, ok := receiveNamespace(ch, 2*time.Second) + second, ok := receive(ch, 2*time.Second) if !ok { t.Fatal("did not receive unpublish event") } @@ -442,7 +444,7 @@ func TestMemoryStore_WatchSeedsSnapshot(t *testing.T) { // Snapshot: both track advertisements, as OpPublish, in some order. seen := map[string]bool{} for range 2 { - ev, ok := receiveTrack(trackCh, 2*time.Second) + ev, ok := receive(trackCh, 2*time.Second) if !ok { t.Fatal("timed out waiting for track snapshot event") } @@ -456,7 +458,7 @@ func TestMemoryStore_WatchSeedsSnapshot(t *testing.T) { } requireTrackSnapshotDone(t, trackCh) - nsEv, ok := receiveNamespace(nsCh, 2*time.Second) + nsEv, ok := receive(nsCh, 2*time.Second) if !ok { t.Fatal("timed out waiting for namespace snapshot event") } @@ -470,7 +472,7 @@ func TestMemoryStore_WatchSeedsSnapshot(t *testing.T) { if err := s.PublishTrack(ctx, discovery.TrackInfo{Key: key2, RelayAddr: "relay-A"}); err != nil { t.Fatalf("live PublishTrack: %v", err) } - live, ok := receiveTrack(trackCh, 2*time.Second) + live, ok := receive(trackCh, 2*time.Second) if !ok { t.Fatal("timed out waiting for live track event") } @@ -479,44 +481,35 @@ func TestMemoryStore_WatchSeedsSnapshot(t *testing.T) { } } -// requireTrackSnapshotDone reads the OpSnapshotDone that ends a watch's -// snapshot. +// requireTrackSnapshotDone reads the OpSnapshotDone that ends a track watch's snapshot. func requireTrackSnapshotDone(t *testing.T, ch <-chan discovery.TrackEvent) { t.Helper() - if ev, ok := receiveTrack(ch, 2*time.Second); !ok || ev.Op != discovery.OpSnapshotDone { + if ev, ok := receive(ch, 2*time.Second); !ok || ev.Op != discovery.OpSnapshotDone { t.Fatalf("got %+v (ok %v), want OpSnapshotDone", ev, ok) } } +// requireNamespaceSnapshotDone reads the OpSnapshotDone that ends a namespace watch's snapshot. func requireNamespaceSnapshotDone(t *testing.T, ch <-chan discovery.NamespaceEvent) { t.Helper() - if ev, ok := receiveNamespace(ch, 2*time.Second); !ok || ev.Op != discovery.OpSnapshotDone { + if ev, ok := receive(ch, 2*time.Second); !ok || ev.Op != discovery.OpSnapshotDone { t.Fatalf("got %+v (ok %v), want OpSnapshotDone", ev, ok) } } -func receiveTrack(ch <-chan discovery.TrackEvent, d time.Duration) (discovery.TrackEvent, bool) { +// receive reads one event from ch; ok is false if ch closed or d elapsed. +func receive[T any](ch <-chan T, d time.Duration) (ev T, ok bool) { select { - case ev, ok := <-ch: + case ev, ok = <-ch: return ev, ok case <-time.After(d): - return discovery.TrackEvent{}, false + return ev, false } } -func receiveNamespace(ch <-chan discovery.NamespaceEvent, d time.Duration) (discovery.NamespaceEvent, bool) { - select { - case ev, ok := <-ch: - return ev, ok - case <-time.After(d): - return discovery.NamespaceEvent{}, false - } -} - -// TestMemoryStoreWithdraw pins the [discovery.DiscoveryStore.Withdraw] contract -// on the reference implementation: it removes only the named relay's -// advertisements, tells watchers about each removal, and is terminal for that -// address so a late publisher cannot re-advertise a relay that is draining. +// TestMemoryStoreWithdraw pins [discovery.DiscoveryStore.Withdraw]: it removes +// only the named relay's advertisements, notifies watchers of each removal, and +// is terminal for that address. func TestMemoryStoreWithdraw(t *testing.T) { s := discovery.NewMemoryStore() defer s.Close() @@ -604,18 +597,14 @@ func TestMemoryStoreWithdraw(t *testing.T) { } } -// TestMemoryStore_WatchMarksSnapshotEnd: a watch delivers the snapshot, then -// one OpSnapshotDone, then live events, so a consumer that restarts a watch -// can reconcile against the snapshot rather than start over. +// TestMemoryStore_WatchMarksSnapshotEnd: a watch delivers the snapshot, one +// OpSnapshotDone, then live events, even when the snapshot is empty. func TestMemoryStore_WatchMarksSnapshotEnd(t *testing.T) { s := discovery.NewMemoryStore() defer s.Close() ctx := t.Context() for _, a := range []string{"relay-A", "relay-B"} { - if err := s.PublishNamespace( - ctx, - discovery.NamespaceInfo{Prefix: wire.TrackNamespace{[]byte("x")}, RelayAddr: a}, - ); err != nil { + if err := s.PublishNamespace(ctx, discovery.NamespaceInfo{Prefix: ns("x"), RelayAddr: a}); err != nil { t.Fatalf("PublishNamespace: %v", err) } } @@ -623,15 +612,12 @@ func TestMemoryStore_WatchMarksSnapshotEnd(t *testing.T) { if err != nil { t.Fatalf("WatchNamespaces: %v", err) } - if err := s.PublishNamespace( - ctx, - discovery.NamespaceInfo{Prefix: wire.TrackNamespace{[]byte("y")}, RelayAddr: "relay-C"}, - ); err != nil { + if err := s.PublishNamespace(ctx, discovery.NamespaceInfo{Prefix: ns("y"), RelayAddr: "relay-C"}); err != nil { t.Fatalf("PublishNamespace: %v", err) } var ops []discovery.Op for range 4 { - ev, ok := receiveNamespace(ch, 2*time.Second) + ev, ok := receive(ch, 2*time.Second) if !ok { t.Fatalf("watch ended after %v", ops) } @@ -646,19 +632,11 @@ func TestMemoryStore_WatchMarksSnapshotEnd(t *testing.T) { if err != nil { t.Fatalf("WatchTracks: %v", err) } - select { - case ev := <-tracks: - if ev.Op != discovery.OpSnapshotDone { - t.Fatalf("empty track snapshot: first event %v, want OpSnapshotDone", ev.Op) - } - case <-time.After(2 * time.Second): - t.Fatal("no OpSnapshotDone on an empty track watch") - } + requireTrackSnapshotDone(t, tracks) // empty snapshot } -// TestMemoryStore_OverflowClosesWatch: a watcher too slow for a live event is -// not silently skipped; its channel is closed, so the consumer notices and -// restarts the watch, reconciling from the new snapshot. +// TestMemoryStore_OverflowClosesWatch: a watcher too slow for a live event has +// its channel closed rather than events silently skipped. func TestMemoryStore_OverflowClosesWatch(t *testing.T) { s := discovery.NewMemoryStore(discovery.WithWatchBufferSize(2)) defer s.Close() @@ -669,7 +647,7 @@ func TestMemoryStore_OverflowClosesWatch(t *testing.T) { } for i := range 5 { _ = s.PublishNamespace(ctx, discovery.NamespaceInfo{ - Prefix: wire.TrackNamespace{[]byte(strconv.Itoa(i))}, RelayAddr: "relay-C", + Prefix: ns(strconv.Itoa(i)), RelayAddr: "relay-C", }) } n := 0 @@ -689,9 +667,8 @@ func TestMemoryStore_OverflowClosesWatch(t *testing.T) { } } -// TestMemoryStore_OverflowReleasesWatch: a watch ended on overflow leaves -// nothing behind, even while its ctx lives on — a consumer that keeps falling -// behind and re-watching with the same ctx must not accumulate goroutines. +// TestMemoryStore_OverflowReleasesWatch: a watch ended on overflow releases its +// goroutine even while its ctx lives on. func TestMemoryStore_OverflowReleasesWatch(t *testing.T) { s := discovery.NewMemoryStore(discovery.WithWatchBufferSize(1)) defer s.Close() @@ -704,7 +681,7 @@ func TestMemoryStore_OverflowReleasesWatch(t *testing.T) { } for j := range 3 { _ = s.PublishNamespace(ctx, discovery.NamespaceInfo{ - Prefix: wire.TrackNamespace{[]byte(strconv.Itoa(i*3 + j))}, RelayAddr: "relay-C", + Prefix: ns(strconv.Itoa(i*3 + j)), RelayAddr: "relay-C", }) } for range ch { //nolint:revive // drain until the overflow close From 80352cc5bf98745e896046affd97c0becc3277e5 Mon Sep 17 00:00:00 2001 From: Vsevolod Strukchinsky Date: Sat, 26 Sep 2026 10:09:08 +0500 Subject: [PATCH 10/12] test(relay): consolidate helpers and group one-fix test files by topic Shared helpers move to helpers_test.go and the relay harness (pipeListener, connectRelay, dialAnotherClient, dialRaw) to harness_test.go. Near-duplicates merge into one helper each: - publishing: publishVideoTrack/publishVideoTrackProps/publish, with newCam1Publisher for the relay-plus-publisher fixture (was publishWithTrackProps, publishTrack, publishWithProps, publishSecondCam1); - Objects: writeSubgroup/publishObjects/sendObjects (was sendObject, publishSubgroupObject, publishOneSubgroup, publishObjects); - subscribing: subscribeCam1 on a given session and newCam1Subscriber for a new client (was subscribeCam1Req/subscribeCam1); one subscribeTracks (was subscribeTracks/openSubscribeTracks); - FETCH: tryFetchElems/collectFetchElems over one readFetchResponse, also used by fetchStitched (tryStitchFetch/collectFetchGroups removed); - drainAll replaces drainAllStreams; ns() replaces videoNS, nsFields and the wire.TrackNamespace{[]byte(...)} literals; requireSessionClosed replaces the inline Done()/2s selects; slices/maps replace equalIDs/sortedKeys. The one-fix files from the draft-20 compliance work fold into topical ones: subscribe_tracks, malformed_track, cache, publish_done, forward_state, request_stream, data_stream, track_status, rangefilter_relay, session_update, session_namespace and relay_upstream. TestRelay_MaxCacheDurationExpiresCachedObject and TestRelay_MaxCacheDurationZeroNeverServesFromCache become the subtests of TestRelay_MaxCacheDurationNotServedFromCache. No other test is removed or changes what it asserts; no production code changes. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/relay/cache_test.go | 327 +++++++ pkg/relay/cross_relay_test.go | 82 +- pkg/relay/data_stream_test.go | 330 +++++++ pkg/relay/datastream_violation_test.go | 232 ----- pkg/relay/default_priority_test.go | 262 ++---- pkg/relay/discovery_integration_test.go | 15 +- pkg/relay/early_data_stream_test.go | 180 ---- pkg/relay/fill_rangefilter_test.go | 79 -- pkg/relay/followup_role_test.go | 98 -- ...omission_test.go => forward_state_test.go} | 162 ++-- pkg/relay/forwarded_publish_test.go | 404 -------- pkg/relay/handler_datagram_test.go | 21 +- pkg/relay/handler_fanout_firstobject_test.go | 7 +- pkg/relay/handler_fanout_lag_test.go | 7 +- pkg/relay/handler_fanout_multipub_test.go | 70 +- pkg/relay/handler_fanout_terminal_test.go | 5 +- pkg/relay/handler_fanout_test.go | 85 +- pkg/relay/handler_fanout_timeout_test.go | 47 +- pkg/relay/handler_fetch_session_test.go | 17 +- pkg/relay/handler_fetch_stitch_test.go | 149 +-- pkg/relay/handler_fetch_test.go | 302 +----- pkg/relay/handler_fetch_unknown_test.go | 149 +-- pkg/relay/handler_fetch_upstream_fail_test.go | 85 +- pkg/relay/handler_namespace_fault_test.go | 23 +- .../handler_publish_alias_window_test.go | 15 +- .../handler_subscribe_alias_window_test.go | 15 +- pkg/relay/harness_test.go | 315 +++++++ pkg/relay/helpers_test.go | 888 ++++++++++++++++++ pkg/relay/inbound_goaway_test.go | 62 +- pkg/relay/include_properties_test.go | 167 ---- pkg/relay/integration_test.go | 21 +- pkg/relay/late_publisher_test.go | 114 +-- pkg/relay/limit_integration_test.go | 9 +- pkg/relay/malformed_track_test.go | 302 +++++- pkg/relay/mandatory_fetch_test.go | 152 --- pkg/relay/mandatory_property_test.go | 142 --- pkg/relay/max_cache_duration_test.go | 297 ------ pkg/relay/metrics_test.go | 23 +- pkg/relay/namespace_state_test.go | 41 +- pkg/relay/narrowing_integration_test.go | 5 +- pkg/relay/newgroup_test.go | 51 +- pkg/relay/param_scope_test.go | 107 --- pkg/relay/prefix_overlap_test.go | 148 --- pkg/relay/priority_test.go | 7 +- pkg/relay/publish_done_code_test.go | 43 - pkg/relay/publish_done_count_test.go | 121 --- ...ne_timing_test.go => publish_done_test.go} | 154 ++- pkg/relay/publish_forward_test.go | 97 -- pkg/relay/publish_skipped_test.go | 9 +- pkg/relay/rangefilter_relay_test.go | 197 +++- pkg/relay/rangefilter_update_relay_test.go | 129 --- pkg/relay/relay_bench_test.go | 3 +- pkg/relay/relay_test.go | 111 --- pkg/relay/relay_upstream_goaway_test.go | 89 -- ...ffinity_test.go => relay_upstream_test.go} | 80 ++ ...est_fin_test.go => request_stream_test.go} | 145 +-- pkg/relay/session_cleanup_test.go | 9 +- pkg/relay/session_handler_fault_test.go | 3 +- pkg/relay/session_handler_test.go | 197 +--- pkg/relay/session_namespace_test.go | 230 +++-- pkg/relay/session_pubsub_test.go | 102 +- pkg/relay/session_update_test.go | 162 +++- pkg/relay/shared_alias_test.go | 7 +- pkg/relay/subscribe_tracks_join_test.go | 86 -- pkg/relay/subscribe_tracks_test.go | 664 +++++++++++++ pkg/relay/subscribe_tracks_update_test.go | 251 ----- pkg/relay/token_verify_test.go | 5 +- pkg/relay/track_status_largest_test.go | 184 ---- pkg/relay/track_status_test.go | 103 ++ pkg/relay/track_status_update_test.go | 44 - pkg/relay/update_ok_largest_test.go | 49 - 71 files changed, 4197 insertions(+), 5096 deletions(-) create mode 100644 pkg/relay/cache_test.go create mode 100644 pkg/relay/data_stream_test.go delete mode 100644 pkg/relay/datastream_violation_test.go delete mode 100644 pkg/relay/early_data_stream_test.go delete mode 100644 pkg/relay/fill_rangefilter_test.go delete mode 100644 pkg/relay/followup_role_test.go rename pkg/relay/{forward_omission_test.go => forward_state_test.go} (69%) delete mode 100644 pkg/relay/forwarded_publish_test.go create mode 100644 pkg/relay/harness_test.go create mode 100644 pkg/relay/helpers_test.go delete mode 100644 pkg/relay/include_properties_test.go delete mode 100644 pkg/relay/mandatory_fetch_test.go delete mode 100644 pkg/relay/mandatory_property_test.go delete mode 100644 pkg/relay/max_cache_duration_test.go delete mode 100644 pkg/relay/param_scope_test.go delete mode 100644 pkg/relay/prefix_overlap_test.go delete mode 100644 pkg/relay/publish_done_code_test.go delete mode 100644 pkg/relay/publish_done_count_test.go rename pkg/relay/{publish_done_timing_test.go => publish_done_test.go} (61%) delete mode 100644 pkg/relay/publish_forward_test.go delete mode 100644 pkg/relay/rangefilter_update_relay_test.go delete mode 100644 pkg/relay/relay_upstream_goaway_test.go rename pkg/relay/{relay_upstream_affinity_test.go => relay_upstream_test.go} (59%) rename pkg/relay/{request_fin_test.go => request_stream_test.go} (64%) delete mode 100644 pkg/relay/subscribe_tracks_join_test.go create mode 100644 pkg/relay/subscribe_tracks_test.go delete mode 100644 pkg/relay/subscribe_tracks_update_test.go delete mode 100644 pkg/relay/track_status_largest_test.go create mode 100644 pkg/relay/track_status_test.go delete mode 100644 pkg/relay/track_status_update_test.go delete mode 100644 pkg/relay/update_ok_largest_test.go diff --git a/pkg/relay/cache_test.go b/pkg/relay/cache_test.go new file mode 100644 index 00000000..7beb832c --- /dev/null +++ b/pkg/relay/cache_test.go @@ -0,0 +1,327 @@ +package relay_test + +import ( + "context" + "errors" + "io" + "testing" + "time" + + "github.com/floatdrop/moq-go/pkg/moqt/message" + "github.com/floatdrop/moq-go/pkg/moqt/session" + "github.com/floatdrop/moq-go/pkg/moqt/wire" + "github.com/floatdrop/moq-go/pkg/relay" +) + +// The relay's per-track cache as seen by FETCH: size-based eviction, and +// MAX_CACHE_DURATION (§12.3), after which the relay must not start forwarding +// an Object, from the cache or from a live subscriber's queue. + +// TestFetch_CacheEvictionUnderLoad: past MaxCacheSize the cache evicts the +// oldest Objects, so a FETCH of the early range returns fewer Objects than it +// spans while the recent tail is still served. +func TestFetch_CacheEvictionUnderLoad(t *testing.T) { + t.Parallel() + + const cacheCap = 16 + + pubSess, teardown := connectRelay(t, relay.Config{ + MaxCacheSize: cacheCap, + }) + defer teardown() + + const publisherAlias = uint64(7) + pubReq, err := pubSess.Publish(t.Context(), &message.Publish{ + Namespace: ns("video"), + Name: []byte("cam1"), + TrackAlias: publisherAlias, + }) + if err != nil { + t.Fatalf("Publish: %v", err) + } + defer pubReq.Close() + + // The fanout caches only while a subscriber exists. + subSess := dialAnotherClient(t, pubSess) + subReq, err := subSess.Subscribe(t.Context(), &message.Subscribe{ + Namespace: ns("video"), + Name: []byte("cam1"), + }) + if err != nil { + t.Fatalf("Subscribe: %v", err) + } + defer subReq.Close() + go drainAll(t.Context(), subSess) + + const totalObjects = cacheCap * 4 + publishObjects(t, pubSess, publisherAlias, 0 /*group*/, totalObjects) + time.Sleep(200 * time.Millisecond) // let the flood reach the cache + + fetchSess := dialAnotherClient(t, pubSess) + _, recent := fetchAndDrain(t, + fetchSess, + ns("video"), + []byte("cam1"), + message.Location{Group: 0, Object: uint64(totalObjects - cacheCap)}, + message.Location{Group: 0, Object: uint64(totalObjects - 1)}, + message.GroupOrderAscending, + ) + if len(recent) == 0 { + t.Fatal("recent-tail FETCH returned 0 objects; expected eviction to retain recently-published entries") + } + if len(recent) > cacheCap { + t.Fatalf("recent-tail FETCH returned %d objects, want <= cacheCap (%d)", len(recent), cacheCap) + } + for _, o := range recent { + if o.object < uint64(totalObjects-cacheCap) { + t.Fatalf("recent-tail FETCH returned object %d, below the tail boundary (%d)", + o.object, totalObjects-cacheCap) + } + } + + fetchSess2 := dialAnotherClient(t, pubSess) + _, oldest := fetchAndDrain(t, + fetchSess2, + ns("video"), + []byte("cam1"), + message.Location{Group: 0, Object: 0}, + message.Location{Group: 0, Object: uint64(cacheCap - 1)}, + message.GroupOrderAscending, + ) + if len(oldest) >= cacheCap { + t.Fatalf("oldest-range FETCH returned %d objects; expected eviction to have dropped some (want < %d)", + len(oldest), cacheCap) + } +} + +const maxCacheMs = 100 + +// maxCacheDurationProp sets MAX_CACHE_DURATION to maxCacheMs. +func maxCacheDurationProp() []wire.KVPair { + return trackProp(message.PropertyMaxCacheDuration, maxCacheMs) +} + +// fetchCam1 FETCHes video/cam1 up to {lastGroup, 0}, retrying for 2s until +// served. +func fetchCam1(t *testing.T, sess *session.Session, lastGroup uint64) []fetchElem { + t.Helper() + for deadline := time.Now().Add(2 * time.Second); ; time.Sleep(20 * time.Millisecond) { + if elems := tryFetchElems(t, sess, ns("video"), []byte("cam1"), lastGroup, nil); elems != nil { + return elems + } + if time.Now().After(deadline) { + t.Fatal("FETCH never served") + } + } +} + +// findElem returns the element at {group, 0}. +func findElem(elems []fetchElem, group uint64) (fetchElem, bool) { + for _, e := range elems { + if e.Group == group && e.Object == 0 { + return e, true + } + } + return fetchElem{}, false +} + +// TestRelay_MaxCacheDurationNotServedFromCache: a FETCH is not served an Object +// whose MAX_CACHE_DURATION elapsed (§12.3). This relay reads a present 0 as +// "never serve from the cache", unlike an absent one, while still forwarding +// live Objects. +func TestRelay_MaxCacheDurationNotServedFromCache(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + duration uint64 + wait time.Duration + }{ + {"expired", maxCacheMs, 3 * maxCacheMs * time.Millisecond}, + {"zero", 0, 0}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + pubSess, alias := newCam1Publisher(t, trackProp(message.PropertyMaxCacheDuration, tc.duration)) + subSess := newCam1Subscriber(t, pubSess) + publishObjects(t, pubSess, alias, 3, 1) + if !awaitSubgroupObject(t, subSess, 2*time.Second) { + t.Fatal("live subscriber did not receive the Object") + } + time.Sleep(tc.wait) + + fetchSess := dialAnotherClient(t, pubSess) + for _, e := range tryFetchElems(t, fetchSess, ns("video"), []byte("cam1"), 3, nil) { + if !e.Unknown && e.Group == 3 { + t.Fatalf("FETCH served Object {3,%d} with MAX_CACHE_DURATION=%d after %v", + e.Object, tc.duration, tc.wait) + } + } + }) + } +} + +// TestRelay_MaxCacheDurationDropsStaleQueuedObject: an Object that waited in a +// slow subscriber's queue past the duration is not forwarded to it. +func TestRelay_MaxCacheDurationDropsStaleQueuedObject(t *testing.T) { + t.Parallel() + pubSess, alias := newCam1Publisher(t, maxCacheDurationProp()) + subSess := newCam1Subscriber(t, pubSess) + go sendObjects(pubSess, alias, 3, 1) + // Not reading: the relay cannot forward until the subscriber accepts, + // by which time the Object is stale. + time.Sleep(3 * maxCacheMs * time.Millisecond) + + ctx, cancel := context.WithTimeout(t.Context(), 500*time.Millisecond) + defer cancel() + ds, err := subSess.AcceptDataStream(ctx) + if err != nil { + return // nothing forwarded: correct + } + sg, ok := ds.(*session.IncomingSubgroupStream) + if !ok { + t.Fatalf("got %T", ds) + } + if obj, err := sg.ReadObject(); err == nil { + t.Fatalf("relay forwarded a stale Object (%d-byte payload) past MAX_CACHE_DURATION", len(obj.Payload)) + } +} + +// TestRelay_MaxCacheDurationSkippedHeadClearsFirstObject: a stream whose +// expired first Object was skipped must not claim FIRST_OBJECT (§11.4.2), and +// must end in a reset, not a FIN (§11.4.3). +func TestRelay_MaxCacheDurationSkippedHeadClearsFirstObject(t *testing.T) { + t.Parallel() + pubSess, alias := newCam1Publisher(t, maxCacheDurationProp()) + subSess := newCam1Subscriber(t, pubSess) + sg, err := pubSess.OpenSubgroup(subgroupHeader(alias, 3)) + if err != nil { + t.Fatalf("OpenSubgroup: %v", err) + } + if err := sg.WriteObject(&message.SubgroupObject{Payload: []byte("head")}); err != nil { + t.Fatalf("write head: %v", err) + } + // The subscriber does not read yet, so the head goes stale in the + // relay's queue; then a fresh Object follows. + time.Sleep(3 * maxCacheMs * time.Millisecond) + go func() { + _ = sg.WriteObject(&message.SubgroupObject{Payload: []byte("tail")}) + _ = sg.Close() + }() + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + ds, err := subSess.AcceptDataStream(ctx) + cancel() + if err != nil { + t.Fatal("the fresh Object was never forwarded") + } + in := ds.(*session.IncomingSubgroupStream) + obj, err := in.ReadObject() + if err != nil { + continue // a stream that carried only the skipped head + } + if string(obj.Payload) == "head" { + t.Fatal("relay forwarded the stale head Object") + } + if !in.Header.ReplayingSubgroup { + t.Fatal("stream starting after a skipped head claims FIRST_OBJECT") + } + for { + if _, err := in.ReadObject(); err != nil { + if errors.Is(err, io.EOF) { + t.Fatal("the stream after an expired head ended with a FIN; want a reset") + } + return + } + } + } +} + +// TestRelay_MaxCacheDurationPerUpstream: an Object is bound by the +// MAX_CACHE_DURATION of the upstream it arrived on (§12.3), not another +// publisher's. +func TestRelay_MaxCacheDurationPerUpstream(t *testing.T) { + t.Parallel() + pubA, _ := newCam1Publisher(t, maxCacheDurationProp()) + pubB := dialAnotherClient(t, pubA) + publishVideoTrackProps(t, pubB, "cam1", 9, nil) + newCam1Subscriber(t, pubA) + publishObjects(t, pubB, 9, 5, 1) + time.Sleep(3 * maxCacheMs * time.Millisecond) + + elems := fetchCam1(t, dialAnotherClient(t, pubA), 5) + if e, ok := findElem(elems, 5); !ok || e.Unknown { + t.Fatalf("FETCH elements %+v: the Object from the publisher without MAX_CACHE_DURATION "+ + "expired by the other publisher's value", elems) + } +} + +// TestRelay_MaxCacheDurationExpiredObjectIsUnknown: an expired Object's state +// is unknown (§12.3), so FETCH reports it with an End of Unknown Range marker, +// not a gap (§11.4.4). +func TestRelay_MaxCacheDurationExpiredObjectIsUnknown(t *testing.T) { + t.Parallel() + pubA, aliasA := newCam1Publisher(t, maxCacheDurationProp()) + pubB := dialAnotherClient(t, pubA) + publishVideoTrackProps(t, pubB, "cam1", 9, nil) + newCam1Subscriber(t, pubA) + publishObjects(t, pubB, 9, 1, 1) + publishObjects(t, pubA, aliasA, 2, 1) + publishObjects(t, pubB, 9, 3, 1) + time.Sleep(3 * maxCacheMs * time.Millisecond) + + elems := fetchCam1(t, dialAnotherClient(t, pubA), 3) + e1, ok1 := findElem(elems, 1) + e2, ok2 := findElem(elems, 2) + e3, ok3 := findElem(elems, 3) + if !ok1 || e1.Unknown || !ok3 || e3.Unknown || !ok2 || !e2.Unknown { + t.Fatalf("FETCH elements %+v, want Objects at groups 1 and 3 and an unknown marker at group 2", elems) + } +} + +// TestRelay_MaxCacheDurationExpiresDuringFetch: an Object fresh when the FETCH +// began but expired by its turn to be written (slow reader) is not sent. +func TestRelay_MaxCacheDurationExpiresDuringFetch(t *testing.T) { + t.Parallel() + pubSess, alias := newCam1Publisher(t, maxCacheDurationProp()) + newCam1Subscriber(t, pubSess) + for g := uint64(1); g <= 3; g++ { + publishObjects(t, pubSess, alias, g, 1) + } + fc := dialAnotherClient(t, pubSess) + fr, err := fc.Fetch(t.Context(), &message.Fetch{ + Namespace: ns("video"), Name: []byte("cam1"), + Parameters: message.Parameters{fetchRangeFilter(message.Location{Group: 1}, message.Location{Group: 3})}, + }) + if err != nil { + t.Fatalf("Fetch: %v", err) + } + defer fr.Close() + ds, err := fc.AcceptDataStream(t.Context()) + if err != nil { + t.Fatalf("AcceptDataStream: %v", err) + } + fs := ds.(*session.IncomingFetchStream) + time.Sleep(3 * maxCacheMs * time.Millisecond) // the relay's writes wait on this read + + var served []uint64 + for { + obj, err := fs.ReadDecoded() + if err != nil { + if !errors.Is(err, io.EOF) { + t.Fatalf("fetch stream: %v", err) + } + break + } + if !obj.IsEndOfRange() { + served = append(served, obj.GroupID) + } + } + if len(served) > 1 { + t.Fatalf( + "served Objects in groups %v after they expired; at most the first write may have started in time", + served, + ) + } +} diff --git a/pkg/relay/cross_relay_test.go b/pkg/relay/cross_relay_test.go index b11492f0..84fd0e18 100644 --- a/pkg/relay/cross_relay_test.go +++ b/pkg/relay/cross_relay_test.go @@ -71,8 +71,6 @@ func dialClient(t *testing.T, tr *testRelay) *session.Session { return sess } -func videoNS() wire.TrackNamespace { return wire.TrackNamespace{[]byte("video")} } - // TestCrossRelay_OnDemandSubscribe is the end-to-end happy path: a subscriber // on relay A receives objects published to relay B, routed across the boundary // purely through Discovery + the Dialer. B advertises the "video" namespace; @@ -101,13 +99,13 @@ func TestCrossRelay_OnDemandSubscribe(t *testing.T) { // Publisher connects to B, advertises the namespace (so FindNamespace can // route here) and PUBLISHes the track (so B has an established upstream). pubSess := dialClient(t, relayB) - pns, err := pubSess.PublishNamespace(ctx, &message.PublishNamespace{Namespace: videoNS()}) + pns, err := pubSess.PublishNamespace(ctx, &message.PublishNamespace{Namespace: ns("video")}) if err != nil { t.Fatalf("PublishNamespace: %v", err) } const pubAlias = uint64(7) pubReq, err := pubSess.Publish(ctx, &message.Publish{ - Namespace: videoNS(), + Namespace: ns("video"), Name: []byte("cam1"), TrackAlias: pubAlias, }) @@ -120,7 +118,7 @@ func TestCrossRelay_OnDemandSubscribe(t *testing.T) { // publisher), so the full chain is live by the time we push objects. subSess := dialClient(t, relayA) subReq, err := subSess.Subscribe(ctx, &message.Subscribe{ - Namespace: videoNS(), + Namespace: ns("video"), Name: []byte("cam1"), }) if err != nil { @@ -226,14 +224,14 @@ func TestCrossRelay_LocalPublisherFailureFallsBackToDiscovery(t *testing.T) { // Healthy publisher on B serves video/cam1. pubB := dialClient(t, relayB) - pnsB, err := pubB.PublishNamespace(ctx, &message.PublishNamespace{Namespace: videoNS()}) + pnsB, err := pubB.PublishNamespace(ctx, &message.PublishNamespace{Namespace: ns("video")}) if err != nil { t.Fatalf("B PublishNamespace: %v", err) } const pubAlias = uint64(9) pubReqB, err := pubB.Publish( ctx, - &message.Publish{Namespace: videoNS(), Name: []byte("cam1"), TrackAlias: pubAlias}, + &message.Publish{Namespace: ns("video"), Name: []byte("cam1"), TrackAlias: pubAlias}, ) if err != nil { t.Fatalf("B Publish: %v", err) @@ -242,7 +240,7 @@ func TestCrossRelay_LocalPublisherFailureFallsBackToDiscovery(t *testing.T) { // A local publisher on A advertises the same namespace but REJECTS every // upstream SUBSCRIBE — the relay must try it, fail, then fall back to B. pLocal := dialClient(t, relayA) - pnsLocal, err := pLocal.PublishNamespace(ctx, &message.PublishNamespace{Namespace: videoNS()}) + pnsLocal, err := pLocal.PublishNamespace(ctx, &message.PublishNamespace{Namespace: ns("video")}) if err != nil { t.Fatalf("local PublishNamespace: %v", err) } @@ -260,7 +258,7 @@ func TestCrossRelay_LocalPublisherFailureFallsBackToDiscovery(t *testing.T) { // Subscriber on A: the local publisher rejects, so A must reach B. subSess := dialClient(t, relayA) - subReq, err := subSess.Subscribe(ctx, &message.Subscribe{Namespace: videoNS(), Name: []byte("cam1")}) + subReq, err := subSess.Subscribe(ctx, &message.Subscribe{Namespace: ns("video"), Name: []byte("cam1")}) if err != nil { t.Fatalf("Subscribe should have fallen back to Discovery, got: %v", err) } @@ -361,10 +359,10 @@ func TestCrossRelay_MultiRemoteFanIn(t *testing.T) { // A redundant publisher on each of B and C: same track, same namespace. startPub := func(tr *testRelay) (*session.Session, *session.Publication) { ps := dialClient(t, tr) - if _, err := ps.PublishNamespace(ctx, &message.PublishNamespace{Namespace: videoNS()}); err != nil { + if _, err := ps.PublishNamespace(ctx, &message.PublishNamespace{Namespace: ns("video")}); err != nil { t.Fatalf("PublishNamespace: %v", err) } - p, err := ps.Publish(ctx, &message.Publish{Namespace: videoNS(), Name: []byte("cam1"), TrackAlias: 7}) + p, err := ps.Publish(ctx, &message.Publish{Namespace: ns("video"), Name: []byte("cam1"), TrackAlias: 7}) if err != nil { t.Fatalf("Publish: %v", err) } @@ -376,7 +374,7 @@ func TestCrossRelay_MultiRemoteFanIn(t *testing.T) { // Subscriber on A. Subscribe returns only after A has established BOTH // upstreams (to B and C), so both Dialer calls have happened by here. subSess := dialClient(t, relayA) - subReq, err := subSess.Subscribe(ctx, &message.Subscribe{Namespace: videoNS(), Name: []byte("cam1")}) + subReq, err := subSess.Subscribe(ctx, &message.Subscribe{Namespace: ns("video"), Name: []byte("cam1")}) if err != nil { t.Fatalf("cross-relay Subscribe: %v", err) } @@ -476,7 +474,7 @@ func TestCrossRelay_SelfExclusion(t *testing.T) { // Seed the store with a namespace advertised by relay-A itself. if err := store.PublishNamespace(ctx, discovery.NamespaceInfo{ - Prefix: videoNS(), + Prefix: ns("video"), RelayAddr: "relay-A", }); err != nil { t.Fatalf("seed PublishNamespace: %v", err) @@ -484,7 +482,7 @@ func TestCrossRelay_SelfExclusion(t *testing.T) { subSess := dialClient(t, relayA) _, err := subSess.Subscribe(ctx, &message.Subscribe{ - Namespace: videoNS(), + Namespace: ns("video"), Name: []byte("cam1"), }) if err == nil { @@ -527,25 +525,25 @@ func TestCrossRelay_PoolReuse(t *testing.T) { // Publisher on B advertises the namespace and PUBLISHes two tracks. pubSess := dialClient(t, relayB) - pns, err := pubSess.PublishNamespace(ctx, &message.PublishNamespace{Namespace: videoNS()}) + pns, err := pubSess.PublishNamespace(ctx, &message.PublishNamespace{Namespace: ns("video")}) if err != nil { t.Fatalf("PublishNamespace: %v", err) } - pub1, err := pubSess.Publish(ctx, &message.Publish{Namespace: videoNS(), Name: []byte("cam1"), TrackAlias: 1}) + pub1, err := pubSess.Publish(ctx, &message.Publish{Namespace: ns("video"), Name: []byte("cam1"), TrackAlias: 1}) if err != nil { t.Fatalf("Publish cam1: %v", err) } - pub2, err := pubSess.Publish(ctx, &message.Publish{Namespace: videoNS(), Name: []byte("cam2"), TrackAlias: 2}) + pub2, err := pubSess.Publish(ctx, &message.Publish{Namespace: ns("video"), Name: []byte("cam2"), TrackAlias: 2}) if err != nil { t.Fatalf("Publish cam2: %v", err) } subSess := dialClient(t, relayA) - sub1, err := subSess.Subscribe(ctx, &message.Subscribe{Namespace: videoNS(), Name: []byte("cam1")}) + sub1, err := subSess.Subscribe(ctx, &message.Subscribe{Namespace: ns("video"), Name: []byte("cam1")}) if err != nil { t.Fatalf("Subscribe cam1: %v", err) } - sub2, err := subSess.Subscribe(ctx, &message.Subscribe{Namespace: videoNS(), Name: []byte("cam2")}) + sub2, err := subSess.Subscribe(ctx, &message.Subscribe{Namespace: ns("video"), Name: []byte("cam2")}) if err != nil { t.Fatalf("Subscribe cam2: %v", err) } @@ -580,7 +578,7 @@ func TestCrossRelay_WatchNamespacesForward(t *testing.T) { subSess := dialClient(t, relayA) nsReq, err := subSess.SubscribeNamespace(ctx, &message.SubscribeNamespace{ - TrackNamespacePrefix: videoNS(), + TrackNamespacePrefix: ns("video"), }) if err != nil { t.Fatalf("SubscribeNamespace: %v", err) @@ -601,7 +599,7 @@ func TestCrossRelay_WatchNamespacesForward(t *testing.T) { defer ticker.Stop() for { _ = store.PublishNamespace(ctx, discovery.NamespaceInfo{ - Prefix: wire.TrackNamespace{[]byte("video"), []byte("cam1")}, + Prefix: ns("video", "cam1"), RelayAddr: "relay-C", }) select { @@ -651,13 +649,13 @@ func TestCrossRelay_WatchNamespacesForwardsUnpublish(t *testing.T) { subSess := dialClient(t, relayA) nsReq, err := subSess.SubscribeNamespace(ctx, &message.SubscribeNamespace{ - TrackNamespacePrefix: videoNS(), + TrackNamespacePrefix: ns("video"), }) if err != nil { t.Fatalf("SubscribeNamespace: %v", err) } - remoteNS := wire.TrackNamespace{[]byte("video"), []byte("cam1")} + remoteNS := ns("video", "cam1") // Same re-advertise ticker as TestCrossRelay_WatchNamespacesForward, and for // the same reason: the watcher registers asynchronously in Start and @@ -747,7 +745,7 @@ func TestCrossRelay_WatchNamespacesSkipsTrackSubscribers(t *testing.T) { // each tick below is offered to both and only the skip separates them. nsSess := dialClient(t, relayA) nsReq, err := nsSess.SubscribeNamespace(ctx, &message.SubscribeNamespace{ - TrackNamespacePrefix: videoNS(), + TrackNamespacePrefix: ns("video"), }) if err != nil { t.Fatalf("SubscribeNamespace: %v", err) @@ -755,7 +753,7 @@ func TestCrossRelay_WatchNamespacesSkipsTrackSubscribers(t *testing.T) { trSess := dialClient(t, relayA) trSub, err := trSess.SubscribeTracks(ctx, &message.SubscribeTracks{ - TrackNamespacePrefix: videoNS(), + TrackNamespacePrefix: ns("video"), }) if err != nil { t.Fatalf("SubscribeTracks: %v", err) @@ -767,7 +765,7 @@ func TestCrossRelay_WatchNamespacesSkipsTrackSubscribers(t *testing.T) { defer ticker.Stop() for { _ = store.PublishNamespace(ctx, discovery.NamespaceInfo{ - Prefix: wire.TrackNamespace{[]byte("video"), []byte("cam1")}, + Prefix: ns("video", "cam1"), RelayAddr: "relay-C", }) select { @@ -839,7 +837,7 @@ func TestCrossRelay_SubscribeNamespaceSeedsRemote(t *testing.T) { // Remote advertisement exists before the subscriber (and before relay A). if err := store.PublishNamespace(ctx, discovery.NamespaceInfo{ - Prefix: wire.TrackNamespace{[]byte("video"), []byte("cam1")}, + Prefix: ns("video", "cam1"), RelayAddr: "relay-C", }); err != nil { t.Fatalf("seed PublishNamespace: %v", err) @@ -849,7 +847,7 @@ func TestCrossRelay_SubscribeNamespaceSeedsRemote(t *testing.T) { subSess := dialClient(t, relayA) nsReq, err := subSess.SubscribeNamespace(ctx, &message.SubscribeNamespace{ - TrackNamespacePrefix: videoNS(), + TrackNamespacePrefix: ns("video"), }) if err != nil { t.Fatalf("SubscribeNamespace: %v", err) @@ -889,7 +887,7 @@ func TestCrossRelay_ConcurrentSubscriberWrites(t *testing.T) { // writes never block. subSess := dialClient(t, relayA) nsReq, err := subSess.SubscribeNamespace(ctx, &message.SubscribeNamespace{ - TrackNamespacePrefix: wire.TrackNamespace{[]byte("room")}, + TrackNamespacePrefix: ns("room"), }) if err != nil { t.Fatalf("SubscribeNamespace: %v", err) @@ -992,7 +990,7 @@ func TestCrossRelay_NoDialerNoop(t *testing.T) { // Seed a remote namespace; without a Dialer the relay must not try to use // it. if err := store.PublishNamespace(ctx, discovery.NamespaceInfo{ - Prefix: videoNS(), + Prefix: ns("video"), RelayAddr: "relay-B", }); err != nil { t.Fatalf("seed PublishNamespace: %v", err) @@ -1001,7 +999,7 @@ func TestCrossRelay_NoDialerNoop(t *testing.T) { relayA := startTestRelay(ctx, relay.Config{Discovery: store, RelayAddr: "relay-A"}) subSess := dialClient(t, relayA) - _, err := subSess.Subscribe(ctx, &message.Subscribe{Namespace: videoNS(), Name: []byte("cam1")}) + _, err := subSess.Subscribe(ctx, &message.Subscribe{Namespace: ns("video"), Name: []byte("cam1")}) if err == nil { t.Fatal("Subscribe succeeded; want rejection (no Dialer, no local publisher)") } @@ -1039,12 +1037,12 @@ func TestCrossRelay_UpstreamFanInCapConverges(t *testing.T) { var pubSessions []*session.Session for addr, tr := range remotes { ps := dialClient(t, tr) - if _, err := ps.PublishNamespace(ctx, &message.PublishNamespace{Namespace: videoNS()}); err != nil { + if _, err := ps.PublishNamespace(ctx, &message.PublishNamespace{Namespace: ns("video")}); err != nil { t.Fatalf("%s PublishNamespace: %v", addr, err) } if _, err := ps.Publish( ctx, - &message.Publish{Namespace: videoNS(), Name: []byte("cam1"), TrackAlias: 7}, + &message.Publish{Namespace: ns("video"), Name: []byte("cam1"), TrackAlias: 7}, ); err != nil { t.Fatalf("%s Publish: %v", addr, err) } @@ -1096,12 +1094,12 @@ func TestCrossRelay_UpstreamFanInCapConverges(t *testing.T) { // Subscribe blocks until the (single) upstream is established, so the dial // logs are settled by the time each call returns. sub1 := dialClient(t, relayA1) - req1, err := sub1.Subscribe(ctx, &message.Subscribe{Namespace: videoNS(), Name: []byte("cam1")}) + req1, err := sub1.Subscribe(ctx, &message.Subscribe{Namespace: ns("video"), Name: []byte("cam1")}) if err != nil { t.Fatalf("A1 Subscribe: %v", err) } sub2 := dialClient(t, relayA2) - req2, err := sub2.Subscribe(ctx, &message.Subscribe{Namespace: videoNS(), Name: []byte("cam1")}) + req2, err := sub2.Subscribe(ctx, &message.Subscribe{Namespace: ns("video"), Name: []byte("cam1")}) if err != nil { t.Fatalf("A2 Subscribe: %v", err) } @@ -1166,7 +1164,7 @@ func TestCrossRelay_GoawayPrecedesUpstreamTeardown(t *testing.T) { // resolves it as an upstream, and serve the far end of the dialled pipe here. const peerAddr = "peer:4433" if err := store.PublishNamespace(ctx, - discovery.NamespaceInfo{Prefix: videoNS(), RelayAddr: peerAddr}); err != nil { + discovery.NamespaceInfo{Prefix: ns("video"), RelayAddr: peerAddr}); err != nil { t.Fatalf("PublishNamespace: %v", err) } @@ -1199,7 +1197,7 @@ func TestCrossRelay_GoawayPrecedesUpstreamTeardown(t *testing.T) { // does, and this peer deliberately never replies — the dial is all we need. subSess := dialClient(t, r) go func() { - _, _ = subSess.Subscribe(ctx, &message.Subscribe{Namespace: videoNS(), Name: []byte("cam1")}) + _, _ = subSess.Subscribe(ctx, &message.Subscribe{Namespace: ns("video"), Name: []byte("cam1")}) }() var peer *session.Session @@ -1282,13 +1280,13 @@ func TestCrossRelay_FetchBackfillsPublishOnceTrack(t *testing.T) { // Publisher on B publishes the whole track, then stops. Nothing is written // after the subscriber joins, so live delivery cannot cover any of it. pubSess := dialClient(t, relayB) - pns, err := pubSess.PublishNamespace(ctx, &message.PublishNamespace{Namespace: videoNS()}) + pns, err := pubSess.PublishNamespace(ctx, &message.PublishNamespace{Namespace: ns("video")}) if err != nil { t.Fatalf("PublishNamespace: %v", err) } const pubAlias = uint64(7) pubReq, err := pubSess.Publish(ctx, &message.Publish{ - Namespace: videoNS(), + Namespace: ns("video"), Name: []byte("catalog"), TrackAlias: pubAlias, }) @@ -1327,7 +1325,7 @@ func TestCrossRelay_FetchBackfillsPublishOnceTrack(t *testing.T) { // to B. Bind the message so its assigned Request ID can anchor the Joining // FETCH below (Subscribe mutates RequestID via AllocRequestID). subSess := dialClient(t, relayA) - subMsg := &message.Subscribe{Namespace: videoNS(), Name: []byte("catalog")} + subMsg := &message.Subscribe{Namespace: ns("video"), Name: []byte("catalog")} subReq, err := subSess.Subscribe(ctx, subMsg) if err != nil { t.Fatalf("cross-relay Subscribe: %v", err) @@ -1439,7 +1437,7 @@ func TestCrossRelay_PublishDoneCodeCrossesRelays(t *testing.T) { pubSess := dialClient(t, relayB) defer func() { _ = pubSess.Close(0, "done") }() - pns, err := pubSess.PublishNamespace(ctx, &message.PublishNamespace{Namespace: videoNS()}) + pns, err := pubSess.PublishNamespace(ctx, &message.PublishNamespace{Namespace: ns("video")}) if err != nil { t.Fatalf("PublishNamespace: %v", err) } @@ -1448,7 +1446,7 @@ func TestCrossRelay_PublishDoneCodeCrossesRelays(t *testing.T) { subSess := dialClient(t, relayA) defer func() { _ = subSess.Close(0, "done") }() - subReq := subscribeCam1Req(t, subSess) + subReq := subscribeCam1(t, subSess) if err := pub.Done(moqt.PublishDoneMalformedTrack, "bad track"); err != nil { t.Fatalf("Done: %v", err) diff --git a/pkg/relay/data_stream_test.go b/pkg/relay/data_stream_test.go new file mode 100644 index 00000000..fa6f6518 --- /dev/null +++ b/pkg/relay/data_stream_test.go @@ -0,0 +1,330 @@ +package relay_test + +import ( + "context" + "errors" + "io" + "math" + "slices" + "testing" + "time" + + "github.com/floatdrop/moq-go/pkg/moqt/message" + "github.com/floatdrop/moq-go/pkg/moqt/session" + "github.com/floatdrop/moq-go/pkg/moqt/wire" + "github.com/floatdrop/moq-go/pkg/relay" +) + +// Inbound data streams at the relay: malformed or early ones, and the session +// errors they carry. + +// TestRelay_UnknownDataStreamTypeClosesSession: an unknown data stream type +// closes the session (§3.4). +func TestRelay_UnknownDataStreamTypeClosesSession(t *testing.T) { + t.Parallel() + l := newPipeListener() + _, teardown := connectRelayOn(t, relay.Config{}, l) + defer teardown() + + peer, conn := dialRaw(t, l) + uni, err := conn.OpenUniStream() + if err != nil { + t.Fatalf("OpenUniStream: %v", err) + } + // 0x01 is none of SUBGROUP_HEADER, FETCH_HEADER or PADDING. + if _, err := uni.Write([]byte{0x01}); err != nil { + t.Fatalf("Write: %v", err) + } + _ = uni.Close() + + requireSessionClosed(t, peer, "an unknown data stream type") +} + +// TestRelay_AbortedDataStreamHeaderKeepsServing: a data stream that ends +// mid-header does not stop the relay forwarding the publisher's later streams +// (§11.4.1). +func TestRelay_AbortedDataStreamHeaderKeepsServing(t *testing.T) { + t.Parallel() + l := newPipeListener() + subSess, teardown := connectRelayOn(t, relay.Config{}, l) + defer teardown() + + pubSess, conn := dialRaw(t, l) + const alias = uint64(7) + publishVideoTrack(t, pubSess, "cam1", alias) + subscribeCam1(t, subSess) + + // SUBGROUP_HEADER type, then FIN before the Track Alias. + uni, err := conn.OpenUniStream() + if err != nil { + t.Fatalf("OpenUniStream: %v", err) + } + if _, err := uni.Write([]byte{0x10}); err != nil { + t.Fatalf("Write: %v", err) + } + _ = uni.Close() + + // From a goroutine, so a relay that stopped accepting streams fails the + // assertion below rather than hanging the write. + go sendObjects(pubSess, alias, 3, 1) + if !awaitSubgroupObject(t, subSess, 2*time.Second) { + t.Fatal("relay stopped forwarding after a data stream ended mid-header") + } +} + +// TestRelay_FINMidObjectIsNotForwardedAsCleanEnd: an END_OF_GROUP subgroup +// stream FIN'd mid-object (§11.4) is not forwarded with a clean FIN. +func TestRelay_FINMidObjectIsNotForwardedAsCleanEnd(t *testing.T) { + t.Parallel() + pubSess, alias := newCam1Publisher(t, nil) + subSess := newCam1Subscriber(t, pubSess) + + go func() { + sg, err := pubSess.OpenSubgroup(message.SubgroupHeader{ + SubgroupIDMode: message.SubgroupIDExplicit, + TrackAlias: alias, + GroupID: 3, + EndOfGroup: true, + }) + if err != nil { + return + } + _ = sg.WriteObject(&message.SubgroupObject{Payload: []byte("whole")}) + _, _ = sg.Write([]byte{0x00}) // next object: Object ID Delta only + _ = sg.Close() + }() + + // The torn object closes the publisher's session, which can beat the + // relay's lazy downstream open — then no stream arrives at all, which is + // fine: nothing was forwarded as complete. + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + ds, err := subSess.AcceptDataStream(ctx) + if err != nil { + if ctx.Err() != nil { + return + } + t.Fatalf("AcceptDataStream: %v", err) + } + sg, ok := ds.(*session.IncomingSubgroupStream) + if !ok { + t.Fatalf("got %T, want *session.IncomingSubgroupStream", ds) + } + // The complete first object may or may not arrive either: the relay can + // reset the downstream stream before its writer drains. What must never + // happen is a clean end. + for range 2 { + if _, err := sg.ReadObject(); err != nil { + if errors.Is(err, io.EOF) { + t.Fatalf("ReadObject = %v; the torn stream was forwarded as a clean end", err) + } + return + } + } + t.Fatal("read two objects from a stream whose second object was torn") +} + +// TestRelay_ObjectIDOverflowClosesSession: an Object ID delta past 2^64 - 1 +// closes the session (§11.4.2). +func TestRelay_ObjectIDOverflowClosesSession(t *testing.T) { + t.Parallel() + pubSess, alias := newCam1Publisher(t, nil) + _ = newCam1Subscriber(t, pubSess) + + go func() { + sg, err := pubSess.OpenSubgroup(message.SubgroupHeader{ + SubgroupIDMode: message.SubgroupIDExplicit, + TrackAlias: alias, + GroupID: 3, + }) + if err != nil { + return + } + _ = sg.WriteObject(&message.SubgroupObject{ObjectIDDelta: math.MaxUint64, Payload: []byte("a")}) + _ = sg.WriteObject(&message.SubgroupObject{ObjectIDDelta: 0, Payload: []byte("b")}) + _ = sg.Close() + }() + + requireSessionClosed(t, pubSess, "an Object ID overflow") +} + +// TestRelay_NonFirstRequestOpenerClosesSession: a request stream opened with a +// message not marked "First" in Table 5, here PUBLISH_STATE_NOTIFY (§10.10), +// closes the session. +func TestRelay_NonFirstRequestOpenerClosesSession(t *testing.T) { + t.Parallel() + l := newPipeListener() + _, teardown := connectRelayOn(t, relay.Config{}, l) + defer teardown() + + peer, conn := dialRaw(t, l) + stream, err := conn.OpenStream() + if err != nil { + t.Fatalf("OpenStream: %v", err) + } + go func() { _ = message.Marshal(stream, &message.PublishStateNotify{}) }() + + requireSessionClosed(t, peer, "a PUBLISH_STATE_NOTIFY opened a request stream") +} + +// TestRelay_SubgroupBeforeSubscribeOKIsDelivered: a subgroup stream that +// arrives before the SUBSCRIBE_OK naming its alias is held briefly (§11.4.2) +// rather than abandoned. Not parallel: the hook is process-wide. +func TestRelay_SubgroupBeforeSubscribeOKIsDelivered(t *testing.T) { + const alias = uint64(4242) // unique to this test + waiting := make(chan struct{}, 1) + defer relay.SetTestHookEarlyStreamWaiting(func(a uint64) { + if a == alias { + select { + case waiting <- struct{}{}: + default: + } + } + })() + + pubSess, teardown := connectRelay(t, relay.Config{}) + t.Cleanup(teardown) + video := ns("video") + if _, err := pubSess.PublishNamespace(t.Context(), &message.PublishNamespace{Namespace: video}); err != nil { + t.Fatalf("PublishNamespace: %v", err) + } + + go func() { + req, err := pubSess.AcceptRequest(t.Context()) + if err != nil { + return + } + if _, ok := req.First.(*message.Subscribe); !ok { + return + } + // Errors are ignored so the reply always goes out. + if sg, err := pubSess.OpenSubgroup(message.SubgroupHeader{ + SubgroupIDMode: message.SubgroupIDExplicit, + TrackAlias: alias, + }); err == nil { + go func() { + _ = sg.WriteObject(&message.SubgroupObject{Payload: []byte("x")}) + _ = sg.Close() + }() + } + select { + case <-waiting: + case <-time.After(time.Second): + } + _ = req.Reply(&message.SubscribeOK{TrackAlias: alias}) + }() + + subSess := dialAnotherClient(t, pubSess) + if _, err := subSess.Subscribe( + t.Context(), + &message.Subscribe{Namespace: video, Name: []byte("cam1")}, + ); err != nil { + t.Fatalf("Subscribe: %v", err) + } + // The object reaches the relay's cache. Live delivery to this subscriber + // is not asserted: the object may be forwarded before the subscriber's + // downstream is registered, and a subscription starts after the Largest + // Object at that point. + if !fetchesObject(t, dialAnotherClient(t, pubSess), video, []byte("cam1"), 2*time.Second) { + t.Fatal("the subgroup that arrived before its SUBSCRIBE_OK never reached the relay's cache: " + + "the relay abandoned it as an unknown Track Alias") + } +} + +// fetchesObject FETCHes the track until the response carries an Object with +// payload "x", reporting whether one did within the deadline. +func fetchesObject( + t *testing.T, + sess *session.Session, + ns wire.TrackNamespace, + name []byte, + within time.Duration, +) bool { + t.Helper() + deadline := time.Now().Add(within) + for time.Now().Before(deadline) { + fr, err := sess.Fetch(t.Context(), &message.Fetch{Namespace: ns, Name: name}) + if err == nil { + ds, err := sess.AcceptDataStream(t.Context()) + if err != nil { + t.Fatalf("AcceptDataStream: %v", err) + } + fs, ok := ds.(*session.IncomingFetchStream) + if !ok { + t.Fatalf("AcceptDataStream = %T, want a FETCH stream", ds) + } + objs := decodeFetchStream(t, fs, message.GroupOrderAscending) + _ = fr.Close() + // Only the published Object counts, not an End of Range marker. + if slices.ContainsFunc(objs, func(o decodedFetchObject) bool { return string(o.payload) == "x" }) { + return true + } + } + time.Sleep(20 * time.Millisecond) + } + return false +} + +// TestRelay_EarlySubgroupWaitIsBounded: at most maxEarlyStreams subgroup +// streams per session wait for an unknown alias, and none past earlyAliasWait. +func TestRelay_EarlySubgroupWaitIsBounded(t *testing.T) { + t.Parallel() + pubSess, teardown := connectRelay(t, relay.Config{}) + t.Cleanup(teardown) + + const ( + bogusAlias = uint64(999) // never bound by a SUBSCRIBE_OK or PUBLISH + waiting = 32 // relay.maxEarlyStreams + ) + // writeFails writes one object on a fresh subgroup stream in the + // background and reports when the write fails: the relay reset the stream. + writeFails := func() <-chan struct{} { + failed := make(chan struct{}) + sg, err := pubSess.OpenSubgroup(message.SubgroupHeader{ + SubgroupIDMode: message.SubgroupIDExplicit, + TrackAlias: bogusAlias, + }) + if err != nil { + t.Fatalf("OpenSubgroup: %v", err) + } + go func() { + if err := sg.WriteObject(&message.SubgroupObject{Payload: []byte("x")}); err != nil { + close(failed) + } + }() + return failed + } + + start := time.Now() + held := make([]<-chan struct{}, waiting) + for i := range held { + held[i] = writeFails() + } + time.Sleep(100 * time.Millisecond) // let the relay start waiting on each + + // One more than the relay will hold is refused at once... + select { + case <-writeFails(): + case <-time.After(500 * time.Millisecond): + t.Fatalf("stream %d was held too; the relay must reset streams past its limit at once", waiting+1) + } + // ...while every one within the limit is still held. + for i, failed := range held { + select { + case <-failed: + t.Fatalf("held stream %d was reset before the wait ran out; the relay holds %d", i, waiting) + default: + } + } + // The held ones are released after the brief wait, not kept for good. + for i, failed := range held { + select { + case <-failed: + case <-time.After(5 * time.Second): + t.Fatalf("held stream %d was still open %s after it arrived", i, time.Since(start)) + } + } + if d := time.Since(start); d < 500*time.Millisecond { + t.Errorf("held streams were reset after %s; the relay should wait about a second for the alias", d) + } +} diff --git a/pkg/relay/datastream_violation_test.go b/pkg/relay/datastream_violation_test.go deleted file mode 100644 index ca8097ed..00000000 --- a/pkg/relay/datastream_violation_test.go +++ /dev/null @@ -1,232 +0,0 @@ -package relay_test - -import ( - "context" - "errors" - "io" - "math" - "testing" - "time" - - "github.com/floatdrop/moq-go/pkg/moqt" - "github.com/floatdrop/moq-go/pkg/moqt/message" - "github.com/floatdrop/moq-go/pkg/moqt/session" - "github.com/floatdrop/moq-go/pkg/moqt/wire" - "github.com/floatdrop/moq-go/pkg/relay" -) - -// dialRaw connects a client session to the relay behind l and also returns its -// conn, so a test can open data streams the session API would never produce. -func dialRaw(t *testing.T, l *pipeListener) (*session.Session, session.Conn) { - t.Helper() - conn, err := l.Dial() - if err != nil { - t.Fatalf("Dial: %v", err) - } - sess, err := session.Client(t.Context(), conn) - if err != nil { - t.Fatalf("session.Client: %v", err) - } - t.Cleanup(func() { _ = sess.Close(moqt.SessionNoError, "") }) - return sess, conn -} - -// TestRelay_UnknownDataStreamTypeClosesSession pins §3.4 at the relay: "An -// endpoint that receives an unknown stream type MUST close the session." The -// relay used to stop accepting data streams and datagrams on that session -// without closing it, leaving the peer connected to a relay that silently -// ignored everything it sent. -func TestRelay_UnknownDataStreamTypeClosesSession(t *testing.T) { - t.Parallel() - l := newPipeListener() - _, teardown := connectRelayOn(t, relay.Config{}, l) - defer teardown() - - peer, conn := dialRaw(t, l) - uni, err := conn.OpenUniStream() - if err != nil { - t.Fatalf("OpenUniStream: %v", err) - } - // 0x01 is none of SUBGROUP_HEADER, FETCH_HEADER or PADDING. - if _, err := uni.Write([]byte{0x01}); err != nil { - t.Fatalf("Write: %v", err) - } - _ = uni.Close() - - select { - case <-peer.Done(): - case <-time.After(2 * time.Second): - t.Fatal("relay left the session open after an unknown data stream type") - } -} - -// TestRelay_AbortedDataStreamHeaderKeepsServing pins §11.4.1: "Early -// termination of a unidirectional stream does not affect the MOQT application -// state." A publisher's data stream that ends before its header is complete -// must not stop the relay from forwarding that publisher's later streams. -func TestRelay_AbortedDataStreamHeaderKeepsServing(t *testing.T) { - t.Parallel() - l := newPipeListener() - subSess, teardown := connectRelayOn(t, relay.Config{}, l) - defer teardown() - - pubSess, conn := dialRaw(t, l) - const alias = uint64(7) - pubReq, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("video")}, - Name: []byte("cam1"), - TrackAlias: alias, - }) - if err != nil { - t.Fatalf("Publish: %v", err) - } - t.Cleanup(func() { pubReq.Close() }) - - subReq, err := subSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, - Name: []byte("cam1"), - }) - if err != nil { - t.Fatalf("Subscribe: %v", err) - } - t.Cleanup(func() { subReq.Close() }) - - // SUBGROUP_HEADER type, then FIN before the Track Alias. - uni, err := conn.OpenUniStream() - if err != nil { - t.Fatalf("OpenUniStream: %v", err) - } - if _, err := uni.Write([]byte{0x10}); err != nil { - t.Fatalf("Write: %v", err) - } - _ = uni.Close() - - // From a goroutine: if the relay has stopped accepting streams, the - // unbuffered pipe blocks the write, and the assertion below should report - // that rather than the test hanging. - go func() { - sg, err := pubSess.OpenSubgroup(message.SubgroupHeader{ - SubgroupIDMode: message.SubgroupIDExplicit, - TrackAlias: alias, - GroupID: 3, - }) - if err != nil { - return - } - _ = sg.WriteObject(&message.SubgroupObject{Payload: []byte("x")}) - _ = sg.Close() - }() - if !awaitSubgroupObject(t, subSess, 2*time.Second) { - t.Fatal("relay stopped forwarding after a data stream ended mid-header") - } -} - -// TestRelay_FINMidObjectIsNotForwardedAsCleanEnd: a publisher's END_OF_GROUP -// subgroup stream that ends with a FIN in the middle of an object (§11.4) is -// torn, not complete. If the relay forwarded it as a clean FIN, the subscriber -// would conclude it holds the whole group. The forwarded stream must end in an -// error instead. -func TestRelay_FINMidObjectIsNotForwardedAsCleanEnd(t *testing.T) { - t.Parallel() - pubSess, alias := publishWithTrackProps(t, nil) - subSess := subscribeCam1(t, pubSess) - - go func() { - sg, err := pubSess.OpenSubgroup(message.SubgroupHeader{ - SubgroupIDMode: message.SubgroupIDExplicit, - TrackAlias: alias, - GroupID: 3, - EndOfGroup: true, - }) - if err != nil { - return - } - _ = sg.WriteObject(&message.SubgroupObject{Payload: []byte("whole")}) - _, _ = sg.Write([]byte{0x00}) // next object: Object ID Delta only - _ = sg.Close() - }() - - // The torn object closes the publisher's session, which can beat the - // relay's lazy downstream open — then no stream arrives at all, which is - // fine: nothing was forwarded as complete. - ctx, cancel := context.WithTimeout(t.Context(), time.Second) - defer cancel() - ds, err := subSess.AcceptDataStream(ctx) - if err != nil { - if ctx.Err() != nil { - return - } - t.Fatalf("AcceptDataStream: %v", err) - } - sg, ok := ds.(*session.IncomingSubgroupStream) - if !ok { - t.Fatalf("got %T, want *session.IncomingSubgroupStream", ds) - } - // The complete first object may or may not arrive either: the relay can - // reset the downstream stream before its writer drains. What must never - // happen is a clean end. - for range 2 { - if _, err := sg.ReadObject(); err != nil { - if errors.Is(err, io.EOF) { - t.Fatalf("ReadObject = %v; the torn stream was forwarded as a clean end", err) - } - return - } - } - t.Fatal("read two objects from a stream whose second object was torn") -} - -// TestRelay_ObjectIDOverflowClosesSession: the relay reconstructs absolute -// Object IDs itself, so it owes §11.4.2 its own check — "If the resulting -// Object ID would be greater than 2^64 - 1, the endpoint MUST close the -// session with a PROTOCOL_VIOLATION" — rather than caching the wrapped ID 0. -func TestRelay_ObjectIDOverflowClosesSession(t *testing.T) { - t.Parallel() - pubSess, alias := publishWithTrackProps(t, nil) - _ = subscribeCam1(t, pubSess) - - go func() { - sg, err := pubSess.OpenSubgroup(message.SubgroupHeader{ - SubgroupIDMode: message.SubgroupIDExplicit, - TrackAlias: alias, - GroupID: 3, - }) - if err != nil { - return - } - _ = sg.WriteObject(&message.SubgroupObject{ObjectIDDelta: math.MaxUint64, Payload: []byte("a")}) - _ = sg.WriteObject(&message.SubgroupObject{ObjectIDDelta: 0, Payload: []byte("b")}) - _ = sg.Close() - }() - - select { - case <-pubSess.Done(): - case <-time.After(2 * time.Second): - t.Fatal("relay left the publisher's session open after an Object ID overflow") - } -} - -// TestRelay_NonFirstRequestOpenerClosesSession: a request stream opened with -// a message not marked "First" in draft-20 Table 5 is a PROTOCOL_VIOLATION. -// PUBLISH_STATE_NOTIFY is the case the relay used to let through: §10.10 says -// an endpoint receiving one "from the subscriber, MUST close the session with -// a PROTOCOL_VIOLATION", yet only a stray REQUEST_UPDATE closed it. -func TestRelay_NonFirstRequestOpenerClosesSession(t *testing.T) { - t.Parallel() - l := newPipeListener() - _, teardown := connectRelayOn(t, relay.Config{}, l) - defer teardown() - - peer, conn := dialRaw(t, l) - stream, err := conn.OpenStream() - if err != nil { - t.Fatalf("OpenStream: %v", err) - } - go func() { _ = message.Marshal(stream, &message.PublishStateNotify{}) }() - - select { - case <-peer.Done(): - case <-time.After(2 * time.Second): - t.Fatal("relay left the session open after a PUBLISH_STATE_NOTIFY opened a request stream") - } -} diff --git a/pkg/relay/default_priority_test.go b/pkg/relay/default_priority_test.go index f0c1f566..32a1c644 100644 --- a/pkg/relay/default_priority_test.go +++ b/pkg/relay/default_priority_test.go @@ -12,136 +12,60 @@ import ( "github.com/floatdrop/moq-go/pkg/relay" ) -// A subgroup or datagram with the DEFAULT_PRIORITY bit set carries no Priority -// byte; it "inherits the Publisher Priority specified in the control message -// that established the subscription" (§11.4.2, §11.3.1) — the -// DEFAULT_PUBLISHER_PRIORITY Track Property, or 128 when that is omitted -// (§12.4). These tests pin that the relay resolves the inherited value rather -// than treating the absent byte as priority 0, on every path that reads it: the -// cache (observed through FETCH, which must spell the priority out) and the +// A DEFAULT_PRIORITY subgroup or datagram inherits the Publisher Priority of +// the message that established the subscription (§11.4.2, §11.3.1): the +// DEFAULT_PUBLISHER_PRIORITY Track Property, or 128 when omitted (§12.4). +// Checked through the cache (FETCH spells the priority out) and the // PRIORITY_FILTER forward decision. -// publishWithTrackProps PUBLISHes video/cam1 with the given Track Properties -// and returns the publisher session and its Track Alias. -func publishWithTrackProps(t *testing.T, props []wire.KVPair) (*session.Session, uint64) { +// fetchedPriority FETCHes {group, 0} of video/cam1 from a new client of via's +// relay and returns its Publisher Priority. +func fetchedPriority(t *testing.T, via *session.Session, group uint64) uint8 { t.Helper() - pubSess, teardown := connectRelay(t, relay.Config{}) - t.Cleanup(teardown) - - const alias = uint64(7) - pubReq, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("video")}, - Name: []byte("cam1"), - TrackAlias: alias, - TrackProperties: message.AppendTrackProperties(props), - }) - if err != nil { - t.Fatalf("Publish: %v", err) - } - t.Cleanup(func() { pubReq.Close() }) - return pubSess, alias -} - -// subscribeCam1 subscribes a fresh client to video/cam1 with extra parameters. -func subscribeCam1(t *testing.T, pubSess *session.Session, params ...message.Parameter) *session.Session { - t.Helper() - subSess := dialAnotherClient(t, pubSess) - subReq, err := subSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, - Name: []byte("cam1"), - Parameters: params, - }) - if err != nil { - t.Fatalf("Subscribe: %v", err) - } - t.Cleanup(func() { subReq.Close() }) - return subSess -} - -// awaitSubgroupObject waits for the relay to forward one subgroup object to -// subSess, reporting whether it arrived before the deadline. -func awaitSubgroupObject(t *testing.T, subSess *session.Session, within time.Duration) bool { - t.Helper() - got := make(chan bool, 1) - go func() { - ds, err := subSess.AcceptDataStream(t.Context()) - if err != nil { - got <- false - return - } - sg, ok := ds.(*session.IncomingSubgroupStream) - if !ok { - got <- false - return - } - _, err = sg.ReadObject() - got <- err == nil - }() - select { - case ok := <-got: - return ok - case <-time.After(within): - return false - } -} - -// publishSubgroupObject writes a one-object subgroup at {group, 0}. inline < 0 -// leaves the DEFAULT_PRIORITY bit set; otherwise the header carries inline. -func publishSubgroupObject(t *testing.T, pubSess *session.Session, alias, group uint64, inline int) { - t.Helper() - hdr := message.SubgroupHeader{ - SubgroupIDMode: message.SubgroupIDExplicit, - TrackAlias: alias, - GroupID: group, - } - if inline >= 0 { - hdr.InlinePriority = true - hdr.PublisherPriority = uint8(inline) - } - sg, err := pubSess.OpenSubgroup(hdr) - if err != nil { - t.Fatalf("OpenSubgroup: %v", err) - } - if err := sg.WriteObject(&message.SubgroupObject{Payload: []byte("x")}); err != nil { - t.Fatalf("WriteObject: %v", err) - } - if err := sg.Close(); err != nil { - t.Fatalf("sg.Close: %v", err) + p, ok := tryFetchedPriority(t, dialAnotherClient(t, via), ns("video"), []byte("cam1"), group) + if !ok { + t.Fatalf("FETCH of {%d,0} was not served", group) } + return p } -// fetchedPriority FETCHes the single object at {group, 0} from the relay's -// cache and returns the Publisher Priority the FETCH response carries. -func fetchedPriority(t *testing.T, pubSess *session.Session, group uint64) uint8 { +// tryFetchedPriority FETCHes {group, 0} and returns the first element's +// Publisher Priority; ok is false while it is not yet servable. +func tryFetchedPriority( + t *testing.T, + sess *session.Session, + ns wire.TrackNamespace, + name []byte, + group uint64, +) (uint8, bool) { t.Helper() - fetchSess := dialAnotherClient(t, pubSess) loc := message.Location{Group: group} - reqStream, err := fetchSess.Fetch(t.Context(), &message.Fetch{ - Namespace: wire.TrackNamespace{[]byte("video")}, - Name: []byte("cam1"), + req, err := sess.Fetch(t.Context(), &message.Fetch{ + Namespace: ns, + Name: name, Parameters: message.Parameters{fetchRangeFilter(loc, loc)}, }) if err != nil { - t.Fatalf("Fetch: %v", err) + return 0, false } - t.Cleanup(func() { reqStream.Close() }) - ds, err := fetchSess.AcceptDataStream(t.Context()) + defer req.Close() + ds, err := sess.AcceptDataStream(t.Context()) if err != nil { - t.Fatalf("AcceptDataStream: %v", err) + return 0, false } fs, ok := ds.(*session.IncomingFetchStream) if !ok { - t.Fatalf("got %T, want *IncomingFetchStream", ds) + return 0, false } obj, err := fs.ReadDecoded() if err != nil { - t.Fatalf("ReadDecoded: %v", err) + return 0, false } - return obj.PublisherPriority + return obj.PublisherPriority, true } func defaultPriorityProp(v uint64) []wire.KVPair { - return []wire.KVPair{{Type: message.PropertyDefaultPublisherPriority, IntVal: v}} + return trackProp(message.PropertyDefaultPublisherPriority, v) } func TestDefaultPriority_Subgroup(t *testing.T) { @@ -149,7 +73,7 @@ func TestDefaultPriority_Subgroup(t *testing.T) { cases := []struct { name string props []wire.KVPair - inline int + inline int // < 0 leaves the DEFAULT_PRIORITY bit set want uint8 }{ {"no property inherits 128", nil, -1, 128}, @@ -159,11 +83,16 @@ func TestDefaultPriority_Subgroup(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() - pubSess, alias := publishWithTrackProps(t, tc.props) - subSess := subscribeCam1(t, pubSess) - publishSubgroupObject(t, pubSess, alias, 3, tc.inline) - // The forwarded copy is the sync point: the relay caches - // before it forwards. + pubSess, alias := newCam1Publisher(t, tc.props) + subSess := newCam1Subscriber(t, pubSess) + hdr := subgroupHeader(alias, 3) + if tc.inline >= 0 { + hdr.InlinePriority, hdr.PublisherPriority = true, uint8(tc.inline) + } + if err := writeSubgroup(pubSess, hdr, 1); err != nil { + t.Fatal(err) + } + // The relay caches before it forwards. if !awaitSubgroupObject(t, subSess, 2*time.Second) { t.Fatal("relay did not forward the object") } @@ -176,8 +105,8 @@ func TestDefaultPriority_Subgroup(t *testing.T) { func TestDefaultPriority_Datagram(t *testing.T) { t.Parallel() - pubSess, alias := publishWithTrackProps(t, defaultPriorityProp(200)) - subSess := subscribeCam1(t, pubSess) + pubSess, alias := newCam1Publisher(t, defaultPriorityProp(200)) + subSess := newCam1Subscriber(t, pubSess) got := make(chan error, 1) go func() { @@ -206,32 +135,27 @@ func TestDefaultPriority_Datagram(t *testing.T) { } } -// TestDefaultPriority_PriorityFilter covers the live forward path: a -// PRIORITY_FILTER admitting [100, 255] must pass a default-priority subgroup -// (inherited 128), which a priority-0 reading would drop. +// TestDefaultPriority_PriorityFilter: PRIORITY_FILTER [100, 255] passes a +// subgroup inheriting 128. func TestDefaultPriority_PriorityFilter(t *testing.T) { t.Parallel() - pubSess, alias := publishWithTrackProps(t, nil) - subSess := subscribeCam1(t, pubSess, message.RangeFilterParam(&message.RangeFilter{ + pubSess, alias := newCam1Publisher(t, nil) + subSess := newCam1Subscriber(t, pubSess, message.RangeFilterParam(&message.RangeFilter{ Type: message.ParamPriorityFilter, Ranges: []message.Range{{Start: 100, End: 255}}, })) - publishSubgroupObject(t, pubSess, alias, 3, -1) + publishObjects(t, pubSess, alias, 3, 1) if !awaitSubgroupObject(t, subSess, 2*time.Second) { t.Fatal("PRIORITY_FILTER [100,255] dropped a subgroup inheriting priority 128") } } -// TestDefaultPriority_UpstreamSubscribeAliasWindow covers the relay-initiated -// SUBSCRIBE path. The SUBSCRIBE_OK's Track Alias resolves on inbound data -// streams as soon as session.Subscribe returns, before the relay has recorded -// that SUBSCRIBE_OK's Track Properties on its track entry (see -// TestSubscribeUpstream_TrackEntryPrecedesAliasRouting). A default-priority -// subgroup arriving in that window must still inherit the SUBSCRIBE_OK's -// DEFAULT_PUBLISHER_PRIORITY, not the omitted-property 128. Not parallel: it -// installs the process-wide alias-window hook. +// TestDefaultPriority_UpstreamSubscribeAliasWindow: a subgroup arriving on a +// relay-initiated SUBSCRIBE's alias before the relay recorded the +// SUBSCRIBE_OK's Track Properties still inherits its DEFAULT_PUBLISHER_PRIORITY. +// Not parallel: it installs the process-wide alias-window hook. func TestDefaultPriority_UpstreamSubscribeAliasWindow(t *testing.T) { - ns := wire.TrackNamespace{[]byte("video")} + video := ns("video") name := []byte("cam-priority-alias-window") restore := relay.SetTestHookAfterAliasRegistered(func(n track.FullTrackName) { @@ -244,7 +168,7 @@ func TestDefaultPriority_UpstreamSubscribeAliasWindow(t *testing.T) { upSess, teardown := connectRelay(t, relay.Config{}) t.Cleanup(teardown) - if _, err := upSess.PublishNamespace(t.Context(), &message.PublishNamespace{Namespace: ns}); err != nil { + if _, err := upSess.PublishNamespace(t.Context(), &message.PublishNamespace{Namespace: video}); err != nil { t.Fatalf("PublishNamespace: %v", err) } go func() { @@ -264,22 +188,12 @@ func TestDefaultPriority_UpstreamSubscribeAliasWindow(t *testing.T) { }); err != nil { return } - // Not publishSubgroupObject: t.Fatal is off-limits here. - sg, err := upSess.OpenSubgroup(message.SubgroupHeader{ - SubgroupIDMode: message.SubgroupIDExplicit, - TrackAlias: alias, - GroupID: 3, - }) - if err != nil { - return - } - _ = sg.WriteObject(&message.SubgroupObject{Payload: []byte("x")}) - _ = sg.Close() + sendObjects(upSess, alias, 3, 1) } }() live := dialAnotherClient(t, upSess) - liveReq, err := live.Subscribe(t.Context(), &message.Subscribe{Namespace: ns, Name: name}) + liveReq, err := live.Subscribe(t.Context(), &message.Subscribe{Namespace: video, Name: name}) if err != nil { t.Fatalf("live Subscribe: %v", err) } @@ -289,7 +203,7 @@ func TestDefaultPriority_UpstreamSubscribeAliasWindow(t *testing.T) { fetchSess := dialAnotherClient(t, upSess) var got uint8 waitFor(t, 5*time.Second, func() bool { - p, ok := tryFetchedPriority(t, fetchSess, ns, name, 3) + p, ok := tryFetchedPriority(t, fetchSess, video, name, 3) got = p return ok }, "relay never cached the subgroup published in the alias window") @@ -298,68 +212,22 @@ func TestDefaultPriority_UpstreamSubscribeAliasWindow(t *testing.T) { } } -// tryFetchedPriority is a non-fatal [fetchedPriority] for polling: ok is false -// while the object at {group, 0} is not yet servable. -func tryFetchedPriority( - t *testing.T, - sess *session.Session, - ns wire.TrackNamespace, - name []byte, - group uint64, -) (uint8, bool) { - t.Helper() - loc := message.Location{Group: group} - req, err := sess.Fetch(t.Context(), &message.Fetch{ - Namespace: ns, - Name: name, - Parameters: message.Parameters{fetchRangeFilter(loc, loc)}, - }) - if err != nil { - return 0, false - } - defer req.Close() - ds, err := sess.AcceptDataStream(t.Context()) - if err != nil { - return 0, false - } - fs, ok := ds.(*session.IncomingFetchStream) - if !ok { - return 0, false - } - obj, err := fs.ReadDecoded() - if err != nil { - return 0, false - } - return obj.PublisherPriority, true -} - -// TestDefaultPriority_PerPublisher: two publishers of one track, each with its -// own DEFAULT_PUBLISHER_PRIORITY. Each DEFAULT_PRIORITY subgroup inherits from -// the PUBLISH that bound its own alias (§11.4.2), not from whichever publisher -// happened to set the relay's shared track Properties first. +// TestDefaultPriority_PerPublisher: with two publishers of one track, each +// subgroup inherits from the PUBLISH that bound its own alias (§11.4.2). func TestDefaultPriority_PerPublisher(t *testing.T) { t.Parallel() - pubA, aliasA := publishWithTrackProps(t, defaultPriorityProp(200)) - subSess := subscribeCam1(t, pubA) + pubA, aliasA := newCam1Publisher(t, defaultPriorityProp(200)) + subSess := newCam1Subscriber(t, pubA) pubB := dialAnotherClient(t, pubA) const aliasB = uint64(9) - pubReqB, err := pubB.Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("video")}, - Name: []byte("cam1"), - TrackAlias: aliasB, - TrackProperties: message.AppendTrackProperties(defaultPriorityProp(10)), - }) - if err != nil { - t.Fatalf("Publish B: %v", err) - } - t.Cleanup(func() { pubReqB.Close() }) + publishVideoTrackProps(t, pubB, "cam1", aliasB, defaultPriorityProp(10)) - publishSubgroupObject(t, pubA, aliasA, 3, -1) + publishObjects(t, pubA, aliasA, 3, 1) if !awaitSubgroupObject(t, subSess, 2*time.Second) { t.Fatal("relay did not forward publisher A's object") } - publishSubgroupObject(t, pubB, aliasB, 4, -1) + publishObjects(t, pubB, aliasB, 4, 1) if !awaitSubgroupObject(t, subSess, 2*time.Second) { t.Fatal("relay did not forward publisher B's object") } diff --git a/pkg/relay/discovery_integration_test.go b/pkg/relay/discovery_integration_test.go index e1bd3bfa..0f6a8994 100644 --- a/pkg/relay/discovery_integration_test.go +++ b/pkg/relay/discovery_integration_test.go @@ -8,7 +8,6 @@ import ( "time" "github.com/floatdrop/moq-go/pkg/moqt/message" - "github.com/floatdrop/moq-go/pkg/moqt/wire" "github.com/floatdrop/moq-go/pkg/relay" "github.com/floatdrop/moq-go/pkg/relay/discovery" ) @@ -34,7 +33,7 @@ func TestDiscovery_PublishOnFirstUpstream(t *testing.T) { defer teardown() pubStream, err := clientSess.Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), TrackAlias: 1, TrackProperties: []byte("rtp-h265"), @@ -84,7 +83,7 @@ func TestDiscovery_UnpublishOnLastUpstream(t *testing.T) { defer teardown() pubStream, err := clientSess.Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), TrackAlias: 1, }) @@ -136,7 +135,7 @@ func TestDiscovery_PublishNamespaceOnFirstAdvertise(t *testing.T) { pubSess2 := dialAnotherClient(t, pubSess1) pns1, err := pubSess1.PublishNamespace(t.Context(), &message.PublishNamespace{ - Namespace: wire.TrackNamespace{[]byte("chat")}, + Namespace: ns("chat"), }) if err != nil { t.Fatalf("PublishNamespace #1: %v", err) @@ -155,7 +154,7 @@ func TestDiscovery_PublishNamespaceOnFirstAdvertise(t *testing.T) { // Second publish from a different session: SAME namespace, SAME relay. // Discovery already has the entry — no new event. pns2, err := pubSess2.PublishNamespace(t.Context(), &message.PublishNamespace{ - Namespace: wire.TrackNamespace{[]byte("chat")}, + Namespace: ns("chat"), }) if err != nil { t.Fatalf("PublishNamespace #2: %v", err) @@ -192,13 +191,13 @@ func TestDiscovery_UnpublishNamespaceOnLastWithdraw(t *testing.T) { pubSess2 := dialAnotherClient(t, pubSess1) pns1, err := pubSess1.PublishNamespace(t.Context(), &message.PublishNamespace{ - Namespace: wire.TrackNamespace{[]byte("chat")}, + Namespace: ns("chat"), }) if err != nil { t.Fatalf("PublishNamespace #1: %v", err) } pns2, err := pubSess2.PublishNamespace(t.Context(), &message.PublishNamespace{ - Namespace: wire.TrackNamespace{[]byte("chat")}, + Namespace: ns("chat"), }) if err != nil { t.Fatalf("PublishNamespace #2: %v", err) @@ -239,7 +238,7 @@ func TestDiscovery_NilDiscoveryIsNoop(t *testing.T) { defer teardown() pubStream, err := clientSess.Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), TrackAlias: 1, }) diff --git a/pkg/relay/early_data_stream_test.go b/pkg/relay/early_data_stream_test.go deleted file mode 100644 index 1252d9b1..00000000 --- a/pkg/relay/early_data_stream_test.go +++ /dev/null @@ -1,180 +0,0 @@ -package relay_test - -import ( - "slices" - "testing" - "time" - - "github.com/floatdrop/moq-go/pkg/moqt/message" - "github.com/floatdrop/moq-go/pkg/moqt/session" - "github.com/floatdrop/moq-go/pkg/moqt/wire" - "github.com/floatdrop/moq-go/pkg/relay" -) - -// TestRelay_SubgroupBeforeSubscribeOKIsDelivered: a publisher may open a -// track's subgroup streams as soon as it accepts the SUBSCRIBE, and they can -// reach the relay before the SUBSCRIBE_OK that names their Track Alias. §11.4.2 -// lets the receiver "buffer it for a brief period to handle reordering with the -// control message that establishes the Track Alias"; abandoning it loses the -// first Groups of the track. The SUBSCRIBE_OK here is sent only once the relay -// is holding the stream (or, for a relay that abandons it, after a second). -// -// Not parallel: the hook is process-wide, and no parallel test runs alongside -// a serial one. -func TestRelay_SubgroupBeforeSubscribeOKIsDelivered(t *testing.T) { - const alias = uint64(4242) // unique to this test - waiting := make(chan struct{}, 1) - defer relay.SetTestHookEarlyStreamWaiting(func(a uint64) { - if a == alias { - select { - case waiting <- struct{}{}: - default: - } - } - })() - - pubSess, teardown := connectRelay(t, relay.Config{}) - t.Cleanup(teardown) - ns := wire.TrackNamespace{[]byte("video")} - if _, err := pubSess.PublishNamespace(t.Context(), &message.PublishNamespace{Namespace: ns}); err != nil { - t.Fatalf("PublishNamespace: %v", err) - } - - go func() { - req, err := pubSess.AcceptRequest(t.Context()) - if err != nil { - return - } - if _, ok := req.First.(*message.Subscribe); !ok { - return - } - // Errors are ignored so the reply always goes out. - if sg, err := pubSess.OpenSubgroup(message.SubgroupHeader{ - SubgroupIDMode: message.SubgroupIDExplicit, - TrackAlias: alias, - }); err == nil { - go func() { - _ = sg.WriteObject(&message.SubgroupObject{Payload: []byte("x")}) - _ = sg.Close() - }() - } - select { - case <-waiting: - case <-time.After(time.Second): - } - _ = req.Reply(&message.SubscribeOK{TrackAlias: alias}) - }() - - subSess := dialAnotherClient(t, pubSess) - if _, err := subSess.Subscribe(t.Context(), &message.Subscribe{Namespace: ns, Name: []byte("cam1")}); err != nil { - t.Fatalf("Subscribe: %v", err) - } - // The object reaches the relay's cache. Live delivery to this subscriber - // is not asserted: the object may be forwarded before the subscriber's - // downstream is registered, and a subscription starts after the Largest - // Object at that point. - if !fetchesObject(t, dialAnotherClient(t, pubSess), ns, []byte("cam1"), 2*time.Second) { - t.Fatal("the subgroup that arrived before its SUBSCRIBE_OK never reached the relay's cache: " + - "the relay abandoned it as an unknown Track Alias") - } -} - -// fetchesObject FETCHes the track from the relay until the response carries -// the Object the test published (payload "x"), or within runs out. -func fetchesObject( - t *testing.T, - sess *session.Session, - ns wire.TrackNamespace, - name []byte, - within time.Duration, -) bool { - t.Helper() - deadline := time.Now().Add(within) - for time.Now().Before(deadline) { - fr, err := sess.Fetch(t.Context(), &message.Fetch{Namespace: ns, Name: name}) - if err == nil { - ds, err := sess.AcceptDataStream(t.Context()) - if err != nil { - t.Fatalf("AcceptDataStream: %v", err) - } - fs, ok := ds.(*session.IncomingFetchStream) - if !ok { - t.Fatalf("AcceptDataStream = %T, want a FETCH stream", ds) - } - objs := decodeFetchStream(t, fs, message.GroupOrderAscending) - _ = fr.Close() - // Only the published Object counts, not an End of Range marker. - if slices.ContainsFunc(objs, func(o decodedFetchObject) bool { return string(o.payload) == "x" }) { - return true - } - } - time.Sleep(20 * time.Millisecond) - } - return false -} - -// TestRelay_EarlySubgroupWaitIsBounded pins both limits on holding a subgroup -// stream for an unknown Track Alias: at most maxEarlyStreams wait per session, -// and none waits past earlyAliasWait. Without them a peer could pin the relay's -// flow control with aliases it never binds. -func TestRelay_EarlySubgroupWaitIsBounded(t *testing.T) { - t.Parallel() - pubSess, teardown := connectRelay(t, relay.Config{}) - t.Cleanup(teardown) - - const ( - bogusAlias = uint64(999) // never bound by a SUBSCRIBE_OK or PUBLISH - waiting = 32 // relay.maxEarlyStreams - ) - // writeFails writes one object on a fresh subgroup stream in the - // background and reports when the write fails: the relay reset the stream. - writeFails := func() <-chan struct{} { - failed := make(chan struct{}) - sg, err := pubSess.OpenSubgroup(message.SubgroupHeader{ - SubgroupIDMode: message.SubgroupIDExplicit, - TrackAlias: bogusAlias, - }) - if err != nil { - t.Fatalf("OpenSubgroup: %v", err) - } - go func() { - if err := sg.WriteObject(&message.SubgroupObject{Payload: []byte("x")}); err != nil { - close(failed) - } - }() - return failed - } - - start := time.Now() - held := make([]<-chan struct{}, waiting) - for i := range held { - held[i] = writeFails() - } - time.Sleep(100 * time.Millisecond) // let the relay start waiting on each - - // One more than the relay will hold is refused at once... - select { - case <-writeFails(): - case <-time.After(500 * time.Millisecond): - t.Fatalf("stream %d was held too; the relay must reset streams past its limit at once", waiting+1) - } - // ...while every one within the limit is still held. - for i, failed := range held { - select { - case <-failed: - t.Fatalf("held stream %d was reset before the wait ran out; the relay holds %d", i, waiting) - default: - } - } - // The held ones are released after the brief wait, not kept for good. - for i, failed := range held { - select { - case <-failed: - case <-time.After(5 * time.Second): - t.Fatalf("held stream %d was still open %s after it arrived", i, time.Since(start)) - } - } - if d := time.Since(start); d < 500*time.Millisecond { - t.Errorf("held streams were reset after %s; the relay should wait about a second for the alias", d) - } -} diff --git a/pkg/relay/fill_rangefilter_test.go b/pkg/relay/fill_rangefilter_test.go deleted file mode 100644 index 30cb8817..00000000 --- a/pkg/relay/fill_rangefilter_test.go +++ /dev/null @@ -1,79 +0,0 @@ -package relay_test - -import ( - "slices" - "testing" - "time" - - "github.com/floatdrop/moq-go/pkg/moqt/message" - "github.com/floatdrop/moq-go/pkg/moqt/session" - "github.com/floatdrop/moq-go/pkg/moqt/wire" -) - -// TestSubscribe_FillInheritsRangeFilters pins §5.1.3: "The fill fetch stream -// inherits the subscription's parameters, including subscriber priority, -// range filters and authorization; parameters carried inside FILL_PARAMETERS -// override them for the fill fetch stream." A Range Filter inside -// FILL_PARAMETERS overrides the subscription's filter of the same type, as a -// REQUEST_UPDATE would (§5.1.4): non-zero replaces it, zero-length removes it. -func TestSubscribe_FillInheritsRangeFilters(t *testing.T) { - t.Parallel() - objectIDs := func(ranges ...message.Range) message.Parameter { - return message.RangeFilterParam(&message.RangeFilter{Type: message.ParamObjectIDFilter, Ranges: ranges}) - } - for _, tc := range []struct { - name string - inner message.Parameters // besides the whole-track Location filter - want []decodedFetchObject - }{ - {"inherited", nil, []decodedFetchObject{{group: 0, object: 1}}}, - {"overridden", message.Parameters{objectIDs(message.Range{Start: 2, End: 2})}, - []decodedFetchObject{{group: 0, object: 2}}}, - {"removed", message.Parameters{message.BytesParam(message.ParamObjectIDFilter, nil)}, - []decodedFetchObject{{group: 0, object: 0}, {group: 0, object: 1}, {group: 0, object: 2}, {group: 1, object: 0}}}, - } { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - pubSess, _, alias := publishAndCache(t) - publishObjects(t, pubSess, alias, 0, 3) - publishObjects(t, pubSess, alias, 1, 1) - time.Sleep(50 * time.Millisecond) - - subSess := dialAnotherClient(t, pubSess) - inner := append(message.Parameters{message.UnfilteredFilter()}, tc.inner...) - sub, err := subSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, - Name: []byte("cam1"), - Parameters: message.Parameters{ - message.NextObjectFilter(), - objectIDs(message.Range{Start: 1, End: 1}), - message.FillParametersParam(inner), - }, - }) - if err != nil { - t.Fatalf("Subscribe: %v", err) - } - t.Cleanup(func() { sub.Close() }) - - ds, err := subSess.AcceptDataStream(t.Context()) - if err != nil { - t.Fatalf("AcceptDataStream: %v", err) - } - fs, ok := ds.(*session.IncomingFetchStream) - if !ok { - t.Fatalf("got %T, want the fill stream", ds) - } - got := decodeFetchStream(t, fs, message.GroupOrderAscending) - ids := func(objs []decodedFetchObject) [][2]uint64 { - var out [][2]uint64 - for _, o := range objs { - out = append(out, [2]uint64{o.group, o.object}) - } - return out - } - if !slices.Equal(ids(got), ids(tc.want)) { - t.Fatalf("fill delivered %v, want %v", ids(got), ids(tc.want)) - } - }) - } -} diff --git a/pkg/relay/followup_role_test.go b/pkg/relay/followup_role_test.go deleted file mode 100644 index 2ae2430f..00000000 --- a/pkg/relay/followup_role_test.go +++ /dev/null @@ -1,98 +0,0 @@ -package relay_test - -import ( - "errors" - "testing" - "time" - - "github.com/floatdrop/moq-go/pkg/moqt" - "github.com/floatdrop/moq-go/pkg/moqt/message" - "github.com/floatdrop/moq-go/pkg/moqt/session" - "github.com/floatdrop/moq-go/pkg/moqt/wire" - "github.com/floatdrop/moq-go/pkg/relay" -) - -// §10.9 / §10.10 at the relay: who may send REQUEST_UPDATE and -// PUBLISH_STATE_NOTIFY on a request stream it serves or opened. - -func requireSessionClosed(t *testing.T, sess *session.Session, what string) { - t.Helper() - select { - case <-sess.Done(): - case <-time.After(2 * time.Second): - t.Fatalf("relay left the session open after %s", what) - } -} - -// TestRelay_SubscriberPublishStateNotifyClosesSession: PUBLISH_STATE_NOTIFY -// "is sent only by the publisher"; one from the subscriber is a -// PROTOCOL_VIOLATION (§10.10). -func TestRelay_SubscriberPublishStateNotifyClosesSession(t *testing.T) { - t.Parallel() - pubSess, _ := publishWithTrackProps(t, nil) - subSess := dialAnotherClient(t, pubSess) - subReq, err := subSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, - Name: []byte("cam1"), - }) - if err != nil { - t.Fatalf("Subscribe: %v", err) - } - go func() { _ = message.Marshal(subReq.Stream, &message.PublishStateNotify{}) }() - requireSessionClosed(t, subSess, "a subscriber's PUBLISH_STATE_NOTIFY") -} - -// TestRelay_UpstreamRequestUpdateOnSubscribeClosesSession: on the relay's own -// upstream SUBSCRIBE the publisher is not the request's sender, so its -// REQUEST_UPDATE is a PROTOCOL_VIOLATION (§10.9). -func TestRelay_UpstreamRequestUpdateOnSubscribeClosesSession(t *testing.T) { - t.Parallel() - ns := wire.TrackNamespace{[]byte("video")} - upSess, teardown := connectRelay(t, relay.Config{}) - defer teardown() - if _, err := upSess.PublishNamespace(t.Context(), &message.PublishNamespace{Namespace: ns}); err != nil { - t.Fatalf("PublishNamespace: %v", err) - } - go func() { - r, err := upSess.AcceptRequest(t.Context()) - if err != nil { - return - } - if _, err := r.AcceptSubscribe(nil); err != nil { - return - } - _ = message.Marshal(r.Stream, &message.RequestUpdate{RequestID: upSess.AllocRequestID()}) - }() - live := dialAnotherClient(t, upSess) - go func() { - _, _ = live.Subscribe(t.Context(), &message.Subscribe{Namespace: ns, Name: []byte("cam1")}) - }() - requireSessionClosed(t, upSess, "a REQUEST_UPDATE on the relay's own SUBSCRIBE") -} - -// TestRelay_PublisherRequestUpdateOnPublishIsAllowed: on an accepted PUBLISH -// the publisher is the request's sender and may send REQUEST_UPDATE (§10.9). -// The relay declines it, but must not treat it as a violation. -func TestRelay_PublisherRequestUpdateOnPublishIsAllowed(t *testing.T) { - t.Parallel() - pubSess, _ := publishWithTrackProps(t, nil) - // publishWithTrackProps keeps the Publication's stream to itself; send the - // update on a second PUBLISH so the test holds the stream. - pub, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("video")}, - Name: []byte("cam2"), - }) - if err != nil { - t.Fatalf("Publish: %v", err) - } - t.Cleanup(func() { _ = pub.Close() }) - _, err = pub.Update(t.Context(), message.Parameters{message.ForwardParam(true)}) - if rej, ok := errors.AsType[*session.RequestRejectedError](err); !ok || rej.Code != moqt.RequestNotSupported { - t.Fatalf("Update on an accepted PUBLISH = %v, want REQUEST_ERROR NOT_SUPPORTED", err) - } - select { - case <-pubSess.Done(): - t.Fatalf("relay closed the session on a legal REQUEST_UPDATE: %v", pubSess.Err()) - case <-time.After(200 * time.Millisecond): - } -} diff --git a/pkg/relay/forward_omission_test.go b/pkg/relay/forward_state_test.go similarity index 69% rename from pkg/relay/forward_omission_test.go rename to pkg/relay/forward_state_test.go index 518ca605..d5d8bd5d 100644 --- a/pkg/relay/forward_omission_test.go +++ b/pkg/relay/forward_state_test.go @@ -13,18 +13,100 @@ import ( "github.com/floatdrop/moq-go/pkg/relay" ) -// TestRelay_ForwardStateOmissionResetsStream pins §11.4.3: a sender that -// closes a subgroup stream "before delivering all such objects [...] MUST -// reset the stream", including when "Omitting a Subgroup Object due to the -// subscriber's Forward State". An Object dropped while the subscription is -// paused (FORWARD=0) means the stream must not end with a FIN. +// Forward State: a FORWARD=0 PUBLISH is resumed for Forward=1 subscribers +// (§9.5, §9.2), and a subgroup stream that omitted Objects ends with a reset, +// not a FIN (§11.4.3). + +// pausedPublish PUBLISHes video/cam1 with FORWARD=0 from a new session and +// delivers each FORWARD value the relay sends back in REQUEST_UPDATE. +func pausedPublish(t *testing.T, via *session.Session) <-chan bool { + t.Helper() + pubSess := dialAnotherClient(t, via) + pub, err := pubSess.Publish(t.Context(), &message.Publish{ + Namespace: ns("video"), + Name: []byte("cam1"), + Parameters: message.Parameters{message.ForwardParam(false)}, + }) + if err != nil { + t.Fatalf("Publish FORWARD=0: %v", err) + } + t.Cleanup(func() { _ = pub.Close() }) + forwards := make(chan bool, 4) + b := pub.Broker() + go func() { + _ = b.Serve(t.Context(), func(m message.Message) bool { + if upd, ok := m.(*message.RequestUpdate); ok { + if f, found := upd.Parameters.Find(message.ParamForward); found { + forwards <- f.Byte == 1 + } + } + return true + }) + }() + return forwards +} + +// requireForwardOn fails unless the next REQUEST_UPDATE sets FORWARD=1 within 2s. +func requireForwardOn(t *testing.T, forwards <-chan bool, when string) { + t.Helper() + select { + case on := <-forwards: + if !on { + t.Fatalf("relay sent FORWARD=0 %s, want FORWARD=1", when) + } + case <-time.After(2 * time.Second): + t.Fatalf("relay never sent REQUEST_UPDATE FORWARD=1 %s", when) + } +} + +// TestRelay_PausedPublishResumedForExistingSubscriber: a FORWARD=0 PUBLISH of a +// track with a Forward=1 subscriber is resumed (§9.5). +func TestRelay_PausedPublishResumedForExistingSubscriber(t *testing.T) { + t.Parallel() + pubSess, _ := newCam1Publisher(t, nil) // establishes the track + _ = newCam1Subscriber(t, pubSess) // Forward State 1 + forwards := pausedPublish(t, pubSess) + requireForwardOn(t, forwards, "for a PUBLISH with an existing Forward=1 subscriber") +} + +// TestRelay_PausedPublishResumedForLaterSubscriber: a FORWARD=0 PUBLISH is +// resumed when a Forward=1 subscriber arrives (§9.2). +func TestRelay_PausedPublishResumedForLaterSubscriber(t *testing.T) { + t.Parallel() + anchor, teardown := connectRelay(t, relay.Config{}) + defer teardown() + forwards := pausedPublish(t, anchor) + _ = newCam1Subscriber(t, anchor) + requireForwardOn(t, forwards, "when a Forward=1 subscriber joined") +} + +// TestRelay_PublishInvalidForwardClosesSession: FORWARD other than 0 or 1 on +// PUBLISH closes the session (§10.2.18). +func TestRelay_PublishInvalidForwardClosesSession(t *testing.T) { + t.Parallel() + anchor, teardown := connectRelay(t, relay.Config{}) + defer teardown() + pubSess := dialAnotherClient(t, anchor) + go func() { + _, _ = pubSess.Publish(t.Context(), &message.Publish{ + Namespace: ns("video"), + Name: []byte("cam1"), + Parameters: message.Parameters{message.ByteParam(message.ParamForward, 2)}, + }) + }() + requireSessionClosed(t, pubSess, "a PUBLISH with FORWARD=2") +} + +// TestRelay_ForwardStateOmissionResetsStream: an Object omitted while the +// subscription is paused (FORWARD=0) makes the stream end with a reset +// (§11.4.3). func TestRelay_ForwardStateOmissionResetsStream(t *testing.T) { t.Parallel() pubSess, teardown := connectRelay(t, relay.Config{}) defer teardown() pub := publishVideoTrack(t, pubSess, "cam1", 1) subSess := dialAnotherClient(t, pubSess) - subReq := subscribeCam1Req(t, subSess) + subReq := subscribeCam1(t, subSess) sg, err := pub.OpenSubgroup(message.SubgroupHeader{SubgroupIDMode: message.SubgroupIDExplicit}) if err != nil { @@ -113,30 +195,17 @@ func requireReset(t *testing.T, end error, why string) { } } -// TestRelay_SkipBeforeStartKeepsFIN pins the one omission §11.4.3 exempts: -// "except any Objects with Locations smaller than the subscription's Start -// Location". A subscription starting at Object 2 skips Objects 0 and 1 and -// still gets a FIN once the Subgroup ends. +// TestRelay_SkipBeforeStartKeepsFIN: Objects before the Start Location are the +// one omission that keeps the FIN (§11.4.3). func TestRelay_SkipBeforeStartKeepsFIN(t *testing.T) { t.Parallel() pubSess, teardown := connectRelay(t, relay.Config{}) defer teardown() pub := publishVideoTrack(t, pubSess, "cam1", 1) subSess := dialAnotherClient(t, pubSess) - subscribeCam1Req(t, subSess, message.LocationFilterParam(&message.LocationFilter{Fields: 2, StartObject: 2})) + subscribeCam1(t, subSess, message.LocationFilterParam(&message.LocationFilter{Fields: 2, StartObject: 2})) - sg, err := pub.OpenSubgroup(message.SubgroupHeader{SubgroupIDMode: message.SubgroupIDExplicit}) - if err != nil { - t.Fatalf("OpenSubgroup: %v", err) - } - go func() { - for range 4 { - if sg.WriteObject(&message.SubgroupObject{Payload: []byte("x")}) != nil { - return - } - } - _ = sg.Close() - }() + publishSubgroupWith(t, pub, 0, 4, nil) ids, end := readUntilEnd(t, subSess) if !errors.Is(end, io.EOF) { t.Fatalf("the subgroup stream ended with %v; want a FIN, as only Objects before the Start Location "+ @@ -148,15 +217,14 @@ func TestRelay_SkipBeforeStartKeepsFIN(t *testing.T) { } // TestRelay_ForwardStateOmissionResetsReopenedStream: after a pause omitted an -// Object, the subscription never holds the whole Subgroup, so the stream the -// relay reopens on resume ends with a reset too. +// Object, the stream reopened on resume ends with a reset too. func TestRelay_ForwardStateOmissionResetsReopenedStream(t *testing.T) { t.Parallel() pubSess, teardown := connectRelay(t, relay.Config{}) defer teardown() pub := publishVideoTrack(t, pubSess, "cam1", 1) subSess := dialAnotherClient(t, pubSess) - subReq := subscribeCam1Req(t, subSess) + subReq := subscribeCam1(t, subSess) sg, err := pub.OpenSubgroup(message.SubgroupHeader{SubgroupIDMode: message.SubgroupIDExplicit}) if err != nil { @@ -219,39 +287,16 @@ func TestRelay_ForwardStateOmissionResetsReopenedStream(t *testing.T) { requireReset(t, end, "Object 1 was omitted while paused") } -// publishSubgroupWith writes objects Objects to group of pub's track on one -// subgroup stream, calling between(i) before Object i, then FINs it. -func publishSubgroupWith(t *testing.T, pub *session.Publication, group uint64, objects int, between func(i int)) { - t.Helper() - sg, err := pub.OpenSubgroup(message.SubgroupHeader{SubgroupIDMode: message.SubgroupIDExplicit, GroupID: group}) - if err != nil { - t.Fatalf("OpenSubgroup: %v", err) - } - go func() { - for i := range objects { - if between != nil { - between(i) - } - if sg.WriteObject(&message.SubgroupObject{Payload: []byte("x")}) != nil { - return - } - } - _ = sg.Close() - }() -} - -// TestRelay_StartRaisedToLaterGroupResetsPromptly: §11.4.3 lists "A -// REQUEST_UPDATE moving [...] the Start Location to a larger Location" among -// the MUST-reset cases. Raised to a later group, the open stream will carry -// nothing more and is reset at its next Object, not held until the Subgroup -// ends. +// TestRelay_StartRaisedToLaterGroupResetsPromptly: a Start raised past the +// stream's group resets it at its next Object, not when the Subgroup ends +// (§11.4.3). func TestRelay_StartRaisedToLaterGroupResetsPromptly(t *testing.T) { t.Parallel() pubSess, teardown := connectRelay(t, relay.Config{}) defer teardown() pub := publishVideoTrack(t, pubSess, "cam1", 1) subSess := dialAnotherClient(t, pubSess) - subReq := subscribeCam1Req(t, subSess) + subReq := subscribeCam1(t, subSess) raised := make(chan struct{}) finish := make(chan struct{}) @@ -284,16 +329,15 @@ func TestRelay_StartRaisedToLaterGroupResetsPromptly(t *testing.T) { } } -// TestRelay_StartRaisedWithinGroupResets: a Start raised past Objects already -// sent, inside the same group, skips the rest as "before the Start", but the -// stream did not deliver the Subgroup and must end with a reset. +// TestRelay_StartRaisedWithinGroupResets: a Start raised within the stream's +// group skips its remaining Objects, so the stream ends with a reset. func TestRelay_StartRaisedWithinGroupResets(t *testing.T) { t.Parallel() pubSess, teardown := connectRelay(t, relay.Config{}) defer teardown() pub := publishVideoTrack(t, pubSess, "cam1", 1) subSess := dialAnotherClient(t, pubSess) - subReq := subscribeCam1Req(t, subSess) + subReq := subscribeCam1(t, subSess) raised := make(chan struct{}) publishSubgroupWith(t, pub, 2, 3, func(i int) { @@ -331,7 +375,7 @@ func TestRelay_EndLocationInsideGroupResets(t *testing.T) { pub := publishVideoTrack(t, pubSess, "cam1", 1) subSess := dialAnotherClient(t, pubSess) // Start {0,0}, End {0,1}: EndGroupDelta 0, EndObject 1. - subscribeCam1Req(t, subSess, message.LocationFilterParam(&message.LocationFilter{Fields: 4, EndObject: 1})) + subscribeCam1(t, subSess, message.LocationFilterParam(&message.LocationFilter{Fields: 4, EndObject: 1})) publishSubgroupWith(t, pub, 0, 4, nil) ids, end := readUntilEnd(t, subSess) @@ -349,7 +393,7 @@ func TestRelay_QueueOverflowResets(t *testing.T) { defer teardown() pub := publishVideoTrack(t, pubSess, "cam1", 1) subSess := dialAnotherClient(t, pubSess) - subscribeCam1Req(t, subSess) + subscribeCam1(t, subSess) // Not reading at first: the relay's writer blocks on Object 0, its // one-slot queue fills, and the rest are dropped. diff --git a/pkg/relay/forwarded_publish_test.go b/pkg/relay/forwarded_publish_test.go deleted file mode 100644 index 67f16419..00000000 --- a/pkg/relay/forwarded_publish_test.go +++ /dev/null @@ -1,404 +0,0 @@ -package relay_test - -import ( - "context" - "testing" - "time" - - "github.com/floatdrop/moq-go/pkg/moqt" - "github.com/floatdrop/moq-go/pkg/moqt/message" - "github.com/floatdrop/moq-go/pkg/moqt/session" - "github.com/floatdrop/moq-go/pkg/relay" -) - -// A PUBLISH the relay sends to a SUBSCRIBE_TRACKS holder (§6.1, §10.20) opens -// a subscription the relay serves like any other: Objects flow on the alias it -// chose (§10.11), a REQUEST_UPDATE is answered (§10.9), REQUEST_ERROR ends it, -// and it ends with PUBLISH_DONE (§10.12). - -// forwardedPublishes accepts the PUBLISHes the relay forwards to sess. -func forwardedPublishes(t *testing.T, sess *session.Session) <-chan *session.Request { - t.Helper() - out := make(chan *session.Request, 4) - go func() { - for { - r, err := sess.AcceptRequest(t.Context()) - if err != nil { - return - } - if _, ok := r.First.(*message.Publish); ok { - out <- r - } - } - }() - return out -} - -func awaitForwarded(t *testing.T, reqs <-chan *session.Request) *session.Request { - t.Helper() - select { - case r := <-reqs: - return r - case <-time.After(2 * time.Second): - t.Fatal("no PUBLISH forwarded to the SUBSCRIBE_TRACKS holder") - } - return nil -} - -func requireNoForward(t *testing.T, reqs <-chan *session.Request, what string) { - t.Helper() - select { - case r := <-reqs: - p := r.First.(*message.Publish) - t.Fatalf("%s: forwarded PUBLISH for %s", what, p.Name) - case <-time.After(300 * time.Millisecond): - } -} - -func subscribeTracks(t *testing.T, sess *session.Session, fields ...string) { - t.Helper() - ts, err := sess.SubscribeTracks(t.Context(), &message.SubscribeTracks{TrackNamespacePrefix: ns(fields...)}) - if err != nil { - t.Fatalf("SubscribeTracks: %v", err) - } - t.Cleanup(func() { _ = ts.Close() }) -} - -// publishVideoTrack PUBLISHes video/ from sess with the given alias. -func publishVideoTrack( - t *testing.T, - sess *session.Session, - name string, - alias uint64, - params ...message.Parameter, -) *session.Publication { - t.Helper() - p, err := sess.Publish(t.Context(), &message.Publish{ - Namespace: ns("video"), Name: []byte(name), TrackAlias: alias, Parameters: params, - }) - if err != nil { - t.Fatalf("Publish %s: %v", name, err) - } - t.Cleanup(func() { _ = p.Close() }) - return p -} - -// acceptForwarded replies PUBLISH_OK, failing — rather than hanging on the -// unbuffered test pipe — if the relay never reads the reply. -func acceptForwarded(t *testing.T, r *session.Request) *session.IncomingPublication { - t.Helper() - type result struct { - in *session.IncomingPublication - err error - } - done := make(chan result, 1) - go func() { - in, err := r.AcceptPublish() - done <- result{in, err} - }() - select { - case res := <-done: - if res.err != nil { - t.Fatalf("AcceptPublish: %v", res.err) - } - return res.in - case <-time.After(2 * time.Second): - t.Fatal("the relay never read the PUBLISH_OK") - } - return nil -} - -// sendObject writes one Object in group on alias, off the test goroutine. -func sendObject(sess *session.Session, alias, group uint64) { - sg, err := sess.OpenSubgroup(message.SubgroupHeader{ - SubgroupIDMode: message.SubgroupIDExplicit, TrackAlias: alias, GroupID: group, - }) - if err != nil { - return - } - _ = sg.WriteObject(&message.SubgroupObject{Payload: []byte("x")}) - _ = sg.Close() -} - -// awaitObjectOn waits for a subgroup stream on alias and reads its first Object. -func awaitObjectOn(t *testing.T, sess *session.Session, alias uint64) { - t.Helper() - ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) - defer cancel() - for { - ds, err := sess.AcceptDataStream(ctx) - if err != nil { - t.Fatalf("no Object delivered on the forwarded PUBLISH's alias %d: %v", alias, err) - } - sg, ok := ds.(*session.IncomingSubgroupStream) - if !ok || sg.Header.TrackAlias != alias { - continue - } - if _, err := sg.ReadObject(); err != nil { - t.Fatalf("ReadObject: %v", err) - } - return - } -} - -func TestForwardedPublish_DeliversObjects(t *testing.T) { - t.Parallel() - subSess, teardown := connectRelay(t, relay.Config{}) - defer teardown() - subscribeTracks(t, subSess, "video") - reqs := forwardedPublishes(t, subSess) - - pubSess := dialAnotherClient(t, subSess) - publishVideoTrack(t, pubSess, "cam", 7) - fwd := awaitForwarded(t, reqs) - in := acceptForwarded(t, fwd) - go sendObject(pubSess, 7, 1) - awaitObjectOn(t, subSess, in.TrackAlias()) -} - -// TestForwardedPublish_UninterestedStopsDelivery: "A subscriber receiving a -// PUBLISH for a Track it does not wish to receive SHOULD send REQUEST_ERROR -// with error code UNINTERESTED" (§10.11); the relay stops serving it. -func TestForwardedPublish_UninterestedStopsDelivery(t *testing.T) { - t.Parallel() - subSess, teardown := connectRelay(t, relay.Config{}) - defer teardown() - subscribeTracks(t, subSess, "video") - reqs := forwardedPublishes(t, subSess) - - pubSess := dialAnotherClient(t, subSess) - publishVideoTrack(t, pubSess, "cam", 7) - fwd := awaitForwarded(t, reqs) - alias := fwd.First.(*message.Publish).TrackAlias - // REQUEST_ERROR and a FIN only: no STOP_SENDING, which would end the - // subscription by itself (§3.3.3) whatever the relay made of the error. - refused := make(chan struct{}) - go func() { - _ = message.Marshal(fwd.Stream, &message.RequestError{ - ErrorCode: moqt.RequestUninterested, ErrorReason: "no thanks", - }) - _ = fwd.Stream.Close() - close(refused) - }() - select { - case <-refused: - case <-time.After(2 * time.Second): - t.Fatal("the relay never read the REQUEST_ERROR") - } - time.Sleep(100 * time.Millisecond) // let the relay act on it - - go sendObject(pubSess, 7, 1) - ctx, cancel := context.WithTimeout(t.Context(), 300*time.Millisecond) - defer cancel() - for { - ds, err := subSess.AcceptDataStream(ctx) - if err != nil { - return // nothing delivered: correct - } - if sg, ok := ds.(*session.IncomingSubgroupStream); ok && sg.Header.TrackAlias == alias { - t.Fatal("relay kept delivering after REQUEST_ERROR UNINTERESTED") - } - } -} - -// TestForwardedPublish_EndsWithPublishDone: when the track's publisher goes -// away the forwarded subscription ends with PUBLISH_DONE, not a bare FIN. -func TestForwardedPublish_EndsWithPublishDone(t *testing.T) { - t.Parallel() - subSess, teardown := connectRelay(t, relay.Config{}) - defer teardown() - subscribeTracks(t, subSess, "video") - reqs := forwardedPublishes(t, subSess) - - pub := publishVideoTrack(t, dialAnotherClient(t, subSess), "cam", 7) - fwd := awaitForwarded(t, reqs) - in := acceptForwarded(t, fwd) - msgs := streamMessages(t, in.Stream) - _ = pub.Done(moqt.PublishDoneTrackEnded, "bye") - if m := nextMessage(t, msgs); m.Type() != message.TypePublishDone { - t.Fatalf("got %T, want PUBLISH_DONE", m) - } -} - -// TestForwardedPublish_AnswersRequestUpdate: the subscriber of a PUBLISH may -// send REQUEST_UPDATE (§10.9), and it gets its single mandated response. -func TestForwardedPublish_AnswersRequestUpdate(t *testing.T) { - t.Parallel() - subSess, teardown := connectRelay(t, relay.Config{}) - defer teardown() - subscribeTracks(t, subSess, "video") - reqs := forwardedPublishes(t, subSess) - - publishVideoTrack(t, dialAnotherClient(t, subSess), "cam", 7) - fwd := awaitForwarded(t, reqs) - acceptForwarded(t, fwd) - // Off the test goroutine: the REQUEST_UPDATE write itself blocks on the - // unbuffered test pipe if the relay never reads it. - answered := make(chan error, 1) - go func() { - _, err := subSess.UpdateRequest(t.Context(), fwd.Stream, message.Parameters{message.ForwardParam(false)}) - answered <- err - }() - select { - case err := <-answered: - if err != nil { - t.Fatalf("REQUEST_UPDATE on a forwarded PUBLISH: %v", err) - } - case <-time.After(2 * time.Second): - t.Fatal("REQUEST_UPDATE on a forwarded PUBLISH was never answered") - } -} - -// TestForwardedPublish_OnePerTrack: a second publisher of a track the -// subscriber already receives does not open a second subscription to it. -func TestForwardedPublish_OnePerTrack(t *testing.T) { - t.Parallel() - subSess, teardown := connectRelay(t, relay.Config{}) - defer teardown() - subscribeTracks(t, subSess, "video") - reqs := forwardedPublishes(t, subSess) - - publishVideoTrack(t, dialAnotherClient(t, subSess), "cam", 7) - fwd := awaitForwarded(t, reqs) - acceptForwarded(t, fwd) - publishVideoTrack(t, dialAnotherClient(t, subSess), "cam", 9) - requireNoForward(t, reqs, "a second publisher of a forwarded track") -} - -// TestForwardedPublish_ExistingTrackAnnounced: SUBSCRIBE_TRACKS asks for -// "all tracks within matching namespaces" (§10.20), including one published -// before it arrived. -func TestForwardedPublish_ExistingTrackAnnounced(t *testing.T) { - t.Parallel() - pubSess, teardown := connectRelay(t, relay.Config{}) - defer teardown() - publishVideoTrack(t, pubSess, "cam", 7) - - subSess := dialAnotherClient(t, pubSess) - reqs := forwardedPublishes(t, subSess) - subscribeTracks(t, subSess, "video") - fwd := awaitForwarded(t, reqs) - if name := string(fwd.First.(*message.Publish).Name); name != "cam" { - t.Fatalf("forwarded %q, want cam", name) - } -} - -// TestForwardedPublish_OwnTrackNotEchoed: §6.1 "the publisher sends PUBLISH -// messages for tracks within matching namespaces, excluding tracks published -// by the subscriber". -func TestForwardedPublish_OwnTrackNotEchoed(t *testing.T) { - t.Parallel() - sess, teardown := connectRelay(t, relay.Config{}) - defer teardown() - subscribeTracks(t, sess, "video") - reqs := forwardedPublishes(t, sess) - publishVideoTrack(t, sess, "mine", 7) - requireNoForward(t, reqs, "the subscriber's own track") -} - -// TestForwardedPublish_DropsUpstreamParameters: Message Parameters "are not -// forwarded by Relays" (§10.2.1); an upstream's AUTHORIZATION_TOKEN in -// particular means nothing on another session. -func TestForwardedPublish_DropsUpstreamParameters(t *testing.T) { - t.Parallel() - subSess, teardown := connectRelay(t, relay.Config{}) - defer teardown() - subscribeTracks(t, subSess, "video") - reqs := forwardedPublishes(t, subSess) - - tok := message.AuthorizationTokenParam(message.Token{ - AliasType: message.AliasTypeUseValue, TokenType: 1, TokenValue: []byte("secret"), - }) - publishVideoTrack(t, dialAnotherClient(t, subSess), "cam", 7, tok) - fwd := awaitForwarded(t, reqs) - if _, found := fwd.First.(*message.Publish).Parameters.Find(message.ParamAuthorizationToken); found { - t.Fatal("forwarded PUBLISH carries the upstream's AUTHORIZATION_TOKEN") - } -} - -// TestForwardedPublish_EchoesSubscribeTracksParameters: SUBSCRIBE_TRACKS -// parameters "are used by the publisher as the initial Subscription parameters -// ... These Parameters are explicitly communicated in PUBLISH" (§10.20.1) — -// except AUTHORIZATION_TOKEN, which "MUST NOT be copied from a -// SUBSCRIBE_TRACKS to the resulting PUBLISH message Parameters" (§10.2.2). -func TestForwardedPublish_EchoesSubscribeTracksParameters(t *testing.T) { - t.Parallel() - subSess, teardown := connectRelay(t, relay.Config{}) - defer teardown() - tok := message.AuthorizationTokenParam(message.Token{ - AliasType: message.AliasTypeUseValue, TokenType: 1, TokenValue: []byte("mine"), - }) - ts, err := subSess.SubscribeTracks(t.Context(), &message.SubscribeTracks{ - TrackNamespacePrefix: ns("video"), - Parameters: message.Parameters{message.SubscriberPriorityParam(9), tok}, - }) - if err != nil { - t.Fatalf("SubscribeTracks: %v", err) - } - t.Cleanup(func() { _ = ts.Close() }) - reqs := forwardedPublishes(t, subSess) - - publishVideoTrack(t, dialAnotherClient(t, subSess), "cam", 7) - params := awaitForwarded(t, reqs).First.(*message.Publish).Parameters - if p, found := params.Find(message.ParamSubscriberPriority); !found || p.Byte != 9 { - t.Errorf("forwarded PUBLISH SUBSCRIBER_PRIORITY = %+v (found %v), want 9", p, found) - } - if _, found := params.Find(message.ParamAuthorizationToken); found { - t.Error("forwarded PUBLISH carries the SUBSCRIBE_TRACKS's AUTHORIZATION_TOKEN") - } -} - -// TestForwardedPublish_SubscribedTrackNotForwarded: a track the subscriber -// already receives through its own SUBSCRIBE is not forwarded as well. -func TestForwardedPublish_SubscribedTrackNotForwarded(t *testing.T) { - t.Parallel() - pubSess, teardown := connectRelay(t, relay.Config{}) - defer teardown() - publishVideoTrack(t, pubSess, "cam", 7) - sess := dialAnotherClient(t, pubSess) - sub, err := sess.Subscribe(t.Context(), &message.Subscribe{Namespace: ns("video"), Name: []byte("cam")}) - if err != nil { - t.Fatalf("Subscribe: %v", err) - } - t.Cleanup(func() { _ = sub.Close() }) - reqs := forwardedPublishes(t, sess) - subscribeTracks(t, sess, "video") - requireNoForward(t, reqs, "a track the subscriber already SUBSCRIBEd to") -} - -// TestForwardedPublish_RepublishedTrackForwardedAgain: once a forwarded -// subscription has ended with PUBLISH_DONE, a new publication of the track is -// forwarded afresh, whether or not the subscriber has closed its side yet -// (§10.12: the sender "can immediately destroy subscription state"). -func TestForwardedPublish_RepublishedTrackForwardedAgain(t *testing.T) { - t.Parallel() - subSess, teardown := connectRelay(t, relay.Config{}) - defer teardown() - subscribeTracks(t, subSess, "video") - reqs := forwardedPublishes(t, subSess) - - pub := publishVideoTrack(t, dialAnotherClient(t, subSess), "cam", 7) - in := acceptForwarded(t, awaitForwarded(t, reqs)) - msgs := streamMessages(t, in.Stream) - _ = pub.Done(moqt.PublishDoneTrackEnded, "bye") - if m := nextMessage(t, msgs); m.Type() != message.TypePublishDone { - t.Fatalf("got %T, want PUBLISH_DONE", m) - } - publishVideoTrack(t, dialAnotherClient(t, subSess), "cam", 9) - awaitForwarded(t, reqs) -} - -// TestForwardedPublish_SubscribeTracksParametersValidated: SUBSCRIBE_TRACKS -// carries SUBSCRIBE's parameters (§10.20.1), so it is refused on the same terms -// — a malformed LOCATION_FILTER gets MALFORMED_TRACK, as on SUBSCRIBE — rather -// than accepted and then failing every forwarded PUBLISH. -func TestForwardedPublish_SubscribeTracksParametersValidated(t *testing.T) { - t.Parallel() - subSess, teardown := connectRelay(t, relay.Config{}) - defer teardown() - _, err := subSess.SubscribeTracks(t.Context(), &message.SubscribeTracks{ - TrackNamespacePrefix: ns("video"), - Parameters: message.Parameters{message.BytesParam(message.ParamLocationFilter, []byte{0xFF})}, - }) - requireRejectedWithCode(t, err, moqt.RequestMalformedTrack) -} diff --git a/pkg/relay/handler_datagram_test.go b/pkg/relay/handler_datagram_test.go index 478e36eb..a361416a 100644 --- a/pkg/relay/handler_datagram_test.go +++ b/pkg/relay/handler_datagram_test.go @@ -8,7 +8,6 @@ import ( "github.com/floatdrop/moq-go/pkg/moqt/message" "github.com/floatdrop/moq-go/pkg/moqt/session" - "github.com/floatdrop/moq-go/pkg/moqt/wire" "github.com/floatdrop/moq-go/pkg/relay" ) @@ -24,7 +23,7 @@ func TestDatagram_PublisherToSubscriberSingleDatagram(t *testing.T) { const publisherAlias = uint64(7) pubReqStream, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), TrackAlias: publisherAlias, }) @@ -35,7 +34,7 @@ func TestDatagram_PublisherToSubscriberSingleDatagram(t *testing.T) { subSess := dialAnotherClient(t, pubSess) subReq, err := subSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), }) if err != nil { @@ -97,7 +96,7 @@ func TestDatagram_FilterDropsBelowStart(t *testing.T) { const publisherAlias = uint64(7) pubReqStream, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), TrackAlias: publisherAlias, }) @@ -108,7 +107,7 @@ func TestDatagram_FilterDropsBelowStart(t *testing.T) { subSess := dialAnotherClient(t, pubSess) subReq, err := subSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), Parameters: message.Parameters{ message.LocationFilterParam(&message.LocationFilter{Fields: 2, StartGroup: 0, StartObject: 2}), @@ -177,7 +176,7 @@ func TestDatagram_UnknownAliasDroppedSilently(t *testing.T) { const publisherAlias = uint64(7) pubReqStream, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), TrackAlias: publisherAlias, }) @@ -188,7 +187,7 @@ func TestDatagram_UnknownAliasDroppedSilently(t *testing.T) { subSess := dialAnotherClient(t, pubSess) subReq, err := subSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), }) if err != nil { @@ -253,7 +252,7 @@ func TestDatagram_PausedSubscriptionReceivesNothing(t *testing.T) { const publisherAlias = uint64(7) pubStream, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), TrackAlias: publisherAlias, }) @@ -264,7 +263,7 @@ func TestDatagram_PausedSubscriptionReceivesNothing(t *testing.T) { subSess := dialAnotherClient(t, pubSess) subMsg := &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), } subStream, err := subSess.Subscribe(t.Context(), subMsg) @@ -339,7 +338,7 @@ func TestDatagram_RedundantPublishersDeduped(t *testing.T) { publish := func(sess *session.Session, alias uint64) { t.Helper() stream, err := sess.Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), TrackAlias: alias, }) @@ -353,7 +352,7 @@ func TestDatagram_RedundantPublishersDeduped(t *testing.T) { subSess := dialAnotherClient(t, pubA) subStream, err := subSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), }) if err != nil { diff --git a/pkg/relay/handler_fanout_firstobject_test.go b/pkg/relay/handler_fanout_firstobject_test.go index d18b3631..d3ca5cd2 100644 --- a/pkg/relay/handler_fanout_firstobject_test.go +++ b/pkg/relay/handler_fanout_firstobject_test.go @@ -8,7 +8,6 @@ import ( "github.com/floatdrop/moq-go/pkg/moqt/message" "github.com/floatdrop/moq-go/pkg/moqt/session" - "github.com/floatdrop/moq-go/pkg/moqt/wire" "github.com/floatdrop/moq-go/pkg/relay" ) @@ -76,7 +75,7 @@ func firstObjectTopology( t.Cleanup(teardown) pub, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), TrackAlias: 7, }) @@ -87,7 +86,7 @@ func firstObjectTopology( sub = dialAnotherClient(t, pubSess) subReq, err := sub.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), Parameters: params, }) @@ -256,7 +255,7 @@ func TestFanout_ResolvesImplicitFirstObjectSubgroupID(t *testing.T) { // The cache must file the objects under subgroup 5 too: FETCH the range // back and check the decoded Subgroup IDs. fetchReq, err := sub.Fetch(t.Context(), &message.Fetch{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), Parameters: message.Parameters{ fetchRangeFilter(message.Location{}, message.Location{Group: 0, Object: 6}), diff --git a/pkg/relay/handler_fanout_lag_test.go b/pkg/relay/handler_fanout_lag_test.go index 44438c69..94e00ab6 100644 --- a/pkg/relay/handler_fanout_lag_test.go +++ b/pkg/relay/handler_fanout_lag_test.go @@ -6,7 +6,6 @@ import ( "github.com/floatdrop/moq-go/pkg/moqt/message" "github.com/floatdrop/moq-go/pkg/moqt/session" - "github.com/floatdrop/moq-go/pkg/moqt/wire" "github.com/floatdrop/moq-go/pkg/relay" ) @@ -29,11 +28,11 @@ func TestFanout_LagWindowResetsSlowSubscriber(t *testing.T) { defer teardown() const alias = uint64(7) - ns := wire.TrackNamespace{[]byte("video")} + video := ns("video") name := []byte("cam1") pubReq, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: ns, Name: name, TrackAlias: alias, + Namespace: video, Name: name, TrackAlias: alias, }) if err != nil { t.Fatalf("Publish: %v", err) @@ -41,7 +40,7 @@ func TestFanout_LagWindowResetsSlowSubscriber(t *testing.T) { defer pubReq.Close() subSess := dialAnotherClient(t, pubSess) - subReq, err := subSess.Subscribe(t.Context(), &message.Subscribe{Namespace: ns, Name: name}) + subReq, err := subSess.Subscribe(t.Context(), &message.Subscribe{Namespace: video, Name: name}) if err != nil { t.Fatalf("Subscribe: %v", err) } diff --git a/pkg/relay/handler_fanout_multipub_test.go b/pkg/relay/handler_fanout_multipub_test.go index de1021ce..dbc2be85 100644 --- a/pkg/relay/handler_fanout_multipub_test.go +++ b/pkg/relay/handler_fanout_multipub_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "io" + "maps" "slices" "testing" "time" @@ -11,7 +12,6 @@ import ( "github.com/floatdrop/moq-go/pkg/moqt" "github.com/floatdrop/moq-go/pkg/moqt/message" "github.com/floatdrop/moq-go/pkg/moqt/session" - "github.com/floatdrop/moq-go/pkg/moqt/wire" "github.com/floatdrop/moq-go/pkg/relay" ) @@ -65,21 +65,6 @@ func readSubgroups(ctx context.Context, sub *session.Session, out chan<- objEven } } -// publishTrack opens a PUBLISH request stream for (video, cam1) with the given -// inbound alias, returning the request stream (kept open by the caller). -func publishTrack(t *testing.T, sess *session.Session, alias uint64) *session.Publication { - t.Helper() - p, err := sess.Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("video")}, - Name: []byte("cam1"), - TrackAlias: alias, - }) - if err != nil { - t.Fatalf("Publish(alias=%d): %v", alias, err) - } - return p -} - // TestFanout_MultiPublisher_DeduplicatesObjects pins §9.5 / §2.1: two publishers // claim the same Full Track Name and push the SAME {GroupID, ObjectID} objects. // The relay must merge them into ONE outbound subgroup stream per subscriber @@ -95,13 +80,13 @@ func TestFanout_MultiPublisher_DeduplicatesObjects(t *testing.T) { pubB := dialAnotherClient(t, pubA) subSess := dialAnotherClient(t, pubA) - aPub := publishTrack(t, pubA, 1) + aPub := publishVideoTrack(t, pubA, "cam1", 1) defer aPub.Close() - bPub := publishTrack(t, pubB, 2) + bPub := publishVideoTrack(t, pubB, "cam1", 2) defer bPub.Close() subReq, err := subSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), }) if err != nil { @@ -160,7 +145,7 @@ func TestFanout_MultiPublisher_DeduplicatesObjects(t *testing.T) { if !errors.Is(end.err, io.EOF) { t.Fatalf("merged stream ended with %v, want io.EOF (clean FIN)", end.err) } - if want := []uint64{0, 1, 2}; !equalIDs(got, want) { + if want := []uint64{0, 1, 2}; !slices.Equal(got, want) { t.Fatalf("subscriber saw object IDs %v, want %v (each delivered exactly once)", got, want) } if end.stream != 1 { @@ -183,13 +168,13 @@ func TestFanout_MultiPublisher_DedupSurvivesCacheEviction(t *testing.T) { pubB := dialAnotherClient(t, pubA) subSess := dialAnotherClient(t, pubA) - aPub := publishTrack(t, pubA, 1) + aPub := publishVideoTrack(t, pubA, "cam1", 1) defer aPub.Close() - bPub := publishTrack(t, pubB, 2) + bPub := publishVideoTrack(t, pubB, "cam1", 2) defer bPub.Close() subReq, err := subSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), }) if err != nil { @@ -251,7 +236,7 @@ func TestFanout_MultiPublisher_DedupSurvivesCacheEviction(t *testing.T) { t.Fatalf("objects spanned %d outbound streams, want 1 (a re-delivered evicted object would reopen)", end.stream) } want := []uint64{0, 1, 2, 3, 4, 5, 6, 7, 8, 9} - if !equalIDs(got, want) { + if !slices.Equal(got, want) { t.Fatalf("subscriber saw %v, want %v (each delivered exactly once despite eviction)", got, want) } } @@ -269,13 +254,13 @@ func TestFanout_MultiPublisher_FailoverContinuesFromSurvivor(t *testing.T) { pubB := dialAnotherClient(t, pubA) subSess := dialAnotherClient(t, pubA) - aPub := publishTrack(t, pubA, 1) + aPub := publishVideoTrack(t, pubA, "cam1", 1) defer aPub.Close() - bPub := publishTrack(t, pubB, 2) + bPub := publishVideoTrack(t, pubB, "cam1", 2) defer bPub.Close() subReq, err := subSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), }) if err != nil { @@ -333,7 +318,7 @@ func TestFanout_MultiPublisher_FailoverContinuesFromSurvivor(t *testing.T) { if end.stream != 1 { t.Fatalf("objects spanned %d streams, want 1 (failover must not reopen)", end.stream) } - if want := []uint64{0, 1, 2}; !equalIDs(got, want) { + if want := []uint64{0, 1, 2}; !slices.Equal(got, want) { t.Fatalf("subscriber saw %v, want %v across the failover", got, want) } } @@ -351,13 +336,13 @@ func TestFanout_MultiPublisher_MergesDisjointObjects(t *testing.T) { pubB := dialAnotherClient(t, pubA) subSess := dialAnotherClient(t, pubA) - aPub := publishTrack(t, pubA, 1) + aPub := publishVideoTrack(t, pubA, "cam1", 1) defer aPub.Close() - bPub := publishTrack(t, pubB, 2) + bPub := publishVideoTrack(t, pubB, "cam1", 2) defer bPub.Close() subReq, err := subSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), }) if err != nil { @@ -421,7 +406,7 @@ func TestFanout_MultiPublisher_MergesDisjointObjects(t *testing.T) { } for _, id := range []uint64{0, 1, 2, 3, 4, 5} { if seen[id] != 1 { - t.Fatalf("object %d missing from delivered set %v", id, sortedKeys(seen)) + t.Fatalf("object %d missing from delivered set %v", id, slices.Sorted(maps.Keys(seen))) } } } @@ -456,24 +441,3 @@ func awaitStreamEnd(t *testing.T, events <-chan objEvent) objEvent { return objEvent{} } } - -func equalIDs(got, want []uint64) bool { - if len(got) != len(want) { - return false - } - for i := range got { - if got[i] != want[i] { - return false - } - } - return true -} - -func sortedKeys(m map[uint64]int) []uint64 { - out := make([]uint64, 0, len(m)) - for k := range m { - out = append(out, k) - } - slices.Sort(out) - return out -} diff --git a/pkg/relay/handler_fanout_terminal_test.go b/pkg/relay/handler_fanout_terminal_test.go index a6769239..45250fa5 100644 --- a/pkg/relay/handler_fanout_terminal_test.go +++ b/pkg/relay/handler_fanout_terminal_test.go @@ -6,7 +6,6 @@ import ( "github.com/floatdrop/moq-go/pkg/moqt/message" "github.com/floatdrop/moq-go/pkg/moqt/session" - "github.com/floatdrop/moq-go/pkg/moqt/wire" "github.com/floatdrop/moq-go/pkg/relay" ) @@ -23,7 +22,7 @@ func TestFanout_ObjectAfterEndOfGroupResetsStream(t *testing.T) { const alias = uint64(7) pubStream, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), TrackAlias: alias, }) @@ -34,7 +33,7 @@ func TestFanout_ObjectAfterEndOfGroupResetsStream(t *testing.T) { subSess := dialAnotherClient(t, pubSess) subStream, err := subSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), }) if err != nil { diff --git a/pkg/relay/handler_fanout_test.go b/pkg/relay/handler_fanout_test.go index 98fe0d1e..af2063e1 100644 --- a/pkg/relay/handler_fanout_test.go +++ b/pkg/relay/handler_fanout_test.go @@ -1,7 +1,6 @@ package relay_test import ( - "context" "errors" "io" "reflect" @@ -11,7 +10,6 @@ import ( "github.com/floatdrop/moq-go/pkg/moqt" "github.com/floatdrop/moq-go/pkg/moqt/message" "github.com/floatdrop/moq-go/pkg/moqt/session" - "github.com/floatdrop/moq-go/pkg/moqt/wire" "github.com/floatdrop/moq-go/pkg/relay" ) @@ -29,7 +27,7 @@ func TestFanout_PublisherToSubscriberSingleObject(t *testing.T) { const publisherAlias = uint64(7) pubReqStream, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), TrackAlias: publisherAlias, }) @@ -40,7 +38,7 @@ func TestFanout_PublisherToSubscriberSingleObject(t *testing.T) { subSess := dialAnotherClient(t, pubSess) subReqStream, err := subSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), }) if err != nil { @@ -135,7 +133,7 @@ func TestFanout_StalledSubscriberDoesNotBlockFastOne(t *testing.T) { const publisherAlias = uint64(7) pubReqStream, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), TrackAlias: publisherAlias, }) @@ -149,7 +147,7 @@ func TestFanout_StalledSubscriberDoesNotBlockFastOne(t *testing.T) { slowSess := dialAnotherClient(t, pubSess) fastReq, err := fastSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), }) if err != nil { @@ -158,7 +156,7 @@ func TestFanout_StalledSubscriberDoesNotBlockFastOne(t *testing.T) { defer fastReq.Close() slowReq, err := slowSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), }) if err != nil { @@ -292,7 +290,7 @@ func TestFanout_UnresponsiveSubscriberDoesNotStallSubgroup(t *testing.T) { const publisherAlias = uint64(7) pubReqStream, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), TrackAlias: publisherAlias, }) @@ -306,7 +304,7 @@ func TestFanout_UnresponsiveSubscriberDoesNotStallSubgroup(t *testing.T) { // complete on the in-process unbuffered pipes. deadSess := dialAnotherClient(t, pubSess) deadReq, err := deadSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), }) if err != nil { @@ -316,7 +314,7 @@ func TestFanout_UnresponsiveSubscriberDoesNotStallSubgroup(t *testing.T) { liveSess := dialAnotherClient(t, pubSess) liveReq, err := liveSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), }) if err != nil { @@ -379,7 +377,7 @@ func TestFanout_AbsoluteStartFilter_DropsObjectsBeforeStart(t *testing.T) { const publisherAlias = uint64(7) pubReqStream, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), TrackAlias: publisherAlias, }) @@ -390,7 +388,7 @@ func TestFanout_AbsoluteStartFilter_DropsObjectsBeforeStart(t *testing.T) { subSess := dialAnotherClient(t, pubSess) subReq, err := subSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), Parameters: message.Parameters{ message.LocationFilterParam(&message.LocationFilter{Fields: 2, StartGroup: 0, StartObject: 2}), @@ -496,7 +494,7 @@ func TestFanout_AbsoluteRangeFilter_DropsObjectsOutsideRange(t *testing.T) { const publisherAlias = uint64(7) pubReqStream, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), TrackAlias: publisherAlias, }) @@ -507,7 +505,7 @@ func TestFanout_AbsoluteRangeFilter_DropsObjectsOutsideRange(t *testing.T) { subSess := dialAnotherClient(t, pubSess) subReq, err := subSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), Parameters: message.Parameters{ message.LocationFilterParam( @@ -610,7 +608,7 @@ func TestSubscribe_InstallsPriorityAndGroupOrder(t *testing.T) { defer teardown() pubReqStream, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), TrackAlias: 7, }) @@ -621,7 +619,7 @@ func TestSubscribe_InstallsPriorityAndGroupOrder(t *testing.T) { subSess := dialAnotherClient(t, pubSess) subReq, err := subSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), Parameters: message.Parameters{ message.SubscriberPriorityParam(42), @@ -657,7 +655,7 @@ func TestFanout_GapInForwardedObjectIDsOpensNewStream(t *testing.T) { const publisherAlias = uint64(7) pubReqStream, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), TrackAlias: publisherAlias, }) @@ -668,7 +666,7 @@ func TestFanout_GapInForwardedObjectIDsOpensNewStream(t *testing.T) { subSess := dialAnotherClient(t, pubSess) subReq, err := subSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), }) if err != nil { @@ -796,7 +794,7 @@ func TestFanout_InboundResetCancelsDownstream(t *testing.T) { const publisherAlias = uint64(7) pubReqStream, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), TrackAlias: publisherAlias, }) @@ -807,7 +805,7 @@ func TestFanout_InboundResetCancelsDownstream(t *testing.T) { subSess := dialAnotherClient(t, pubSess) subReq, err := subSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), }) if err != nil { @@ -890,7 +888,7 @@ func TestFanout_UpdatesTrackEntryLargestObject(t *testing.T) { const publisherAlias = uint64(7) pubReqStream, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), TrackAlias: publisherAlias, }) @@ -905,14 +903,14 @@ func TestFanout_UpdatesTrackEntryLargestObject(t *testing.T) { // subscriber 2) doesn't deadlock on a missing acceptor. subSess := dialAnotherClient(t, pubSess) subReq, err := subSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), }) if err != nil { t.Fatalf("Subscribe: %v", err) } defer subReq.Close() - go drainAllStreams(t.Context(), subSess) + go drainAll(t.Context(), subSess) // Phase 1: publish three objects (absIDs 0, 1, 2) on group 4. After // this the relay's TrackEntry.LargestObject must be {Group: 4, @@ -956,7 +954,7 @@ func TestFanout_UpdatesTrackEntryLargestObject(t *testing.T) { // object at a Location <= {4, 2} should be filtered out. subSess2 := dialAnotherClient(t, pubSess) subReq2, err := subSess2.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), Parameters: message.Parameters{ message.LocationFilterParam(&message.LocationFilter{Fields: 2}), @@ -1059,35 +1057,6 @@ func TestFanout_UpdatesTrackEntryLargestObject(t *testing.T) { } } -// drainAllStreams accepts every data stream the session yields and reads -// it to EOF. Used by tests that need a "background drain" so the relay -// doesn't block on OpenSubgroup for an unread acceptor. -// -// The ctx must be cancellable (typically t.Context()) so this goroutine -// exits when the test finishes. Without an explicit cancellation -// signal, AcceptDataStream blocks indefinitely on its underlying -// AcceptUniStream — it does not observe session shutdown directly — -// and the leaked goroutine accumulates across repeated test runs -// (go test -count=N), eventually wedging the process at exit when the -// runtime waits for all goroutines. -func drainAllStreams(ctx context.Context, s *session.Session) { - for { - ds, err := s.AcceptDataStream(ctx) - if err != nil { - return - } - sg, ok := ds.(*session.IncomingSubgroupStream) - if !ok { - continue - } - for { - if _, err := sg.ReadObject(); err != nil { - break - } - } - } -} - // TestSubscribe_InvalidGroupOrderRejected pins the §10.2.8 rule: GROUP_ORDER // values other than 0x1 (Ascending) and 0x2 (Descending) are a session-level // PROTOCOL_VIOLATION, so a SUBSCRIBE carrying one closes the whole session. @@ -1098,7 +1067,7 @@ func TestSubscribe_InvalidGroupOrderRejected(t *testing.T) { defer teardown() pubReqStream, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), TrackAlias: 7, }) @@ -1109,16 +1078,12 @@ func TestSubscribe_InvalidGroupOrderRejected(t *testing.T) { subSess := dialAnotherClient(t, pubSess) _, _ = subSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), Parameters: message.Parameters{ message.ByteParam(message.ParamGroupOrder, 0x05), }, }) - select { - case <-subSess.Done(): - case <-time.After(2 * time.Second): - t.Fatal("session not closed after out-of-range GROUP_ORDER SUBSCRIBE (§10.2.8)") - } + requireSessionClosed(t, subSess, "out-of-range GROUP_ORDER SUBSCRIBE (§10.2.8)") } diff --git a/pkg/relay/handler_fanout_timeout_test.go b/pkg/relay/handler_fanout_timeout_test.go index 991443b3..190008fa 100644 --- a/pkg/relay/handler_fanout_timeout_test.go +++ b/pkg/relay/handler_fanout_timeout_test.go @@ -23,27 +23,6 @@ import ( // MaxFanoutLag is deliberately left at its zero value throughout, so the only // escalation that can fire is the one under test. -// publishOneSubgroup writes n one-byte objects on (group, subgroup) and closes -// the stream. Run in a goroutine: the in-process transport is synchronous, so -// the first WriteObject blocks until the subscriber reads. -func publishOneSubgroup(sess *session.Session, alias, group uint64, n int) { - sg, err := sess.OpenSubgroup(message.SubgroupHeader{ - SubgroupIDMode: message.SubgroupIDExplicit, - TrackAlias: alias, - GroupID: group, - SubgroupID: 0, - }) - if err != nil { - return - } - for range n { - if err := sg.WriteObject(&message.SubgroupObject{Payload: []byte("x")}); err != nil { - return - } - } - _ = sg.Close() -} - // countUntilEnd reads objects until the stream ends and returns how many // arrived. The in-process pipe transport does not carry §3.3.4 reset codes, so // a reset and a clean FIN look alike to the reader — the count is what @@ -78,11 +57,11 @@ func TestFanout_DeliveryTimeoutKeepsSubscriptionAlive(t *testing.T) { defer teardown() const alias = uint64(7) - ns := wire.TrackNamespace{[]byte("video")} + video := ns("video") name := []byte("cam1") pubReq, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: ns, Name: name, TrackAlias: alias, + Namespace: video, Name: name, TrackAlias: alias, }) if err != nil { t.Fatalf("Publish: %v", err) @@ -93,7 +72,7 @@ func TestFanout_DeliveryTimeoutKeepsSubscriptionAlive(t *testing.T) { // none, so §8's "smaller of the two non-zero values" resolves to this one. subSess := dialAnotherClient(t, pubSess) subReq, err := subSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: ns, Name: name, + Namespace: video, Name: name, Parameters: message.Parameters{message.ObjectDeliveryTimeoutParam(timeout)}, }) if err != nil { @@ -102,7 +81,7 @@ func TestFanout_DeliveryTimeoutKeepsSubscriptionAlive(t *testing.T) { defer subReq.Close() const objects = 6 - go publishOneSubgroup(pubSess, alias, 0, objects) + go sendObjects(pubSess, alias, 0, objects) ds, err := subSess.AcceptDataStream(t.Context()) if err != nil { @@ -126,7 +105,7 @@ func TestFanout_DeliveryTimeoutKeepsSubscriptionAlive(t *testing.T) { // The subscription must have survived: a fresh group still reaches us. // Read promptly this time so the timeout has no chance to fire again. - go publishOneSubgroup(pubSess, alias, 1, 2) + go sendObjects(pubSess, alias, 1, 2) ds2, err := subSess.AcceptDataStream(t.Context()) if err != nil { @@ -171,12 +150,12 @@ func testPublisherTrackDeliveryTimeout(t *testing.T, timeout time.Duration, trac defer teardown() const alias = uint64(7) - ns := wire.TrackNamespace{[]byte("video")} + video := ns("video") name := []byte("cam1") props := message.AppendTrackProperties(trackProps) pubReq, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: ns, Name: name, TrackAlias: alias, TrackProperties: props, + Namespace: video, Name: name, TrackAlias: alias, TrackProperties: props, }) if err != nil { t.Fatalf("Publish: %v", err) @@ -184,14 +163,14 @@ func testPublisherTrackDeliveryTimeout(t *testing.T, timeout time.Duration, trac defer pubReq.Close() subSess := dialAnotherClient(t, pubSess) - subReq, err := subSess.Subscribe(t.Context(), &message.Subscribe{Namespace: ns, Name: name}) + subReq, err := subSess.Subscribe(t.Context(), &message.Subscribe{Namespace: video, Name: name}) if err != nil { t.Fatalf("Subscribe: %v", err) } defer subReq.Close() const objects = 6 - go publishOneSubgroup(pubSess, alias, 0, objects) + go sendObjects(pubSess, alias, 0, objects) ds, err := subSess.AcceptDataStream(t.Context()) if err != nil { @@ -219,11 +198,11 @@ func TestFanout_NoDeliveryTimeoutLeavesStalledSubscriberAlone(t *testing.T) { defer teardown() const alias = uint64(7) - ns := wire.TrackNamespace{[]byte("video")} + video := ns("video") name := []byte("cam1") pubReq, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: ns, Name: name, TrackAlias: alias, + Namespace: video, Name: name, TrackAlias: alias, }) if err != nil { t.Fatalf("Publish: %v", err) @@ -231,14 +210,14 @@ func TestFanout_NoDeliveryTimeoutLeavesStalledSubscriberAlone(t *testing.T) { defer pubReq.Close() subSess := dialAnotherClient(t, pubSess) - subReq, err := subSess.Subscribe(t.Context(), &message.Subscribe{Namespace: ns, Name: name}) + subReq, err := subSess.Subscribe(t.Context(), &message.Subscribe{Namespace: video, Name: name}) if err != nil { t.Fatalf("Subscribe: %v", err) } defer subReq.Close() const objects = 4 - go publishOneSubgroup(pubSess, alias, 0, objects) + go sendObjects(pubSess, alias, 0, objects) ds, err := subSess.AcceptDataStream(t.Context()) if err != nil { diff --git a/pkg/relay/handler_fetch_session_test.go b/pkg/relay/handler_fetch_session_test.go index 10054770..846e5fa0 100644 --- a/pkg/relay/handler_fetch_session_test.go +++ b/pkg/relay/handler_fetch_session_test.go @@ -7,7 +7,6 @@ import ( "github.com/floatdrop/moq-go/pkg/moqt" "github.com/floatdrop/moq-go/pkg/moqt/message" - "github.com/floatdrop/moq-go/pkg/moqt/wire" "github.com/floatdrop/moq-go/pkg/relay" ) @@ -19,7 +18,7 @@ func TestFetch_RejectsUnknownTrack(t *testing.T) { defer teardown() _, err := clientSess.Fetch(t.Context(), &message.Fetch{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), Parameters: message.Parameters{ fetchRangeFilter(message.Location{}, message.Location{Group: 1, Object: math.MaxUint64}), @@ -37,7 +36,7 @@ func TestFetch_AuthDenialUsesPolicyCode(t *testing.T) { defer teardown() _, err := clientSess.Fetch(t.Context(), &message.Fetch{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), }) requireRejectedWithCode(t, err, moqt.RequestUnauthorized) @@ -55,7 +54,7 @@ func TestTrackStatus_ReplyForKnownTrack(t *testing.T) { defer teardown() pubStream, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), TrackAlias: 1, TrackProperties: []byte("rtp-h265"), @@ -67,7 +66,7 @@ func TestTrackStatus_ReplyForKnownTrack(t *testing.T) { querySess := dialAnotherClient(t, pubSess) tsStream, err := querySess.TrackStatus(t.Context(), &message.TrackStatus{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), }) if err != nil { @@ -91,7 +90,7 @@ func TestTrackStatus_ReplyEmptyPropertiesForKnownNamespace(t *testing.T) { defer teardown() pnsStream, err := pubSess.PublishNamespace(t.Context(), &message.PublishNamespace{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), }) if err != nil { t.Fatalf("PublishNamespace: %v", err) @@ -100,7 +99,7 @@ func TestTrackStatus_ReplyEmptyPropertiesForKnownNamespace(t *testing.T) { querySess := dialAnotherClient(t, pubSess) tsStream, err := querySess.TrackStatus(t.Context(), &message.TrackStatus{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam-anything"), }) if err != nil { @@ -122,7 +121,7 @@ func TestTrackStatus_RejectsUnknownTrack(t *testing.T) { defer teardown() _, err := clientSess.TrackStatus(t.Context(), &message.TrackStatus{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("phantom"), }) requireRejectedWithCode(t, err, moqt.RequestDoesNotExist) @@ -137,7 +136,7 @@ func TestTrackStatus_AuthDenialUsesPolicyCode(t *testing.T) { defer teardown() _, err := clientSess.TrackStatus(t.Context(), &message.TrackStatus{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), }) requireRejectedWithCode(t, err, moqt.RequestUnauthorized) diff --git a/pkg/relay/handler_fetch_stitch_test.go b/pkg/relay/handler_fetch_stitch_test.go index f2b3db52..1c90fba2 100644 --- a/pkg/relay/handler_fetch_stitch_test.go +++ b/pkg/relay/handler_fetch_stitch_test.go @@ -1,15 +1,10 @@ package relay_test import ( - "context" - "errors" - "io" "testing" "time" "github.com/floatdrop/moq-go/pkg/moqt/message" - "github.com/floatdrop/moq-go/pkg/moqt/session" - "github.com/floatdrop/moq-go/pkg/moqt/wire" "github.com/floatdrop/moq-go/pkg/relay" ) @@ -31,12 +26,12 @@ func TestFetch_StitchesEvictedRangeFromUpstream(t *testing.T) { upSess, teardown := connectRelay(t, relay.Config{}) defer teardown() - ns := wire.TrackNamespace{[]byte("video")} + video := ns("video") name := []byte("cam1") const liveLo, liveHi = uint64(5), uint64(9) // cached → floor = group 5 const upstreamAlias = uint64(42) - if _, err := upSess.PublishNamespace(t.Context(), &message.PublishNamespace{Namespace: ns}); err != nil { + if _, err := upSess.PublishNamespace(t.Context(), &message.PublishNamespace{Namespace: video}); err != nil { t.Fatalf("PublishNamespace: %v", err) } @@ -103,7 +98,7 @@ func TestFetch_StitchesEvictedRangeFromUpstream(t *testing.T) { // Live subscriber S triggers the on-demand upstream subscription and lets // the relay cache the tail. Its data streams are drained and ignored. live := dialAnotherClient(t, upSess) - liveReq, err := live.Subscribe(t.Context(), &message.Subscribe{Namespace: ns, Name: name}) + liveReq, err := live.Subscribe(t.Context(), &message.Subscribe{Namespace: video, Name: name}) if err != nil { t.Fatalf("live Subscribe: %v", err) } @@ -114,12 +109,11 @@ func TestFetch_StitchesEvictedRangeFromUpstream(t *testing.T) { // Retrying absorbs the caching timing (the relay populates the cache as it // reads the tail) without polling relay internals. fc := dialAnotherClient(t, upSess) - want := liveHi + 1 // groups 0..liveHi deadline := time.Now().Add(5 * time.Second) for { - got := tryStitchFetch(t, fc, ns, name, liveHi) - if uint64(len(got)) == want && contiguousFromZero(got) { - break // success: groups 0..liveHi, in order + got := objectGroups(tryFetchElems(t, fc, video, name, liveHi, nil)) + if groupsEqual(got, 0, liveHi) { + break } if time.Now().After(deadline) { t.Fatalf("stitched FETCH never returned groups 0..%d; last saw %v", liveHi, got) @@ -127,134 +121,3 @@ func TestFetch_StitchesEvictedRangeFromUpstream(t *testing.T) { time.Sleep(50 * time.Millisecond) } } - -// tryStitchFetch issues one standalone FETCH for [0, lastGroup] and returns the -// decoded group IDs from the response (empty on a REQUEST_ERROR, e.g. before -// the relay has observed any object). -func tryStitchFetch( - t *testing.T, - sess *session.Session, - ns wire.TrackNamespace, - name []byte, - lastGroup uint64, -) []uint64 { - t.Helper() - fetchReq, err := sess.Fetch(t.Context(), &message.Fetch{ - Namespace: ns, - Name: name, - Parameters: message.Parameters{ - fetchRangeFilter(message.Location{}, message.Location{Group: lastGroup, Object: 0}), - }, - }) - if err != nil { - return nil // not yet serviceable (e.g. no objects observed) — caller retries - } - defer fetchReq.Close() - return collectFetchGroups(t, sess, 3*time.Second) -} - -func contiguousFromZero(groups []uint64) bool { - for i, g := range groups { - if g != uint64(i) { - return false - } - } - return true -} - -// writeFetchGroupRange writes single-object groups [startG, endG] (one object -// at ID 0 per group) onto a FETCH response stream using §11.4.4 ascending delta -// encoding: the first object carries the absolute start group, and each -// consecutive group encodes a GroupIDDelta of 0 (newGroup = prevGroup + 0 + 1). -func writeFetchGroupRange(out *session.OutgoingFetchStream, startG, endG uint64) { - first := true - for g := startG; g <= endG; g++ { - fo := &message.FetchObject{} - fo.SerializationFlags |= message.FetchFlagGroupIDDelta | message.FetchFlagObjectIDDelta - if first { - fo.GroupIDDelta = g // absolute group ID of the first object - fo.SerializationFlags |= message.FetchFlagPriority // first object spells priority out - first = false - } else { - fo.GroupIDDelta = 0 // consecutive group - } - fo.ObjectIDDelta = 0 - fo.ObjectPayload = []byte{byte('a' + g)} - if err := out.WriteObject(fo); err != nil { - return - } - } -} - -// drainAll consumes and discards every data stream on sess until ctx ends. -func drainAll(ctx context.Context, sess *session.Session) { - for { - ds, err := sess.AcceptDataStream(ctx) - if err != nil { - return - } - switch s := ds.(type) { - case *session.IncomingSubgroupStream: - for { - if _, err := s.ReadObject(); err != nil { - break - } - } - case *session.IncomingFetchStream: - for { - if _, err := s.ReadObject(); err != nil { - break - } - } - } - } -} - -// collectFetchGroups accepts the next FETCH response data stream and returns -// the decoded group IDs in arrival order. -func collectFetchGroups(t *testing.T, sess *session.Session, timeout time.Duration) []uint64 { - t.Helper() - type result struct { - groups []uint64 - err error - } - ch := make(chan result, 1) - go func() { - ds, err := sess.AcceptDataStream(t.Context()) - if err != nil { - ch <- result{err: err} - return - } - fs, ok := ds.(*session.IncomingFetchStream) - if !ok { - ch <- result{err: errors.New("not a fetch stream")} - return - } - var groups []uint64 - for { - obj, err := fs.ReadDecoded() - if err != nil { - if !errors.Is(err, io.EOF) { - ch <- result{err: err} - return - } - ch <- result{groups: groups} - return - } - if obj.IsEndOfRange() { - continue - } - groups = append(groups, obj.GroupID) - } - }() - select { - case r := <-ch: - if r.err != nil { - t.Fatalf("reading FETCH response: %v", r.err) - } - return r.groups - case <-time.After(timeout): - t.Fatal("FETCH response did not arrive within deadline") - return nil - } -} diff --git a/pkg/relay/handler_fetch_test.go b/pkg/relay/handler_fetch_test.go index 793e0633..cd2ffb58 100644 --- a/pkg/relay/handler_fetch_test.go +++ b/pkg/relay/handler_fetch_test.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "errors" - "fmt" "io" "math" "reflect" @@ -14,181 +13,8 @@ import ( "github.com/floatdrop/moq-go/pkg/moqt" "github.com/floatdrop/moq-go/pkg/moqt/message" "github.com/floatdrop/moq-go/pkg/moqt/session" - "github.com/floatdrop/moq-go/pkg/moqt/wire" - "github.com/floatdrop/moq-go/pkg/relay" ) -// fetchAndDrain issues a standalone FETCH and reads the response stream -// to FIN. It returns the FETCH_OK plus the decoded absolute (group, -// object) tuples and their payloads in arrival order. The decoded -// values reverse §11.4.4's delta encoding so test assertions can -// compare against the publisher's absolute IDs directly. -// -// orderHint is the GroupOrder the test expects the relay to use; the -// delta-reversal must agree with it (§11.4.4.1). -func fetchAndDrain( - t *testing.T, - sess *session.Session, - ns wire.TrackNamespace, - name []byte, - start, endIncl message.Location, - order message.GroupOrder, - extra ...message.Parameter, -) (*message.FetchOK, []decodedFetchObject) { - t.Helper() - - params := message.Parameters{message.GroupOrderParam(order), fetchRangeFilter(start, endIncl)} - params = append(params, extra...) - - reqStream, err := sess.Fetch(t.Context(), &message.Fetch{ - Namespace: ns, - Name: name, - Parameters: params, - }) - if err != nil { - t.Fatalf("Fetch: %v", err) - } - t.Cleanup(func() { reqStream.Close() }) - - ds, err := sess.AcceptDataStream(t.Context()) - if err != nil { - t.Fatalf("AcceptDataStream: %v", err) - } - fs, isFetch := ds.(*session.IncomingFetchStream) - if !isFetch { - t.Fatalf("got %T, want *IncomingFetchStream", ds) - } - - objs := decodeFetchStream(t, fs, order) - return reqStream.OK, objs -} - -type decodedFetchObject struct { - group, object uint64 - payload []byte -} - -// decodeFetchStream reverses the §11.4.4 delta encoding and returns -// every object until EOF. -func decodeFetchStream(t *testing.T, fs *session.IncomingFetchStream, order message.GroupOrder) []decodedFetchObject { - t.Helper() - var ( - out []decodedFetchObject - prevGroup uint64 - prevObject uint64 - havePrev bool - descending = order == message.GroupOrderDescending - ) - for { - fo, err := fs.ReadObject() - if err != nil { - if errors.Is(err, io.EOF) { - return out - } - t.Fatalf("ReadObject: %v", err) - } - - var g, o uint64 - switch { - case !havePrev: - // First object: GroupIDDelta and ObjectIDDelta carry - // absolute values (§11.4.4.1). - g = fo.GroupIDDelta - o = fo.ObjectIDDelta - case fo.SerializationFlags&message.FetchFlagGroupIDDelta != 0: - if descending { - g = prevGroup - fo.GroupIDDelta - 1 - } else { - g = prevGroup + fo.GroupIDDelta + 1 - } - o = fo.ObjectIDDelta - default: - g = prevGroup - if fo.SerializationFlags&message.FetchFlagObjectIDDelta != 0 { - o = prevObject + fo.ObjectIDDelta // §11.4.4.1: no +1 - } else { - o = prevObject + 1 - } - } - - out = append(out, decodedFetchObject{ - group: g, - object: o, - payload: fo.ObjectPayload, - }) - prevGroup = g - prevObject = o - havePrev = true - } -} - -// publishObjects emits one subgroup with objects at IDs 0..n-1 on -// the given (group, subgroup). The relay's fanout caches them as a -// side effect. -func publishObjects( - t *testing.T, - pubSess *session.Session, - trackAlias, group uint64, - count int, -) { - t.Helper() - sg, err := pubSess.OpenSubgroup(message.SubgroupHeader{ - SubgroupIDMode: message.SubgroupIDExplicit, - TrackAlias: trackAlias, - GroupID: group, - SubgroupID: 0, - }) - if err != nil { - t.Fatalf("OpenSubgroup g=%d: %v", group, err) - } - for i := range count { - if err := sg.WriteObject(&message.SubgroupObject{ - ObjectIDDelta: 0, - Payload: []byte{byte('A' + i)}, - }); err != nil { - t.Fatalf("WriteObject g=%d #%d: %v", group, i, err) - } - } - if err := sg.Close(); err != nil { - t.Fatalf("sg.Close g=%d: %v", group, err) - } -} - -// publishAndCache sets up the publisher session, drains the -// subscriber so the fanout doesn't deadlock on OpenSubgroup, and -// returns the subscriber session ready for FETCH. -func publishAndCache(t *testing.T) (*session.Session, *session.Session, uint64) { - t.Helper() - pubSess, teardown := connectRelay(t, relay.Config{}) - t.Cleanup(teardown) - - const publisherAlias = uint64(7) - pubReq, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("video")}, - Name: []byte("cam1"), - TrackAlias: publisherAlias, - }) - if err != nil { - t.Fatalf("Publish: %v", err) - } - t.Cleanup(func() { pubReq.Close() }) - - // A subscriber must exist before the fanout will accept inbound - // objects (otherwise drainInbound throws them away). - subSess := dialAnotherClient(t, pubSess) - subReq, err := subSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, - Name: []byte("cam1"), - }) - if err != nil { - t.Fatalf("Subscribe: %v", err) - } - t.Cleanup(func() { subReq.Close() }) - go drainAllStreams(t.Context(), subSess) - - return pubSess, subSess, publisherAlias -} - // TestFetch_DatagramObjectRoundTrips is the regression test for the §11.4.4.1 // Datagram bit (0x40): an object published as an OBJECT_DATAGRAM and served // from the relay cache via FETCH must arrive with its payload intact and the @@ -225,7 +51,7 @@ func TestFetch_DatagramObjectRoundTrips(t *testing.T) { fetchSess := dialAnotherClient(t, pubSess) reqStream, err := fetchSess.Fetch(t.Context(), &message.Fetch{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), Parameters: message.Parameters{ fetchRangeFilter(message.Location{Group: 3, Object: 5}, message.Location{Group: 3, Object: 5}), @@ -305,7 +131,7 @@ func TestFetch_StatusMarkersNotServed(t *testing.T) { fetchSess := dialAnotherClient(t, pubSess) _, objs := fetchAndDrain(t, fetchSess, - wire.TrackNamespace{[]byte("video")}, + ns("video"), []byte("cam1"), message.Location{Group: 0, Object: 0}, message.Location{Group: 0, Object: 3}, // inclusive: covers 0..3 incl. the marker @@ -343,7 +169,7 @@ func TestFetch_WholeGroupEndForm(t *testing.T) { fetchSess := dialAnotherClient(t, pubSess) ok, objs := fetchAndDrain(t, fetchSess, - wire.TrackNamespace{[]byte("video")}, + ns("video"), []byte("cam1"), message.Location{Group: 0, Object: 1}, message.Location{Group: 0, Object: math.MaxUint64}, // the rest of group 0 @@ -386,7 +212,7 @@ func TestFetch_FromCacheAscending(t *testing.T) { fetchSess := dialAnotherClient(t, pubSess) ok, objs := fetchAndDrain(t, fetchSess, - wire.TrackNamespace{[]byte("video")}, + ns("video"), []byte("cam1"), message.Location{Group: 0, Object: 0}, message.Location{Group: 1, Object: 1}, // inclusive: covers {1,0} and {1,1} @@ -424,7 +250,7 @@ func TestFetch_FromCacheDescending(t *testing.T) { fetchSess := dialAnotherClient(t, pubSess) _, objs := fetchAndDrain(t, fetchSess, - wire.TrackNamespace{[]byte("video")}, + ns("video"), []byte("cam1"), message.Location{Group: 0, Object: 0}, message.Location{Group: 2, Object: 1}, // inclusive: covers groups 0..2 @@ -456,7 +282,7 @@ func TestFetch_RejectsStartBeyondLargest(t *testing.T) { fetchSess := dialAnotherClient(t, pubSess) _, err := fetchSess.Fetch(t.Context(), &message.Fetch{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), Parameters: message.Parameters{ fetchRangeFilter( @@ -478,7 +304,7 @@ func TestFetch_RejectsEmptyTrack(t *testing.T) { fetchSess := dialAnotherClient(t, pubSess) _, err := fetchSess.Fetch(t.Context(), &message.Fetch{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), Parameters: message.Parameters{ fetchRangeFilter(message.Location{}, message.Location{Group: 1, Object: math.MaxUint64}), @@ -505,7 +331,7 @@ func TestSubscribe_FillCurrentGroup(t *testing.T) { subSess := dialAnotherClient(t, pubSess) subMsg := &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), Parameters: message.Parameters{ message.NextObjectFilter(), @@ -580,7 +406,7 @@ func TestSubscribe_FillWholeTrack(t *testing.T) { subSess := dialAnotherClient(t, pubSess) subMsg := &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), Parameters: message.Parameters{ message.NextObjectFilter(), @@ -628,7 +454,7 @@ func TestSubscribe_FillInheritsSubscriptionFilter(t *testing.T) { subSess := dialAnotherClient(t, pubSess) subStream, err := subSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), Parameters: message.Parameters{ message.NextObjectFilter(), @@ -671,7 +497,7 @@ func TestSubscribe_RequestUpdateOpensSecondFill(t *testing.T) { subSess := dialAnotherClient(t, pubSess) subMsg := &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), Parameters: message.Parameters{ message.NextObjectFilter(), @@ -721,7 +547,7 @@ func TestSubscribe_FillNotOpenedWhileForwardPaused(t *testing.T) { subSess := dialAnotherClient(t, pubSess) subStream, err := subSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), Parameters: message.Parameters{ message.ForwardParam(false), @@ -772,19 +598,6 @@ func acceptFillStream(t *testing.T, sess *session.Session) uint64 { return fs.Header.RequestID } -// tryAcceptDataStream waits up to d for a data stream, reporting whether one -// arrived. Used by tests whose expected outcome is that none does. -func tryAcceptDataStream(t *testing.T, sess *session.Session, d time.Duration) (session.DataStream, bool) { - t.Helper() - ctx, cancel := context.WithTimeout(t.Context(), d) - defer cancel() - ds, err := sess.AcceptDataStream(ctx) - if err != nil { - return nil, false - } - return ds, true -} - // TestSubscribe_NoFillParametersOpensNoStream pins the other half of §10.2.15: // "a subscription with no FILL_PARAMETERS opens none". Presence of the // parameter is the whole request signal, so a plain SUBSCRIBE must not produce @@ -799,7 +612,7 @@ func TestSubscribe_NoFillParametersOpensNoStream(t *testing.T) { subSess := dialAnotherClient(t, pubSess) subStream, err := subSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), Parameters: message.Parameters{message.NextObjectFilter()}, }) @@ -835,7 +648,7 @@ func TestFetch_PartialRangeCarriesPriority(t *testing.T) { fetchSess := dialAnotherClient(t, pubSess) _, objs := fetchAndDrain(t, fetchSess, - wire.TrackNamespace{[]byte("video")}, + ns("video"), []byte("cam1"), message.Location{Group: 7, Object: 0}, message.Location{Group: 7, Object: 1}, @@ -860,7 +673,7 @@ func TestFetch_OKEndLocationCappedToWatermark(t *testing.T) { fetchSess := dialAnotherClient(t, pubSess) ok, _ := fetchAndDrain(t, fetchSess, - wire.TrackNamespace{[]byte("video")}, + ns("video"), []byte("cam1"), message.Location{Group: 0, Object: 0}, message.Location{Group: 999, Object: math.MaxUint64}, // far past the watermark @@ -872,43 +685,6 @@ func TestFetch_OKEndLocationCappedToWatermark(t *testing.T) { } } -// waitRelayLargest polls TRACK_STATUS until the relay reports the given Largest -// Location, so a test can depend on the fanout having observed objects without -// sleeping for a duration that is either flaky or slow. TRACK_STATUS_OK carries -// LARGEST_OBJECT (§10.2.17), which makes the relay's watermark observable over -// the protocol itself. -func waitRelayLargest( - t *testing.T, - sess *session.Session, - ns wire.TrackNamespace, - name []byte, - wantGroup, wantObject uint64, -) { - t.Helper() - deadline := time.Now().Add(10 * time.Second) - var last string - for { - req, err := sess.TrackStatus(t.Context(), &message.TrackStatus{ - Namespace: ns, - Name: name, - }) - if err == nil { - p, ok := req.OK.Parameters.Find(message.ParamLargestObject) - _ = req.Close() - if ok && p.Group == wantGroup && p.Object == wantObject { - return - } - last = fmt.Sprintf("largest={%d,%d} present=%t", p.Group, p.Object, ok) - } else { - last = err.Error() - } - if time.Now().After(deadline) { - t.Fatalf("relay never reported largest {%d,%d}: %s", wantGroup, wantObject, last) - } - time.Sleep(10 * time.Millisecond) - } -} - // TestSubscribe_FillOpensNoStreamOnEmptyTrack pins §5.1.3's "If the fill range // is empty, or starts after Largest Object, the publisher does not open a fill // fetch stream." @@ -925,7 +701,7 @@ func TestSubscribe_FillOpensNoStreamOnEmptyTrack(t *testing.T) { subSess := dialAnotherClient(t, pubSess) subStream, err := subSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), Parameters: message.Parameters{ message.NextObjectFilter(), @@ -965,11 +741,11 @@ func TestSubscribe_FillRelativeStartClampsAtOrigin(t *testing.T) { // One group only, so any relative start above 1 reaches below the origin. publishObjects(t, pubSess, publisherAlias, 0 /*group*/, 3 /*count*/) - waitRelayLargest(t, pubSess, wire.TrackNamespace{[]byte("video")}, []byte("cam1"), 0, 2) + waitRelayLargest(t, pubSess, ns("video"), []byte("cam1"), 0, 2) subSess := dialAnotherClient(t, pubSess) subMsg := &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), Parameters: message.Parameters{ message.NextObjectFilter(), @@ -1004,42 +780,6 @@ func TestSubscribe_FillRelativeStartClampsAtOrigin(t *testing.T) { } } -// fetchRangeFilter builds the §5.1.2 LOCATION_FILTER carrying a FETCH's range. -// draft-20 moved the range out of the FETCH message and made both ends -// inclusive, so these tests name the last Object they expect rather than one -// past it; an Object of MaxUint64 means "the whole end group", which is the -// three-field form with EndObject omitted. -func fetchRangeFilter(start, endIncl message.Location) message.Parameter { - if endIncl.Object == math.MaxUint64 { - return message.AbsoluteRangeFilter(start, endIncl.Group-start.Group) - } - return message.AbsoluteRangeObjectFilter(start, endIncl.Group-start.Group, endIncl.Object) -} - -// fetchRequestRange returns the inclusive [start, end] range a FETCH asks for. -// draft-20 carries it in the LOCATION_FILTER parameter (§5.1.2) rather than in -// message fields, so the fake upstreams in these tests read it back the same -// way a real publisher would. ok is false when the filter is absent or -// open-ended; the relay always sends the absolute four-field form upstream. -func fetchRequestRange(m *message.Fetch) (start, end message.Location, ok bool) { - f, err := message.LocationFilterFromParam(m.Parameters) - if err != nil || f == nil { - return start, end, false - } - end, hasEnd := f.End() - if !hasEnd { - return start, end, false - } - return message.Location{Group: f.StartGroup, Object: f.StartObject}, end, true -} - -// fetchOKEnd is [fetchRequestRange]'s end alone, for fake upstreams that just -// echo the requested end back in FETCH_OK. -func fetchOKEnd(m *message.Fetch) message.Location { - _, end, _ := fetchRequestRange(m) - return end -} - // TestFetch_ObjectIDDeltaEncoding pins the relay's FETCH encoder to // §11.4.4.1 on the wire rather than through a decoder sharing its reading: // without a Group ID Delta "the Object ID is the prior Object's ID plus the @@ -1048,8 +788,8 @@ func fetchOKEnd(m *message.Fetch) message.Location { // encode as: absolute 0, delta omitted, delta 4. func TestFetch_ObjectIDDeltaEncoding(t *testing.T) { t.Parallel() - pubSess, alias := publishWithTrackProps(t, nil) - subSess := subscribeCam1(t, pubSess) + pubSess, alias := newCam1Publisher(t, nil) + subSess := newCam1Subscriber(t, pubSess) go func() { sg, err := pubSess.OpenSubgroup(message.SubgroupHeader{ @@ -1087,7 +827,7 @@ func TestFetch_ObjectIDDeltaEncoding(t *testing.T) { fetchSess := dialAnotherClient(t, pubSess) reqStream, err := fetchSess.Fetch(t.Context(), &message.Fetch{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), Parameters: message.Parameters{ fetchRangeFilter(message.Location{Group: 3}, message.Location{Group: 3, Object: 5}), diff --git a/pkg/relay/handler_fetch_unknown_test.go b/pkg/relay/handler_fetch_unknown_test.go index f0bf1da2..fdd7b8d8 100644 --- a/pkg/relay/handler_fetch_unknown_test.go +++ b/pkg/relay/handler_fetch_unknown_test.go @@ -1,8 +1,6 @@ package relay_test import ( - "errors" - "io" "math" "testing" "time" @@ -14,13 +12,6 @@ import ( "github.com/floatdrop/moq-go/pkg/relay" ) -// fetchElem is one decoded element of a FETCH response stream: a real object -// (unknown == false) or a §11.4.4.2 End of Unknown Range marker. -type fetchElem struct { - Group, Object uint64 - Unknown bool -} - // unknownGapTopology wires the stitch-test topology (upstream publisher → // relay ← live subscriber) with a configurable upstream FETCH answer, and // returns a fetch-only downstream client. The upstream pushes single-object @@ -103,134 +94,24 @@ func unknownGapTopology( return fetchClient } -// tryFetchElems issues one standalone FETCH for [0, {lastGroup, 1}) with the -// given parameters and returns the decoded response elements, markers -// included (nil before the relay can service the request). -func tryFetchElems( - t *testing.T, - sess *session.Session, - ns wire.TrackNamespace, - name []byte, - lastGroup uint64, - params message.Parameters, -) []fetchElem { - t.Helper() - fetchReq, err := sess.Fetch(t.Context(), &message.Fetch{ - Namespace: ns, - Name: name, - Parameters: append(message.Parameters{ - fetchRangeFilter(message.Location{}, message.Location{Group: lastGroup, Object: 0}), - }, params...), - }) - if err != nil { - return nil // not yet serviceable — caller retries - } - defer fetchReq.Close() - order := message.GroupOrderAscending - if p, ok := params.Find(message.ParamGroupOrder); ok { - order = message.GroupOrder(p.Byte) - } - return collectFetchElems(t, sess, order, 3*time.Second) -} - -// collectFetchElems accepts the next FETCH response data stream and returns -// every decoded element — real objects and End-of-Range markers — in arrival -// order. -func collectFetchElems( - t *testing.T, - sess *session.Session, - order message.GroupOrder, - timeout time.Duration, -) []fetchElem { - t.Helper() - type result struct { - elems []fetchElem - err error - } - ch := make(chan result, 1) - go func() { - ds, err := sess.AcceptDataStream(t.Context()) - if err != nil { - ch <- result{err: err} - return - } - fs, ok := ds.(*session.IncomingFetchStream) - if !ok { - ch <- result{err: errors.New("not a fetch stream")} - return - } - fs.GroupOrder = order - var elems []fetchElem - for { - obj, err := fs.ReadDecoded() - if err != nil { - if !errors.Is(err, io.EOF) { - ch <- result{err: err} - return - } - ch <- result{elems: elems} - return - } - elems = append(elems, fetchElem{ - Group: obj.GroupID, - Object: obj.ObjectID, - Unknown: obj.EndOfUnknownRange, - }) - } - }() - select { - case r := <-ch: - if r.err != nil { - t.Fatalf("reading FETCH response: %v", r.err) - } - return r.elems - case <-time.After(timeout): - t.Fatal("FETCH response did not arrive within deadline") - return nil - } -} - -// realGroups extracts the group IDs of the non-marker elements. -func realGroups(elems []fetchElem) []uint64 { - var out []uint64 - for _, e := range elems { - if !e.Unknown { - out = append(out, e.Group) - } - } - return out -} - -func groupsEqual(got []uint64, wantLo, wantHi uint64) bool { - if uint64(len(got)) != wantHi-wantLo+1 { - return false - } - for i, g := range got { - if g != wantLo+uint64(i) { - return false - } - } - return true -} - // TestFetch_UnknownRangeMarkerWhenUpstreamRejects pins the §11.4.4 truthfulness // fix: when the below-floor portion of a FETCH cannot be stitched (the upstream // rejects the FETCH), the relay must not leave it as a plain gap — a gap in a // FIN-terminated response asserts non-existence — but cover it with an End of // Unknown Range marker (0x10C) preceding the cached objects. func TestFetch_UnknownRangeMarkerWhenUpstreamRejects(t *testing.T) { - ns := wire.TrackNamespace{[]byte("video")} + video := ns("video") name := []byte("cam-unknown") const liveLo, liveHi = uint64(5), uint64(9) - fc := unknownGapTopology(t, ns, name, liveLo, liveHi, + fc := unknownGapTopology(t, video, name, liveLo, liveHi, func(_ *session.Session, req *session.Request, _ *message.Fetch) { _ = req.RejectError(moqt.RequestDoesNotExist, "no FETCH here") }) deadline := time.Now().Add(5 * time.Second) for { - elems := tryFetchElems(t, fc, ns, name, liveHi, nil) + elems := tryFetchElems(t, fc, video, name, liveHi, nil) if len(elems) > 0 && elems[0].Unknown && groupsEqual(realGroups(elems), liveLo, liveHi) { // The marker covers [request start, cache floor): its Location // is the floor's predecessor. @@ -257,11 +138,11 @@ func TestFetch_UnknownRangeMarkerWhenUpstreamRejects(t *testing.T) { // the unserviceable below-floor range comes last in stream order, so the // marker must trail the cached objects, at the range's start Location. func TestFetch_UnknownRangeMarkerDescending(t *testing.T) { - ns := wire.TrackNamespace{[]byte("video")} + video := ns("video") name := []byte("cam-unknown-desc") const liveLo, liveHi = uint64(5), uint64(9) - fc := unknownGapTopology(t, ns, name, liveLo, liveHi, + fc := unknownGapTopology(t, video, name, liveLo, liveHi, func(_ *session.Session, req *session.Request, _ *message.Fetch) { _ = req.RejectError(moqt.RequestDoesNotExist, "no FETCH here") }) @@ -269,7 +150,7 @@ func TestFetch_UnknownRangeMarkerDescending(t *testing.T) { params := message.Parameters{message.GroupOrderParam(message.GroupOrderDescending)} deadline := time.Now().Add(5 * time.Second) for { - elems := tryFetchElems(t, fc, ns, name, liveHi, params) + elems := tryFetchElems(t, fc, video, name, liveHi, params) got := realGroups(elems) descOK := uint64(len(got)) == liveHi-liveLo+1 for i, g := range got { @@ -297,12 +178,12 @@ func TestFetch_UnknownRangeMarkerDescending(t *testing.T) { // its own 0x10C marker and serves the rest; the relay must re-emit that // marker to the downstream fetcher instead of flattening it into a gap. func TestFetch_PreservesUpstreamUnknownMarker(t *testing.T) { - ns := wire.TrackNamespace{[]byte("video")} + video := ns("video") name := []byte("cam-propagate") const liveLo, liveHi = uint64(5), uint64(9) const unknownHi = uint64(2) // upstream declares groups 0..2 unknown - fc := unknownGapTopology(t, ns, name, liveLo, liveHi, + fc := unknownGapTopology(t, video, name, liveLo, liveHi, func(upSess *session.Session, req *session.Request, m *message.Fetch) { _, sfEnd, sfOK := fetchRequestRange(m) if !sfOK { @@ -343,7 +224,7 @@ func TestFetch_PreservesUpstreamUnknownMarker(t *testing.T) { deadline := time.Now().Add(5 * time.Second) for { - elems := tryFetchElems(t, fc, ns, name, liveHi, nil) + elems := tryFetchElems(t, fc, video, name, liveHi, nil) if len(elems) > 0 && elems[0].Unknown && groupsEqual(realGroups(elems), unknownHi+1, liveHi) { if elems[0].Group != unknownHi || elems[0].Object != math.MaxUint64 { @@ -367,12 +248,12 @@ func TestFetch_PreservesUpstreamUnknownMarker(t *testing.T) { // it must insert an unknown marker between the stitched head and the cached // tail rather than let that gap read as non-existence. func TestFetch_UnknownMarkerWhenUpstreamCapsEndLocation(t *testing.T) { - ns := wire.TrackNamespace{[]byte("video")} + video := ns("video") name := []byte("cam-capped") const liveLo, liveHi = uint64(5), uint64(9) const upstreamHi = uint64(2) // upstream serves 0..2 and caps there - fc := unknownGapTopology(t, ns, name, liveLo, liveHi, + fc := unknownGapTopology(t, video, name, liveLo, liveHi, func(upSess *session.Session, req *session.Request, m *message.Fetch) { sfStart, _, sfOK := fetchRequestRange(m) if !sfOK { @@ -392,7 +273,7 @@ func TestFetch_UnknownMarkerWhenUpstreamCapsEndLocation(t *testing.T) { deadline := time.Now().Add(5 * time.Second) for { - elems := tryFetchElems(t, fc, ns, name, liveHi, nil) + elems := tryFetchElems(t, fc, video, name, liveHi, nil) got := realGroups(elems) if len(got) > 0 && got[0] == 0 && got[len(got)-1] == liveHi && groupsEqual(got[:upstreamHi+1], 0, upstreamHi) && @@ -425,11 +306,11 @@ func TestFetch_UnknownMarkerWhenUpstreamCapsEndLocation(t *testing.T) { // The relay falls back to declaring the whole sub-range unknown instead of // letting the rogue Location corrupt downstream Group IDs. func TestFetch_DiscardsOutOfRangeUpstreamElements(t *testing.T) { - ns := wire.TrackNamespace{[]byte("video")} + video := ns("video") name := []byte("cam-rogue") const liveLo, liveHi = uint64(5), uint64(9) - fc := unknownGapTopology(t, ns, name, liveLo, liveHi, + fc := unknownGapTopology(t, video, name, liveLo, liveHi, func(upSess *session.Session, req *session.Request, m *message.Fetch) { _, sfEnd, sfOK := fetchRequestRange(m) if !sfOK { @@ -454,7 +335,7 @@ func TestFetch_DiscardsOutOfRangeUpstreamElements(t *testing.T) { deadline := time.Now().Add(5 * time.Second) for { - elems := tryFetchElems(t, fc, ns, name, liveHi, nil) + elems := tryFetchElems(t, fc, video, name, liveHi, nil) if len(elems) > 0 && elems[0].Unknown && groupsEqual(realGroups(elems), liveLo, liveHi) { // The rogue marker must not appear; the below-floor range is // covered by the relay's own whole-sub-range marker instead. diff --git a/pkg/relay/handler_fetch_upstream_fail_test.go b/pkg/relay/handler_fetch_upstream_fail_test.go index 201b3bfb..8baad9ae 100644 --- a/pkg/relay/handler_fetch_upstream_fail_test.go +++ b/pkg/relay/handler_fetch_upstream_fail_test.go @@ -1,9 +1,7 @@ package relay_test import ( - "errors" "fmt" - "io" "sync" "testing" "time" @@ -293,11 +291,11 @@ func runStitch(t *testing.T, opts stitchOpts) []*session.DecodedFetchObject { upSess, teardown := connectRelay(t, relay.Config{}) t.Cleanup(teardown) - ns := wire.TrackNamespace{[]byte("video")} + video := ns("video") name := []byte("cam1") const upstreamAlias = uint64(42) - if _, err := upSess.PublishNamespace(t.Context(), &message.PublishNamespace{Namespace: ns}); err != nil { + if _, err := upSess.PublishNamespace(t.Context(), &message.PublishNamespace{Namespace: video}); err != nil { t.Fatalf("PublishNamespace: %v", err) } @@ -373,7 +371,7 @@ func runStitch(t *testing.T, opts stitchOpts) []*session.DecodedFetchObject { // Trigger the on-demand upstream subscription so the relay caches the tail. live := dialAnotherClient(t, upSess) - liveReq, err := live.Subscribe(t.Context(), &message.Subscribe{Namespace: ns, Name: name}) + liveReq, err := live.Subscribe(t.Context(), &message.Subscribe{Namespace: video, Name: name}) if err != nil { t.Fatalf("live Subscribe: %v", err) } @@ -391,12 +389,12 @@ func runStitch(t *testing.T, opts stitchOpts) []*session.DecodedFetchObject { // response is non-empty". probe := opts probe.extraParams = nil - objs, served := fetchStitched(t, fc, ns, name, stitchLiveHi, probe) + objs, served := fetchStitched(t, fc, video, name, stitchLiveHi, probe) if served && len(stitchedGroups(objs)) > 0 { if len(opts.extraParams) == 0 { return objs } - filtered, ok := fetchStitched(t, fc, ns, name, stitchLiveHi, opts) + filtered, ok := fetchStitched(t, fc, video, name, stitchLiveHi, opts) if !ok { t.Fatalf("the filtered stitch FETCH was not served%s", upstreamNote()) } @@ -440,46 +438,9 @@ func fetchStitched( return nil, false } defer fetchReq.Close() - - type result struct { - objs []*session.DecodedFetchObject - err error - } - ch := make(chan result, 1) - go func() { - ds, err := sess.AcceptDataStream(t.Context()) - if err != nil { - ch <- result{err: err} - return - } - fs, ok := ds.(*session.IncomingFetchStream) - if !ok { - ch <- result{err: errors.New("not a fetch stream")} - return - } - var r result - for { - obj, err := fs.ReadDecoded() - if err != nil { - if !errors.Is(err, io.EOF) { - r.err = err - } - ch <- r - return - } - r.objs = append(r.objs, obj) - } - }() - select { - case r := <-ch: - if r.err != nil { - t.Fatalf("reading FETCH response: %v", r.err) - } - return r.objs, true - case <-time.After(5 * time.Second): - t.Fatal("FETCH response did not arrive within deadline") - return nil, false - } + // Decoded ascending whatever opts.order: the descending case asserts only + // on its marker, whose IDs are absolute. + return readFetchResponse(t, sess, message.GroupOrderAscending, 5*time.Second), true } // stitchMarker names which §11.4.4.2 outcome a stitched response encoded for @@ -532,36 +493,6 @@ func stitchedGroups(objs []*session.DecodedFetchObject) []uint64 { return groups } -// openSubgroupWaiting opens a subgroup stream, waiting out a temporarily -// exhausted stream limit instead of treating it as fatal. -// -// sessiontest hands opened streams to the peer through a bounded queue and -// reports a full one as [session.ErrNoStreamCredit] — the in-process stand-in -// for a peer that has not raised MAX_STREAMS yet. The relay drains that queue -// continuously, so the condition clears on its own; a publisher pushing a -// burst of groups just has to wait, exactly as it would against a real peer. -// Treating it as terminal would end the upstream loop over a transient -// condition. -func openSubgroupWaiting( - t *testing.T, - sess *session.Session, - hdr message.SubgroupHeader, -) (*session.OutgoingSubgroupStream, error) { - t.Helper() - deadline := time.Now().After - start := time.Now() - for { - sg, err := sess.OpenSubgroup(hdr) - if !errors.Is(err, session.ErrNoStreamCredit) { - return sg, err - } - if deadline(start.Add(5 * time.Second)) { - return nil, err - } - time.Sleep(5 * time.Millisecond) - } -} - // TestFetch_RangeFilterKeepsTimedOutMarker pins that the §5.1.4 Range Filter // pass does not eat §11.4.4.2 end-of-range markers. // diff --git a/pkg/relay/handler_namespace_fault_test.go b/pkg/relay/handler_namespace_fault_test.go index 27450dbd..259ae6f2 100644 --- a/pkg/relay/handler_namespace_fault_test.go +++ b/pkg/relay/handler_namespace_fault_test.go @@ -8,7 +8,6 @@ import ( "github.com/floatdrop/moq-go/pkg/moqt/message" "github.com/floatdrop/moq-go/pkg/moqt/session/sessiontest" - "github.com/floatdrop/moq-go/pkg/moqt/wire" "github.com/floatdrop/moq-go/pkg/relay" "github.com/floatdrop/moq-go/pkg/relay/internal/relaytest" ) @@ -75,7 +74,7 @@ func TestPublishNamespace_FailedRequestOKIsNotAdvertised(t *testing.T) { defer teardown() subStream, err := subSess.SubscribeNamespace(t.Context(), &message.SubscribeNamespace{ - TrackNamespacePrefix: wire.TrackNamespace{[]byte("video")}, + TrackNamespacePrefix: ns("video"), }) if err != nil { t.Fatalf("SubscribeNamespace: %v", err) @@ -89,7 +88,7 @@ func TestPublishNamespace_FailedRequestOKIsNotAdvertised(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _, _ = faultedPub.PublishNamespace(ctx, &message.PublishNamespace{ - Namespace: wire.TrackNamespace{[]byte("video"), []byte("faulted")}, + Namespace: ns("video", "faulted"), }) }() awaitFault(t, fired) @@ -99,7 +98,7 @@ func TestPublishNamespace_FailedRequestOKIsNotAdvertised(t *testing.T) { // despite its failed OK, "faulted" would already be queued ahead of it. okPub := dialAnotherClient(t, subSess) if _, err := okPub.PublishNamespace(t.Context(), &message.PublishNamespace{ - Namespace: wire.TrackNamespace{[]byte("video"), []byte("ok")}, + Namespace: ns("video", "ok"), }); err != nil { t.Fatalf("healthy PublishNamespace: %v", err) } @@ -137,7 +136,7 @@ func TestSubscribeNamespace_FailedRequestOKLeavesNoSubscriber(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _, _ = faultedSub.SubscribeNamespace(ctx, &message.SubscribeNamespace{ - TrackNamespacePrefix: wire.TrackNamespace{[]byte("video")}, + TrackNamespacePrefix: ns("video"), }) }() awaitFault(t, fired) @@ -147,7 +146,7 @@ func TestSubscribeNamespace_FailedRequestOKLeavesNoSubscriber(t *testing.T) { // subscriber have run to completion. goodSub := dialAnotherClient(t, faultedSub) goodStream, err := goodSub.SubscribeNamespace(t.Context(), &message.SubscribeNamespace{ - TrackNamespacePrefix: wire.TrackNamespace{[]byte("video")}, + TrackNamespacePrefix: ns("video"), }) if err != nil { t.Fatalf("healthy SubscribeNamespace: %v", err) @@ -156,7 +155,7 @@ func TestSubscribeNamespace_FailedRequestOKLeavesNoSubscriber(t *testing.T) { pub := dialAnotherClient(t, faultedSub) pubStream, err := pub.PublishNamespace(t.Context(), &message.PublishNamespace{ - Namespace: wire.TrackNamespace{[]byte("video"), []byte("cam1")}, + Namespace: ns("video", "cam1"), }) if err != nil { t.Fatalf("PublishNamespace: %v", err) @@ -180,13 +179,3 @@ func TestSubscribeNamespace_FailedRequestOKLeavesNoSubscriber(t *testing.T) { "(only the REQUEST_OK) — it was registered despite the failed ack", n) } } - -func isNamespace(m message.Message) bool { - _, ok := m.(*message.Namespace) - return ok -} - -func isNamespaceDone(m message.Message) bool { - _, ok := m.(*message.NamespaceDone) - return ok -} diff --git a/pkg/relay/handler_publish_alias_window_test.go b/pkg/relay/handler_publish_alias_window_test.go index de5340d5..828e3c64 100644 --- a/pkg/relay/handler_publish_alias_window_test.go +++ b/pkg/relay/handler_publish_alias_window_test.go @@ -8,7 +8,6 @@ import ( "github.com/floatdrop/moq-go/pkg/moqt" "github.com/floatdrop/moq-go/pkg/moqt/message" "github.com/floatdrop/moq-go/pkg/moqt/track" - "github.com/floatdrop/moq-go/pkg/moqt/wire" "github.com/floatdrop/moq-go/pkg/relay" ) @@ -33,7 +32,7 @@ import ( // reports the alias routable, waits for the write, then holds the window open // while the relay routes (or drops) the stream. func TestPublish_TrackEntryPrecedesAliasRouting(t *testing.T) { - ns := wire.TrackNamespace{[]byte("video")} + video := ns("video") name := []byte("cam-publish-alias-window") const alias, groupID = uint64(77), uint64(3) @@ -66,7 +65,7 @@ func TestPublish_TrackEntryPrecedesAliasRouting(t *testing.T) { pubErr := make(chan error, 1) go func() { _, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: ns, + Namespace: video, Name: name, TrackAlias: alias, }) @@ -95,7 +94,7 @@ func TestPublish_TrackEntryPrecedesAliasRouting(t *testing.T) { // If the relay reset that stream for want of an entry, it never learns a // LARGEST_OBJECT and TRACK_STATUS reports none. probe := dialAnotherClient(t, pubSess) - waitRelayLargest(t, probe, ns, name, groupID, 0) + waitRelayLargest(t, probe, video, name, groupID, 0) } // TestPublish_RejectedAliasLeavesTrackUnknown pins the other half of the entry @@ -109,7 +108,7 @@ func TestPublish_TrackEntryPrecedesAliasRouting(t *testing.T) { // deciding whether to retry, and nothing would reclaim the entry until an // unrelated session teardown swept it. func TestPublish_RejectedAliasLeavesTrackUnknown(t *testing.T) { - ns := wire.TrackNamespace{[]byte("video")} + video := ns("video") const alias = uint64(91) first := []byte("cam-alias-taken") second := []byte("cam-alias-duplicate") @@ -118,7 +117,7 @@ func TestPublish_RejectedAliasLeavesTrackUnknown(t *testing.T) { t.Cleanup(teardown) pub, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: ns, Name: first, TrackAlias: alias, + Namespace: video, Name: first, TrackAlias: alias, }) if err != nil { t.Fatalf("first PUBLISH: %v", err) @@ -128,14 +127,14 @@ func TestPublish_RejectedAliasLeavesTrackUnknown(t *testing.T) { // §11.1: the alias is taken, so this PUBLISH is rejected — after the // relay has already created the entry for `second`. if _, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: ns, Name: second, TrackAlias: alias, + Namespace: video, Name: second, TrackAlias: alias, }); err == nil { t.Fatal("duplicate Track Alias PUBLISH was accepted, want rejection") } fetcher := dialAnotherClient(t, pubSess) _, err = fetcher.Fetch(t.Context(), &message.Fetch{ - Namespace: ns, + Namespace: video, Name: second, Parameters: message.Parameters{ fetchRangeFilter(message.Location{}, message.Location{Group: 1, Object: 0}), diff --git a/pkg/relay/handler_subscribe_alias_window_test.go b/pkg/relay/handler_subscribe_alias_window_test.go index 91d9d8b6..9bcdff8f 100644 --- a/pkg/relay/handler_subscribe_alias_window_test.go +++ b/pkg/relay/handler_subscribe_alias_window_test.go @@ -8,7 +8,6 @@ import ( "github.com/floatdrop/moq-go/pkg/moqt/message" "github.com/floatdrop/moq-go/pkg/moqt/session" "github.com/floatdrop/moq-go/pkg/moqt/track" - "github.com/floatdrop/moq-go/pkg/moqt/wire" "github.com/floatdrop/moq-go/pkg/relay" ) @@ -28,7 +27,7 @@ import ( // TestFetch_UnknownRangeMarkerDescending fail on CI while passing everywhere // else: it asserts on a complete tail and the floor kept going missing. func TestSubscribeUpstream_TrackEntryPrecedesAliasRouting(t *testing.T) { - ns := wire.TrackNamespace{[]byte("video")} + video := ns("video") name := []byte("cam-subscribe-alias-window") const liveLo, liveHi = uint64(5), uint64(9) @@ -46,7 +45,7 @@ func TestSubscribeUpstream_TrackEntryPrecedesAliasRouting(t *testing.T) { // unknownGapTopology's own barrier waits for LARGEST_OBJECT {liveHi,0}, // which cannot arrive if the Groups carrying it were dropped, so a // regression fails inside the helper before reaching the assert below. - fc := unknownGapTopology(t, ns, name, liveLo, liveHi, + fc := unknownGapTopology(t, video, name, liveLo, liveHi, func(_ *session.Session, req *session.Request, _ *message.Fetch) { _ = req.RejectError(moqt.RequestDoesNotExist, "no FETCH here") }) @@ -54,7 +53,7 @@ func TestSubscribeUpstream_TrackEntryPrecedesAliasRouting(t *testing.T) { // The watermark only proves the newest Group landed; assert the whole // tail is served, which is the property a dropped floor violates. waitFor(t, 5*time.Second, func() bool { - return groupsEqual(realGroups(tryFetchElems(t, fc, ns, name, liveHi, nil)), liveLo, liveHi) + return groupsEqual(realGroups(tryFetchElems(t, fc, video, name, liveHi, nil)), liveLo, liveHi) }, "relay never served the full tail; the oldest Group was dropped in the alias window") } @@ -74,13 +73,13 @@ func TestSubscribeUpstream_TrackEntryPrecedesAliasRouting(t *testing.T) { // rather than a single probe because the first FETCH may land before the entry // exists, which is DOES_NOT_EXIST for the uninteresting reason. func TestFetch_UnconfirmedTrackIsNotKnown(t *testing.T) { - ns := wire.TrackNamespace{[]byte("video")} + video := ns("video") name := []byte("cam-never-answered") upSess, teardown := connectRelay(t, relay.Config{}) t.Cleanup(teardown) - if _, err := upSess.PublishNamespace(t.Context(), &message.PublishNamespace{Namespace: ns}); err != nil { + if _, err := upSess.PublishNamespace(t.Context(), &message.PublishNamespace{Namespace: video}); err != nil { t.Fatalf("PublishNamespace: %v", err) } // Accept the relay's SUBSCRIBE and never reply to it. @@ -95,14 +94,14 @@ func TestFetch_UnconfirmedTrackIsNotKnown(t *testing.T) { // Trigger the on-demand upstream SUBSCRIBE, which will hang. live := dialAnotherClient(t, upSess) go func() { - _, _ = live.Subscribe(t.Context(), &message.Subscribe{Namespace: ns, Name: name}) + _, _ = live.Subscribe(t.Context(), &message.Subscribe{Namespace: video, Name: name}) }() fetcher := dialAnotherClient(t, upSess) deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { _, err := fetcher.Fetch(t.Context(), &message.Fetch{ - Namespace: ns, + Namespace: video, Name: name, Parameters: message.Parameters{ fetchRangeFilter(message.Location{}, message.Location{Group: 1, Object: 0}), diff --git a/pkg/relay/harness_test.go b/pkg/relay/harness_test.go new file mode 100644 index 00000000..57f56bb6 --- /dev/null +++ b/pkg/relay/harness_test.go @@ -0,0 +1,315 @@ +package relay_test + +import ( + "context" + "net" + "runtime" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/floatdrop/moq-go/pkg/moqt" + "github.com/floatdrop/moq-go/pkg/moqt/session" + "github.com/floatdrop/moq-go/pkg/moqt/session/sessiontest" + "github.com/floatdrop/moq-go/pkg/relay" +) + +// The in-process relay harness: a [relay.Listener] over [sessiontest] pipes, +// connectRelay to start a relay with one client, and dialAnotherClient to add +// more clients to it. + +// pipeListener is an in-process [relay.Listener] backed by [sessiontest]. +// Dial queues the server-side conn for Accept and returns the client side; +// Close stops Accept with [net.ErrClosed], a clean shutdown for the relay. +type pipeListener struct { + conns chan session.Conn + done chan struct{} + + // closeDelay stalls Close, modelling a listener whose socket teardown is + // not instantaneous. Stop calls Close first, so this delays the GOAWAY + // broadcast behind it. + closeDelay time.Duration + + // faultFor, when non-nil, is consulted for each server-side conn in dial + // order from 1; a non-nil return wraps that conn in [sessiontest.Faulty], + // which reaches the relay's "write failed" branches. See [faultConn]. + faultFor func(conn int) sessiontest.FaultFunc + + dialled atomic.Int64 +} + +// faultConn builds a [pipeListener.faultFor] that faults only the nth dialled +// conn, counting from 1: connectRelay's client is 1, each dialAnotherClient +// the next. +func faultConn(n int, fault sessiontest.FaultFunc) func(int) sessiontest.FaultFunc { + return func(conn int) sessiontest.FaultFunc { + if conn == n { + return fault + } + return nil + } +} + +// newPipeListener returns an open, unfaulted pipeListener. +func newPipeListener() *pipeListener { + return &pipeListener{ + conns: make(chan session.Conn, 4), + done: make(chan struct{}), + } +} + +// Dial creates a conn pair, queues the server side for Accept, and returns +// the client side; it fails once the listener is closed. +func (l *pipeListener) Dial() (session.Conn, error) { + return l.DialWithLimits(-1, -1) +} + +// DialWithLimits is [pipeListener.Dial] with bidi-stream credit caps (negative +// is unlimited). serverBidi bounds how many PUBLISH streams the relay can open +// to a SUBSCRIBE_TRACKS subscriber, the PUBLISH_SKIPPED (§10.21) trigger. +func (l *pipeListener) DialWithLimits(clientBidi, serverBidi int) (session.Conn, error) { + clientConn, serverConn := sessiontest.NewConnPairWithLimits(clientBidi, serverBidi) + if l.faultFor != nil { + if fault := l.faultFor(int(l.dialled.Add(1))); fault != nil { + serverConn = sessiontest.Faulty(serverConn, fault) + } + } + select { + case l.conns <- serverConn: + return clientConn, nil + case <-l.done: + return nil, net.ErrClosed + } +} + +func (l *pipeListener) Accept(ctx context.Context) (session.Conn, error) { + select { + case c := <-l.conns: + return c, nil + case <-ctx.Done(): + return nil, ctx.Err() + case <-l.done: + return nil, net.ErrClosed + } +} + +func (l *pipeListener) Addr() net.Addr { return nil } + +// isClosed reports whether Close has run. +func (l *pipeListener) isClosed() bool { + select { + case <-l.done: + return true + default: + return false + } +} + +func (l *pipeListener) Close() error { + if l.closeDelay > 0 { + time.Sleep(l.closeDelay) + } + select { + case <-l.done: + default: + close(l.done) + } + return nil +} + +// connectRelay starts a relay on a fresh pipeListener with cfg, dials one +// client into it, and returns that client plus a teardown that stops the relay +// and waits for a clean shutdown. GoawayTimeout defaults to 50ms. It takes +// testing.TB to serve benchmarks too, so its contexts are cancelled via +// tb.Cleanup rather than taken from the test. +func connectRelay(tb testing.TB, cfg relay.Config) (clientSess *session.Session, teardown func()) { + tb.Helper() + return connectRelayOn(tb, cfg, newPipeListener()) +} + +// connectRelayOn is [connectRelay] on a caller-configured listener. +func connectRelayOn( + tb testing.TB, + cfg relay.Config, + l *pipeListener, +) (clientSess *session.Session, teardown func()) { + tb.Helper() + if cfg.GoawayTimeout == 0 { + cfg.GoawayTimeout = 50 * time.Millisecond + } + ctx, cancel := context.WithCancel(context.Background()) + tb.Cleanup(cancel) + r := relay.New(l, cfg) + startErr := make(chan error, 1) + go func() { startErr <- r.Start(ctx) }() + + clientConn, err := l.Dial() + if err != nil { + tb.Fatalf("Dial: %v", err) + } + sess, err := session.Client(ctx, clientConn) + if err != nil { + tb.Fatalf("session.Client: %v", err) + } + + pipeListenerMu.Lock() + pipeListenerOf[sess] = l + pipeListenerMu.Unlock() + + // Every client of this relay is tracked so teardown can close them: + // Relay.Stop waits on relay-side handlers that block reading client + // streams the test never closed, and under -count=N those leak. + clientsForRelay := newClientSessionTracker() + clientsForRelay.add(sess) + pipeListenerClientsMu.Lock() + pipeListenerClients[sess] = clientsForRelay + pipeListenerClientsMu.Unlock() + + return sess, func() { + pipeListenerMu.Lock() + delete(pipeListenerOf, sess) + pipeListenerMu.Unlock() + pipeListenerClientsMu.Lock() + delete(pipeListenerClients, sess) + pipeListenerClientsMu.Unlock() + + // Stop the relay first: GOAWAY-migration tests expect the broadcast + // to reach clients that are still alive. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + stopDone := make(chan error, 1) + go func() { stopDone <- r.Stop(ctx) }() + + // Give clients a brief window to close cooperatively, then + // force-close them so wedged handlers see EOF. Only this goroutine + // receives from stopDone: a second receiver once raced for the + // single value and hung teardown. + const cooperativeWindow = 250 * time.Millisecond + select { + case <-stopDone: + case <-time.After(cooperativeWindow): + clientsForRelay.closeAll() + // Bound the wait so a deadlock fails fast with a goroutine + // dump instead of hitting the package timeout. + select { + case <-stopDone: + case <-time.After(8 * time.Second): + dumpGoroutines(tb, "relay teardown: Relay.Stop did not return "+ + "within 8s after closing all clients (wedged session handler?)") + return + } + } + clientsForRelay.closeAll() // idempotent final sweep + + select { + case err := <-startErr: + if err != nil { + tb.Errorf("Start returned: %v", err) + } + case <-time.After(time.Second): + tb.Error("Start did not return after Stop") + } + } +} + +// pipeListenerOf maps a relay's first client session to its listener, so +// dialAnotherClient can reach the same relay. +var ( + pipeListenerMu sync.Mutex + pipeListenerOf = make(map[*session.Session]*pipeListener) +) + +// pipeListenerClients maps a relay's first client session to the tracker of +// every client dialled into that relay, for teardown. +var ( + pipeListenerClientsMu sync.Mutex + pipeListenerClients = make(map[*session.Session]*clientSessionTracker) +) + +// clientSessionTracker collects the client sessions of one relay. +type clientSessionTracker struct { + mu sync.Mutex + sessions []*session.Session +} + +func newClientSessionTracker() *clientSessionTracker { + return &clientSessionTracker{} +} + +func (t *clientSessionTracker) add(s *session.Session) { + t.mu.Lock() + t.sessions = append(t.sessions, s) + t.mu.Unlock() +} + +func (t *clientSessionTracker) closeAll() { + t.mu.Lock() + sessions := append([]*session.Session(nil), t.sessions...) + t.sessions = nil + t.mu.Unlock() + for _, s := range sessions { + _ = s.Close(moqt.SessionNoError, "test teardown") + } +} + +// dumpGoroutines fails the test with msg and a full goroutine stack dump. +func dumpGoroutines(tb testing.TB, msg string) { + tb.Helper() + buf := make([]byte, 1<<20) + n := runtime.Stack(buf, true) + tb.Errorf("%s; goroutine dump follows:\n%s", msg, buf[:n]) +} + +// dialAnotherClient dials a new client into the relay existing is connected +// to; connectRelay's teardown closes it. +func dialAnotherClient(tb testing.TB, existing *session.Session) *session.Session { + tb.Helper() + return dialAnotherClientWithLimits(tb, existing, -1, -1) +} + +// dialAnotherClientWithLimits is [dialAnotherClient] with bidi-stream credit +// caps on the new connection; see [pipeListener.DialWithLimits]. +func dialAnotherClientWithLimits( + tb testing.TB, + existing *session.Session, + clientBidi, serverBidi int, +) *session.Session { + tb.Helper() + pipeListenerMu.Lock() + l, ok := pipeListenerOf[existing] + pipeListenerMu.Unlock() + if !ok { + tb.Fatal("dialAnotherClient: no pipeListener registered for the existing session; was connectRelay used?") + } + conn, err := l.DialWithLimits(clientBidi, serverBidi) + if err != nil { + tb.Fatalf("listener.DialWithLimits: %v", err) + } + sess, err := session.Client(context.Background(), conn) + if err != nil { + tb.Fatalf("session.Client: %v", err) + } + pipeListenerClientsMu.Lock() + if tracker, ok := pipeListenerClients[existing]; ok { + tracker.add(sess) + } + pipeListenerClientsMu.Unlock() + return sess +} + +// dialRaw dials a client into the relay behind l and also returns its conn, so +// a test can open streams the session API would never produce. +func dialRaw(t *testing.T, l *pipeListener) (*session.Session, session.Conn) { + t.Helper() + conn, err := l.Dial() + if err != nil { + t.Fatalf("Dial: %v", err) + } + sess, err := session.Client(t.Context(), conn) + if err != nil { + t.Fatalf("session.Client: %v", err) + } + t.Cleanup(func() { _ = sess.Close(moqt.SessionNoError, "") }) + return sess, conn +} diff --git a/pkg/relay/helpers_test.go b/pkg/relay/helpers_test.go new file mode 100644 index 00000000..ff653cec --- /dev/null +++ b/pkg/relay/helpers_test.go @@ -0,0 +1,888 @@ +package relay_test + +import ( + "context" + "errors" + "fmt" + "io" + "math" + "testing" + "time" + + "github.com/floatdrop/moq-go/pkg/moqt" + "github.com/floatdrop/moq-go/pkg/moqt/message" + "github.com/floatdrop/moq-go/pkg/moqt/session" + "github.com/floatdrop/moq-go/pkg/moqt/wire" + "github.com/floatdrop/moq-go/pkg/relay" +) + +// Shared helpers for the relay_test package. The relay itself is started by +// connectRelay (harness_test.go); these drive clients of it. + +// ns builds a Track Namespace from its fields. +func ns(fields ...string) wire.TrackNamespace { + out := make(wire.TrackNamespace, len(fields)) + for i, f := range fields { + out[i] = []byte(f) + } + return out +} + +// trackProp is one integer Track Property, for PUBLISH or SUBSCRIBE_OK. +func trackProp(typ, v uint64) []wire.KVPair { + return []wire.KVPair{{Type: typ, IntVal: v}} +} + +// dynamicGroupsProperties is Track Properties carrying DYNAMIC_GROUPS = value. +func dynamicGroupsProperties(value uint64) []byte { + return message.AppendTrackProperties(trackProp(message.PropertyDynamicGroups, value)) +} + +// Publishing. + +// publishVideoTrack PUBLISHes video/ on alias with the given Message +// Parameters; the publication closes at cleanup. +func publishVideoTrack( + t *testing.T, + sess *session.Session, + name string, + alias uint64, + params ...message.Parameter, +) *session.Publication { + t.Helper() + return publish(t, sess, &message.Publish{ + Namespace: ns("video"), Name: []byte(name), TrackAlias: alias, Parameters: params, + }) +} + +// publishVideoTrackProps is [publishVideoTrack] with Track Properties. +func publishVideoTrackProps( + t *testing.T, + sess *session.Session, + name string, + alias uint64, + props []wire.KVPair, +) *session.Publication { + t.Helper() + return publish(t, sess, &message.Publish{ + Namespace: ns("video"), Name: []byte(name), TrackAlias: alias, + TrackProperties: message.AppendTrackProperties(props), + }) +} + +// publish sends m from sess, failing the test on error; the publication closes +// at cleanup. +func publish(t *testing.T, sess *session.Session, m *message.Publish) *session.Publication { + t.Helper() + p, err := sess.Publish(t.Context(), m) + if err != nil { + t.Fatalf("Publish %s: %v", m.Name, err) + } + t.Cleanup(func() { _ = p.Close() }) + return p +} + +// newCam1Publisher starts a relay and PUBLISHes video/cam1 on it with the given +// Track Properties, returning the publisher session and its Track Alias. +func newCam1Publisher(t *testing.T, props []wire.KVPair) (*session.Session, uint64) { + t.Helper() + pubSess, teardown := connectRelay(t, relay.Config{}) + t.Cleanup(teardown) + const alias = uint64(7) + publishVideoTrackProps(t, pubSess, "cam1", alias, props) + return pubSess, alias +} + +// publishAndCache is [newCam1Publisher] plus a drained live subscriber, so the +// relay forwards and caches what is published. It returns the publisher, the +// subscriber and the publisher's Track Alias. +func publishAndCache(t *testing.T) (*session.Session, *session.Session, uint64) { + t.Helper() + pubSess, alias := newCam1Publisher(t, nil) + subSess := dialAnotherClient(t, pubSess) + subscribeCam1(t, subSess) + go drainAll(t.Context(), subSess) + return pubSess, subSess, alias +} + +// Objects. + +// writeSubgroup writes n Objects (payloads "A", "B", ...) on a new subgroup +// stream with hdr, then FINs it. +func writeSubgroup(sess *session.Session, hdr message.SubgroupHeader, n int) error { + sg, err := sess.OpenSubgroup(hdr) + if err != nil { + return fmt.Errorf("OpenSubgroup g=%d: %w", hdr.GroupID, err) + } + for i := range n { + if err := sg.WriteObject(&message.SubgroupObject{Payload: []byte{byte('A' + i)}}); err != nil { + return fmt.Errorf("WriteObject g=%d #%d: %w", hdr.GroupID, i, err) + } + } + if err := sg.Close(); err != nil { + return fmt.Errorf("Close g=%d: %w", hdr.GroupID, err) + } + return nil +} + +// subgroupHeader is the header of subgroup 0 of group on alias, inheriting the +// publisher priority. +func subgroupHeader(alias, group uint64) message.SubgroupHeader { + return message.SubgroupHeader{SubgroupIDMode: message.SubgroupIDExplicit, TrackAlias: alias, GroupID: group} +} + +// publishObjects writes Objects 0..n-1 of group on alias as one subgroup, +// failing the test on error. +func publishObjects(t *testing.T, sess *session.Session, alias, group uint64, n int) { + t.Helper() + if err := writeSubgroup(sess, subgroupHeader(alias, group), n); err != nil { + t.Fatal(err) + } +} + +// sendObjects is [publishObjects] for use off the test goroutine, where the +// synchronous in-process transport blocks until the relay reads; errors are +// dropped. +func sendObjects(sess *session.Session, alias, group uint64, n int) { + _ = writeSubgroup(sess, subgroupHeader(alias, group), n) +} + +// publishSubgroupWith writes objects Objects to group of pub's track on one +// subgroup stream from a goroutine, calling between(i) before Object i, then +// FINs it. +func publishSubgroupWith(t *testing.T, pub *session.Publication, group uint64, objects int, between func(i int)) { + t.Helper() + sg, err := pub.OpenSubgroup(message.SubgroupHeader{SubgroupIDMode: message.SubgroupIDExplicit, GroupID: group}) + if err != nil { + t.Fatalf("OpenSubgroup: %v", err) + } + go func() { + for i := range objects { + if between != nil { + between(i) + } + if sg.WriteObject(&message.SubgroupObject{Payload: []byte("x")}) != nil { + return + } + } + _ = sg.Close() + }() +} + +// openSubgroupWaiting opens a subgroup stream, waiting up to 5s out +// [session.ErrNoStreamCredit]: sessiontest's bounded stream queue reports a +// full queue that way, and the relay drains it on its own. +func openSubgroupWaiting( + t *testing.T, + sess *session.Session, + hdr message.SubgroupHeader, +) (*session.OutgoingSubgroupStream, error) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for { + sg, err := sess.OpenSubgroup(hdr) + if !errors.Is(err, session.ErrNoStreamCredit) { + return sg, err + } + if time.Now().After(deadline) { + return nil, err + } + time.Sleep(5 * time.Millisecond) + } +} + +// Subscribing. + +// subscribeCam1 SUBSCRIBEs sess to video/cam1; the subscription closes at +// cleanup. +func subscribeCam1(t *testing.T, sess *session.Session, params ...message.Parameter) *session.Subscription { + t.Helper() + sub, err := sess.Subscribe(t.Context(), &message.Subscribe{ + Namespace: ns("video"), Name: []byte("cam1"), Parameters: params, + }) + if err != nil { + t.Fatalf("Subscribe: %v", err) + } + t.Cleanup(func() { _ = sub.Close() }) + return sub +} + +// newCam1Subscriber dials a new client into via's relay and SUBSCRIBEs it to +// video/cam1. +func newCam1Subscriber(t *testing.T, via *session.Session, params ...message.Parameter) *session.Session { + t.Helper() + sess := dialAnotherClient(t, via) + subscribeCam1(t, sess, params...) + return sess +} + +// subscribeTracks sends SUBSCRIBE_TRACKS for prefix and returns its request +// stream; it closes at cleanup. +func subscribeTracks( + t *testing.T, + sess *session.Session, + prefix wire.TrackNamespace, + params ...message.Parameter, +) session.Stream { + t.Helper() + ts, err := sess.SubscribeTracks(t.Context(), &message.SubscribeTracks{ + TrackNamespacePrefix: prefix, Parameters: params, + }) + if err != nil { + t.Fatalf("SubscribeTracks: %v", err) + } + t.Cleanup(func() { _ = ts.Close() }) + return ts.Stream +} + +// forwardedPublishes delivers the PUBLISHes the relay forwards to sess. +func forwardedPublishes(t *testing.T, sess *session.Session) <-chan *session.Request { + t.Helper() + out := make(chan *session.Request, 4) + go func() { + for { + r, err := sess.AcceptRequest(t.Context()) + if err != nil { + return + } + if _, ok := r.First.(*message.Publish); ok { + out <- r + } + } + }() + return out +} + +// awaitForwarded returns the next forwarded PUBLISH, failing after 2s. +func awaitForwarded(t *testing.T, reqs <-chan *session.Request) *session.Request { + t.Helper() + select { + case r := <-reqs: + return r + case <-time.After(2 * time.Second): + t.Fatal("no PUBLISH forwarded to the SUBSCRIBE_TRACKS holder") + } + return nil +} + +// requireNoForward fails if a PUBLISH is forwarded within 300ms. +func requireNoForward(t *testing.T, reqs <-chan *session.Request, what string) { + t.Helper() + select { + case r := <-reqs: + p := r.First.(*message.Publish) + t.Fatalf("%s: forwarded PUBLISH for %s", what, p.Name) + case <-time.After(300 * time.Millisecond): + } +} + +// acceptForwarded replies PUBLISH_OK to a forwarded PUBLISH, failing rather +// than hanging if the relay never reads the reply. +func acceptForwarded(t *testing.T, r *session.Request) *session.IncomingPublication { + t.Helper() + type result struct { + in *session.IncomingPublication + err error + } + done := make(chan result, 1) + go func() { + in, err := r.AcceptPublish() + done <- result{in, err} + }() + select { + case res := <-done: + if res.err != nil { + t.Fatalf("AcceptPublish: %v", res.err) + } + return res.in + case <-time.After(2 * time.Second): + t.Fatal("the relay never read the PUBLISH_OK") + } + return nil +} + +// Receiving. + +// awaitSubgroupObject reports whether the next data stream sess accepts within +// the deadline is a subgroup stream carrying an Object. +func awaitSubgroupObject(t *testing.T, sess *session.Session, within time.Duration) bool { + t.Helper() + got := make(chan bool, 1) + go func() { + ds, err := sess.AcceptDataStream(t.Context()) + if err != nil { + got <- false + return + } + sg, ok := ds.(*session.IncomingSubgroupStream) + if !ok { + got <- false + return + } + _, err = sg.ReadObject() + got <- err == nil + }() + select { + case ok := <-got: + return ok + case <-time.After(within): + return false + } +} + +// awaitObjectOn reads the first Object of a subgroup stream on alias, skipping +// streams for other aliases and failing after 2s. +func awaitObjectOn(t *testing.T, sess *session.Session, alias uint64) { + t.Helper() + ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) + defer cancel() + for { + ds, err := sess.AcceptDataStream(ctx) + if err != nil { + t.Fatalf("no Object delivered on alias %d: %v", alias, err) + } + sg, ok := ds.(*session.IncomingSubgroupStream) + if !ok || sg.Header.TrackAlias != alias { + continue + } + if _, err := sg.ReadObject(); err != nil { + t.Fatalf("ReadObject: %v", err) + } + return + } +} + +// tryAcceptDataStream waits up to d for a data stream, reporting whether one +// arrived. +func tryAcceptDataStream(t *testing.T, sess *session.Session, d time.Duration) (session.DataStream, bool) { + t.Helper() + ctx, cancel := context.WithTimeout(t.Context(), d) + defer cancel() + ds, err := sess.AcceptDataStream(ctx) + if err != nil { + return nil, false + } + return ds, true +} + +// drainAll reads and discards every data stream on sess until ctx ends, so the +// relay never blocks on an unread subscriber. +func drainAll(ctx context.Context, sess *session.Session) { + for { + ds, err := sess.AcceptDataStream(ctx) + if err != nil { + return + } + switch s := ds.(type) { + case *session.IncomingSubgroupStream: + for { + if _, err := s.ReadObject(); err != nil { + break + } + } + case *session.IncomingFetchStream: + for { + if _, err := s.ReadObject(); err != nil { + break + } + } + } + } +} + +// streamMessages delivers the control messages read from stream until it +// ends, then closes the channel. +func streamMessages(t *testing.T, stream session.Stream) <-chan message.Message { + t.Helper() + out := make(chan message.Message, 16) + go func() { + defer close(out) + for { + m, err := message.Parse(stream) + if err != nil { + return + } + out <- m + } + }() + return out +} + +// nextMessage returns the next message from msgs, failing after 2s or if the +// stream ended. +func nextMessage(t *testing.T, msgs <-chan message.Message) message.Message { + t.Helper() + select { + case m, ok := <-msgs: + if !ok { + t.Fatal("stream ended") + } + return m + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting for a message") + } + return nil +} + +// isNamespace reports whether m is a NAMESPACE. +func isNamespace(m message.Message) bool { + _, ok := m.(*message.Namespace) + return ok +} + +// isNamespaceDone reports whether m is a NAMESPACE_DONE. +func isNamespaceDone(m message.Message) bool { + _, ok := m.(*message.NamespaceDone) + return ok +} + +// awaitPublishDone reads the next message on a subscription's request stream +// and requires it to be PUBLISH_DONE within 2s. +func awaitPublishDone(t *testing.T, sub *session.Subscription) *message.PublishDone { + t.Helper() + got := make(chan message.Message, 1) + go func() { + msg, _ := message.Parse(sub) + got <- msg + }() + select { + case msg := <-got: + pd, ok := msg.(*message.PublishDone) + if !ok { + t.Fatalf("got %T on the subscription stream, want *message.PublishDone", msg) + } + return pd + case <-time.After(2 * time.Second): + t.Fatal("no PUBLISH_DONE on the subscription stream") + return nil + } +} + +// watchUpstreamNewGroup answers each REQUEST_UPDATE on a publisher's request +// stream with REQUEST_OK and delivers the NEW_GROUP_REQUEST values it carries. +func watchUpstreamNewGroup(t *testing.T, pubStream session.Stream) <-chan uint64 { + t.Helper() + got := make(chan uint64, 1) + go func() { + for { + m, err := message.Parse(pubStream) + if err != nil { + return + } + upd, ok := m.(*message.RequestUpdate) + if !ok { + continue + } + _ = message.Marshal(pubStream, &message.RequestOK{}) + if v, ok := newGroupReqValue(upd.Parameters); ok { + select { + case got <- v: + default: + } + } + } + }() + return got +} + +// Assertions. + +// requireRejectedWithCode asserts that err is a REQUEST_ERROR with code want. +func requireRejectedWithCode(t *testing.T, err error, want moqt.RequestErrorCode) { + t.Helper() + rejected, ok := errors.AsType[*session.RequestRejectedError](err) + if !ok { + t.Fatalf("want *RequestRejectedError, got %T: %v", err, err) + } + if rejected.Code != want { + t.Fatalf("rejected code = %#x, want %#x; reason=%q", uint64(rejected.Code), uint64(want), rejected.Reason) + } +} + +// requireSessionClosed fails unless sess closes within 2s. +func requireSessionClosed(t *testing.T, sess *session.Session, what string) { + t.Helper() + select { + case <-sess.Done(): + case <-time.After(2 * time.Second): + t.Fatalf("relay left the session open after %s", what) + } +} + +// waitFor polls cond every 10ms for up to d, failing with msg if it never +// holds. +func waitFor(t *testing.T, d time.Duration, cond func() bool, msg string) { + t.Helper() + deadline := time.Now().Add(d) + for time.Now().Before(deadline) { + if cond() { + return + } + time.Sleep(10 * time.Millisecond) + } + if !cond() { + t.Fatal(msg) + } +} + +// waitRelayLargest polls TRACK_STATUS until the relay reports Largest Object +// {wantGroup, wantObject} (§10.2.17), failing after 10s. +func waitRelayLargest( + t *testing.T, + sess *session.Session, + ns wire.TrackNamespace, + name []byte, + wantGroup, wantObject uint64, +) { + t.Helper() + deadline := time.Now().Add(10 * time.Second) + var last string + for { + req, err := sess.TrackStatus(t.Context(), &message.TrackStatus{ + Namespace: ns, + Name: name, + }) + if err == nil { + p, ok := req.OK.Parameters.Find(message.ParamLargestObject) + _ = req.Close() + if ok && p.Group == wantGroup && p.Object == wantObject { + return + } + last = fmt.Sprintf("largest={%d,%d} present=%t", p.Group, p.Object, ok) + } else { + last = err.Error() + } + if time.Now().After(deadline) { + t.Fatalf("relay never reported largest {%d,%d}: %s", wantGroup, wantObject, last) + } + time.Sleep(10 * time.Millisecond) + } +} + +// FETCH. + +// fetchRangeFilter is the LOCATION_FILTER (§5.1.2) for the inclusive FETCH +// range [start, endIncl]; an end Object of MaxUint64 means the whole end group. +func fetchRangeFilter(start, endIncl message.Location) message.Parameter { + if endIncl.Object == math.MaxUint64 { + return message.AbsoluteRangeFilter(start, endIncl.Group-start.Group) + } + return message.AbsoluteRangeObjectFilter(start, endIncl.Group-start.Group, endIncl.Object) +} + +// fetchRequestRange returns the inclusive range a FETCH's LOCATION_FILTER asks +// for; ok is false when the filter is absent or open-ended. +func fetchRequestRange(m *message.Fetch) (start, end message.Location, ok bool) { + f, err := message.LocationFilterFromParam(m.Parameters) + if err != nil || f == nil { + return start, end, false + } + end, hasEnd := f.End() + if !hasEnd { + return start, end, false + } + return message.Location{Group: f.StartGroup, Object: f.StartObject}, end, true +} + +// fetchOKEnd is the end of [fetchRequestRange], for fake upstreams that echo it +// in FETCH_OK. +func fetchOKEnd(m *message.Fetch) message.Location { + _, end, _ := fetchRequestRange(m) + return end +} + +// decodedFetchObject is one Object of a FETCH response with absolute IDs. +type decodedFetchObject struct { + group, object uint64 + payload []byte +} + +// fetchAndDrain FETCHes [start, endIncl] in order and reads the response to +// FIN, returning the FETCH_OK and the Objects decoded by [decodeFetchStream]. +func fetchAndDrain( + t *testing.T, + sess *session.Session, + ns wire.TrackNamespace, + name []byte, + start, endIncl message.Location, + order message.GroupOrder, + extra ...message.Parameter, +) (*message.FetchOK, []decodedFetchObject) { + t.Helper() + params := message.Parameters{message.GroupOrderParam(order), fetchRangeFilter(start, endIncl)} + params = append(params, extra...) + reqStream, err := sess.Fetch(t.Context(), &message.Fetch{ + Namespace: ns, + Name: name, + Parameters: params, + }) + if err != nil { + t.Fatalf("Fetch: %v", err) + } + t.Cleanup(func() { reqStream.Close() }) + + ds, err := sess.AcceptDataStream(t.Context()) + if err != nil { + t.Fatalf("AcceptDataStream: %v", err) + } + fs, isFetch := ds.(*session.IncomingFetchStream) + if !isFetch { + t.Fatalf("got %T, want *IncomingFetchStream", ds) + } + return reqStream.OK, decodeFetchStream(t, fs, order) +} + +// decodeFetchStream reads fs to EOF, reversing the §11.4.4.1 delta encoding +// itself rather than through the session decoder. +func decodeFetchStream(t *testing.T, fs *session.IncomingFetchStream, order message.GroupOrder) []decodedFetchObject { + t.Helper() + var ( + out []decodedFetchObject + prevGroup uint64 + prevObject uint64 + havePrev bool + descending = order == message.GroupOrderDescending + ) + for { + fo, err := fs.ReadObject() + if err != nil { + if errors.Is(err, io.EOF) { + return out + } + t.Fatalf("ReadObject: %v", err) + } + + var g, o uint64 + switch { + case !havePrev: + // The first Object's deltas are absolute. + g = fo.GroupIDDelta + o = fo.ObjectIDDelta + case fo.SerializationFlags&message.FetchFlagGroupIDDelta != 0: + if descending { + g = prevGroup - fo.GroupIDDelta - 1 + } else { + g = prevGroup + fo.GroupIDDelta + 1 + } + o = fo.ObjectIDDelta + default: + g = prevGroup + if fo.SerializationFlags&message.FetchFlagObjectIDDelta != 0 { + o = prevObject + fo.ObjectIDDelta // no +1 here + } else { + o = prevObject + 1 + } + } + + out = append(out, decodedFetchObject{group: g, object: o, payload: fo.ObjectPayload}) + prevGroup = g + prevObject = o + havePrev = true + } +} + +// fetchElem is one element of a FETCH response: an Object or a §11.4.4.2 +// End of Range marker. +type fetchElem struct { + Group, Object uint64 + Unknown bool // End of Unknown Range + Marker bool // any End of Range marker +} + +// tryFetchElems FETCHes [{0,0}, {lastGroup,0}] with params and returns the +// response elements, or nil when the FETCH is refused. +func tryFetchElems( + t *testing.T, + sess *session.Session, + ns wire.TrackNamespace, + name []byte, + lastGroup uint64, + params message.Parameters, +) []fetchElem { + t.Helper() + fetchReq, err := sess.Fetch(t.Context(), &message.Fetch{ + Namespace: ns, + Name: name, + Parameters: append(message.Parameters{ + fetchRangeFilter(message.Location{}, message.Location{Group: lastGroup, Object: 0}), + }, params...), + }) + if err != nil { + return nil // not yet serviceable: the caller retries + } + defer fetchReq.Close() + order := message.GroupOrderAscending + if p, ok := params.Find(message.ParamGroupOrder); ok { + order = message.GroupOrder(p.Byte) + } + return collectFetchElems(t, sess, order, 3*time.Second) +} + +// collectFetchElems reads the next FETCH response on sess to FIN within +// timeout and returns its elements in arrival order. +func collectFetchElems( + t *testing.T, + sess *session.Session, + order message.GroupOrder, + timeout time.Duration, +) []fetchElem { + t.Helper() + var elems []fetchElem + for _, obj := range readFetchResponse(t, sess, order, timeout) { + elems = append(elems, fetchElem{ + Group: obj.GroupID, + Object: obj.ObjectID, + Unknown: obj.EndOfUnknownRange, + Marker: obj.IsEndOfRange(), + }) + } + return elems +} + +// readFetchResponse accepts the next data stream on sess, requires a FETCH +// response, and decodes it to FIN within timeout, markers included. +func readFetchResponse( + t *testing.T, + sess *session.Session, + order message.GroupOrder, + timeout time.Duration, +) []*session.DecodedFetchObject { + t.Helper() + type result struct { + objs []*session.DecodedFetchObject + err error + } + ch := make(chan result, 1) + go func() { + ds, err := sess.AcceptDataStream(t.Context()) + if err != nil { + ch <- result{err: err} + return + } + fs, ok := ds.(*session.IncomingFetchStream) + if !ok { + ch <- result{err: errors.New("not a fetch stream")} + return + } + fs.GroupOrder = order + var r result + for { + obj, err := fs.ReadDecoded() + if err != nil { + if !errors.Is(err, io.EOF) { + r.err = err + } + ch <- r + return + } + r.objs = append(r.objs, obj) + } + }() + select { + case r := <-ch: + if r.err != nil { + t.Fatalf("reading FETCH response: %v", r.err) + } + return r.objs + case <-time.After(timeout): + t.Fatal("FETCH response did not arrive within deadline") + return nil + } +} + +// realGroups returns the groups of the elements that are not End of Unknown +// Range markers. +func realGroups(elems []fetchElem) []uint64 { + var out []uint64 + for _, e := range elems { + if !e.Unknown { + out = append(out, e.Group) + } + } + return out +} + +// objectGroups returns the groups of the elements that are Objects. +func objectGroups(elems []fetchElem) []uint64 { + var out []uint64 + for _, e := range elems { + if !e.Marker { + out = append(out, e.Group) + } + } + return out +} + +// groupsEqual reports whether got is exactly wantLo..wantHi in order. +func groupsEqual(got []uint64, wantLo, wantHi uint64) bool { + if uint64(len(got)) != wantHi-wantLo+1 { + return false + } + for i, g := range got { + if g != wantLo+uint64(i) { + return false + } + } + return true +} + +// fetchRange FETCHes [start, end] and returns the response's Objects; served +// is false when the FETCH was refused or no stream arrived within 2s. +func fetchRange( + t *testing.T, + sess *session.Session, + ns wire.TrackNamespace, + name []byte, + start, end message.Location, +) (objs []*session.DecodedFetchObject, served bool) { + t.Helper() + fr, err := sess.Fetch(t.Context(), &message.Fetch{ + Namespace: ns, Name: name, + Parameters: message.Parameters{fetchRangeFilter(start, end)}, + }) + if err != nil { + return nil, false + } + defer fr.Close() + ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) + defer cancel() + ds, err := sess.AcceptDataStream(ctx) + if err != nil { + return nil, false + } + fs, ok := ds.(*session.IncomingFetchStream) + if !ok { + return nil, false + } + for { + o, err := fs.ReadDecoded() + if err != nil { + return objs, true + } + if !o.IsEndOfRange() { + objs = append(objs, o) + } + } +} + +// writeFetchGroupRange writes one Object per group startG..endG to a FETCH +// response in ascending delta encoding (§11.4.4). +func writeFetchGroupRange(out *session.OutgoingFetchStream, startG, endG uint64) { + first := true + for g := startG; g <= endG; g++ { + fo := &message.FetchObject{} + fo.SerializationFlags |= message.FetchFlagGroupIDDelta | message.FetchFlagObjectIDDelta + if first { + fo.GroupIDDelta = g // absolute + fo.SerializationFlags |= message.FetchFlagPriority + first = false + } else { + fo.GroupIDDelta = 0 // the next group + } + fo.ObjectIDDelta = 0 + fo.ObjectPayload = []byte{byte('a' + g)} + if err := out.WriteObject(fo); err != nil { + return + } + } +} diff --git a/pkg/relay/inbound_goaway_test.go b/pkg/relay/inbound_goaway_test.go index dd60561b..a5e1e294 100644 --- a/pkg/relay/inbound_goaway_test.go +++ b/pkg/relay/inbound_goaway_test.go @@ -1,13 +1,11 @@ package relay_test import ( - "context" "testing" "time" "github.com/floatdrop/moq-go/pkg/moqt/message" "github.com/floatdrop/moq-go/pkg/moqt/session" - "github.com/floatdrop/moq-go/pkg/moqt/wire" "github.com/floatdrop/moq-go/pkg/relay" ) @@ -38,7 +36,7 @@ func TestRelay_InboundGoawayLeavesSessionOpen(t *testing.T) { } // Still usable: requests from the GOAWAY sender are answered. if _, err := clientSess.PublishNamespace(t.Context(), &message.PublishNamespace{ - Namespace: wire.TrackNamespace{[]byte("still-here")}, + Namespace: ns("still-here"), }); err != nil { t.Fatalf("PublishNamespace after GOAWAY: %v", err) } @@ -53,8 +51,8 @@ func TestRelay_NoUpstreamSubscribeToGoingAwayPublisher(t *testing.T) { t.Parallel() pubSess, teardown := connectRelay(t, relay.Config{}) defer teardown() - ns := wire.TrackNamespace{[]byte("video")} - if _, err := pubSess.PublishNamespace(t.Context(), &message.PublishNamespace{Namespace: ns}); err != nil { + video := ns("video") + if _, err := pubSess.PublishNamespace(t.Context(), &message.PublishNamespace{Namespace: video}); err != nil { t.Fatalf("PublishNamespace: %v", err) } subscribes := make(chan *session.Request, 4) @@ -74,7 +72,7 @@ func TestRelay_NoUpstreamSubscribeToGoingAwayPublisher(t *testing.T) { time.Sleep(200 * time.Millisecond) // let the relay read the GOAWAY subSess := dialAnotherClient(t, pubSess) - _, err := subSess.Subscribe(t.Context(), &message.Subscribe{Namespace: ns, Name: []byte("cam1")}) + _, err := subSess.Subscribe(t.Context(), &message.Subscribe{Namespace: video, Name: []byte("cam1")}) select { case r := <-subscribes: t.Fatalf("the relay sent %s to a publisher that had sent GOAWAY", r.First.Type()) @@ -93,7 +91,7 @@ func TestRelay_NoForwardedPublishToGoingAwayHolder(t *testing.T) { defer teardown() holder := dialAnotherClient(t, pubSess) forwarded := forwardedPublishes(t, holder) - subscribeTracks(t, holder, "video") + subscribeTracks(t, holder, ns("video")) if err := holder.SendGoaway(10*time.Second, ""); err != nil { t.Fatalf("SendGoaway: %v", err) } @@ -111,9 +109,9 @@ func TestRelay_NoUpstreamFetchToGoingAwayPublisher(t *testing.T) { t.Parallel() pubSess, teardown := connectRelay(t, relay.Config{}) defer teardown() - ns := wire.TrackNamespace{[]byte("video")} + video := ns("video") name := []byte("cam1") - if _, err := pubSess.PublishNamespace(t.Context(), &message.PublishNamespace{Namespace: ns}); err != nil { + if _, err := pubSess.PublishNamespace(t.Context(), &message.PublishNamespace{Namespace: video}); err != nil { t.Fatalf("PublishNamespace: %v", err) } fetches := make(chan *session.Request, 4) @@ -145,7 +143,7 @@ func TestRelay_NoUpstreamFetchToGoingAwayPublisher(t *testing.T) { }() live := dialAnotherClient(t, pubSess) - if _, err := live.Subscribe(t.Context(), &message.Subscribe{Namespace: ns, Name: name}); err != nil { + if _, err := live.Subscribe(t.Context(), &message.Subscribe{Namespace: video, Name: name}); err != nil { t.Fatalf("Subscribe: %v", err) } go drainAll(t.Context(), live) @@ -155,7 +153,7 @@ func TestRelay_NoUpstreamFetchToGoingAwayPublisher(t *testing.T) { fc := dialAnotherClient(t, pubSess) deadline := time.Now().Add(5 * time.Second) for { - objs, served := fetchRange(t, fc, ns, name, + objs, served := fetchRange(t, fc, video, name, message.Location{Group: stitchLiveLo}, message.Location{Group: stitchLiveHi}) if served && len(objs) > 0 { break @@ -174,7 +172,7 @@ func TestRelay_NoUpstreamFetchToGoingAwayPublisher(t *testing.T) { if _, served := fetchStitched( t, fc, - ns, + video, name, stitchLiveHi, stitchOpts{fillTimeout: 300 * time.Millisecond}, @@ -187,43 +185,3 @@ func TestRelay_NoUpstreamFetchToGoingAwayPublisher(t *testing.T) { default: } } - -// fetchRange FETCHes [start, end] from the relay and returns the Objects of -// the response, or served false when the FETCH was refused or its stream did -// not arrive. -func fetchRange( - t *testing.T, - sess *session.Session, - ns wire.TrackNamespace, - name []byte, - start, end message.Location, -) (objs []*session.DecodedFetchObject, served bool) { - t.Helper() - fr, err := sess.Fetch(t.Context(), &message.Fetch{ - Namespace: ns, Name: name, - Parameters: message.Parameters{fetchRangeFilter(start, end)}, - }) - if err != nil { - return nil, false - } - defer fr.Close() - ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) - defer cancel() - ds, err := sess.AcceptDataStream(ctx) - if err != nil { - return nil, false - } - fs, ok := ds.(*session.IncomingFetchStream) - if !ok { - return nil, false - } - for { - o, err := fs.ReadDecoded() - if err != nil { - return objs, true - } - if !o.IsEndOfRange() { - objs = append(objs, o) - } - } -} diff --git a/pkg/relay/include_properties_test.go b/pkg/relay/include_properties_test.go deleted file mode 100644 index 81640c4c..00000000 --- a/pkg/relay/include_properties_test.go +++ /dev/null @@ -1,167 +0,0 @@ -package relay_test - -import ( - "context" - "testing" - "time" - - "github.com/floatdrop/moq-go/pkg/moqt/message" - "github.com/floatdrop/moq-go/pkg/moqt/session" - "github.com/floatdrop/moq-go/pkg/moqt/wire" - "github.com/floatdrop/moq-go/pkg/relay" -) - -// §10.2.21: INCLUDE_PROPERTIES "specifies whether the OK message sent in -// response includes Track Properties or whether the resulting PUBLISH -// messages include Track Properties in the case of SUBSCRIBE_TRACKS. If -// INCLUDE_PROPERTIES is 0, the Track Properties are still present in the -// message, but they SHOULD be empty." -// -// Without Track Properties the subscriber reads DEFAULT_PUBLISHER_PRIORITY as -// 128 (§12.4), so for such a subscription the relay writes the priority -// inline on the subgroups and datagrams it forwards instead of inheriting it. - -const trackDefaultPriority = 7 - -func priorityTrackProps() []wire.KVPair { - return []wire.KVPair{{Type: message.PropertyDefaultPublisherPriority, IntVal: trackDefaultPriority}} -} - -func noProps() message.Parameter { return message.IncludePropertiesParam(false) } - -func TestIncludeProperties_SubscribeOK(t *testing.T) { - t.Parallel() - pubSess, alias := publishWithTrackProps(t, priorityTrackProps()) - subSess := dialAnotherClient(t, pubSess) - sub := subscribeCam1Req(t, subSess, noProps()) - if len(sub.OK.TrackProperties) != 0 { - t.Fatalf("SUBSCRIBE_OK Track Properties %x with INCLUDE_PROPERTIES=0, want empty", sub.OK.TrackProperties) - } - with := subscribeCam1Req(t, dialAnotherClient(t, pubSess)) - if len(with.OK.TrackProperties) == 0 { - t.Fatal("SUBSCRIBE_OK lost its Track Properties without INCLUDE_PROPERTIES") - } - - // The subgroup the publisher sends with the default priority reaches - // the subscriber with the priority spelled out. - go sendObject(pubSess, alias, 1) - ds, ok := tryAcceptDataStream(t, subSess, 2*time.Second) - if !ok { - t.Fatal("no subgroup forwarded") - } - sg := ds.(*session.IncomingSubgroupStream) - if !sg.Header.InlinePriority || sg.Header.PublisherPriority != trackDefaultPriority { - t.Fatalf("forwarded header inline=%v priority=%d, want inline priority %d", - sg.Header.InlinePriority, sg.Header.PublisherPriority, trackDefaultPriority) - } -} - -func TestIncludeProperties_Datagram(t *testing.T) { - t.Parallel() - pubSess, alias := publishWithTrackProps(t, priorityTrackProps()) - subSess := dialAnotherClient(t, pubSess) - subscribeCam1Req(t, subSess, noProps()) - if err := pubSess.SendDatagram(&message.ObjectDatagram{ - Type: message.DatagramDefaultPriorityBit, TrackAlias: alias, GroupID: 1, ObjectPayload: []byte("x"), - }); err != nil { - t.Fatalf("SendDatagram: %v", err) - } - ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) - defer cancel() - d, err := subSess.ReceiveDatagram(ctx) - if err != nil { - t.Fatalf("ReceiveDatagram: %v", err) - } - if d.HasDefaultPriority() || d.PublisherPriority != trackDefaultPriority { - t.Fatalf("forwarded datagram default=%v priority=%d, want explicit priority %d", - d.HasDefaultPriority(), d.PublisherPriority, trackDefaultPriority) - } -} - -func TestIncludeProperties_FetchAndTrackStatus(t *testing.T) { - t.Parallel() - pubSess, alias := publishWithTrackProps(t, priorityTrackProps()) - subscribeCam1(t, pubSess) // keeps the track's upstream alive - publishSubgroupObject(t, pubSess, alias, 1, -1) - time.Sleep(50 * time.Millisecond) - c := dialAnotherClient(t, pubSess) - - ts, err := c.TrackStatus(t.Context(), &message.TrackStatus{ - Namespace: wire.TrackNamespace{[]byte("video")}, Name: []byte("cam1"), - Parameters: message.Parameters{noProps()}, - }) - if err != nil { - t.Fatalf("TrackStatus: %v", err) - } - if len(ts.OK.TrackProperties) != 0 { - t.Fatalf("TRACK_STATUS_OK Track Properties %x with INCLUDE_PROPERTIES=0, want empty", ts.OK.TrackProperties) - } - - fr, err := c.Fetch(t.Context(), &message.Fetch{ - Namespace: wire.TrackNamespace{[]byte("video")}, Name: []byte("cam1"), - Parameters: message.Parameters{noProps(), - fetchRangeFilter(message.Location{Group: 1}, message.Location{Group: 1})}, - }) - if err != nil { - t.Fatalf("Fetch: %v", err) - } - defer fr.Close() - if len(fr.OK.TrackProperties) != 0 { - t.Fatalf("FETCH_OK Track Properties %x with INCLUDE_PROPERTIES=0, want empty", fr.OK.TrackProperties) - } - go drainAll(t.Context(), c) -} - -func TestIncludeProperties_SubscribeTracks(t *testing.T) { - t.Parallel() - holder, teardown := connectRelay(t, relay.Config{}) - defer teardown() - reqs := forwardedPublishes(t, holder) - openSubscribeTracks(t, holder, ns("video"), noProps()) - - pubSess := dialAnotherClient(t, holder) - p, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: ns("video"), Name: []byte("cam"), TrackAlias: 7, - TrackProperties: message.AppendTrackProperties(priorityTrackProps()), - }) - if err != nil { - t.Fatalf("Publish: %v", err) - } - defer p.Close() - fwd := awaitForwarded(t, reqs) - if tp := fwd.First.(*message.Publish).TrackProperties; len(tp) != 0 { - t.Fatalf("forwarded PUBLISH Track Properties %x with INCLUDE_PROPERTIES=0, want empty", tp) - } - acceptForwarded(t, fwd) - - go sendObject(pubSess, 7, 1) - ds, ok := tryAcceptDataStream(t, holder, 2*time.Second) - if !ok { - t.Fatal("no subgroup forwarded") - } - if h := ds.(*session.IncomingSubgroupStream).Header; !h.InlinePriority || - h.PublisherPriority != trackDefaultPriority { - t.Fatalf("forwarded header inline=%v priority=%d, want inline priority %d", - h.InlinePriority, h.PublisherPriority, trackDefaultPriority) - } -} - -// TestIncludeProperties_TrackStatusStillAnswers: INCLUDE_PROPERTIES only -// empties the Track Properties; it does not change whether the track is known. -// A PUBLISHed track with properties and no Objects yet is answered -// TRACK_STATUS_OK either way. -func TestIncludeProperties_TrackStatusStillAnswers(t *testing.T) { - t.Parallel() - pubSess, _ := publishWithTrackProps(t, priorityTrackProps()) - c := dialAnotherClient(t, pubSess) - ts, err := c.TrackStatus(t.Context(), &message.TrackStatus{ - Namespace: wire.TrackNamespace{[]byte("video")}, Name: []byte("cam1"), - Parameters: message.Parameters{noProps()}, - }) - if err != nil { - t.Fatalf("TRACK_STATUS with INCLUDE_PROPERTIES=0 on a known track: %v", err) - } - if len(ts.OK.TrackProperties) != 0 { - t.Fatalf("TRACK_STATUS_OK Track Properties %x, want empty", ts.OK.TrackProperties) - } -} diff --git a/pkg/relay/integration_test.go b/pkg/relay/integration_test.go index 8e6cd6e4..8bc2aca6 100644 --- a/pkg/relay/integration_test.go +++ b/pkg/relay/integration_test.go @@ -27,7 +27,6 @@ import ( "github.com/floatdrop/moq-go/pkg/moqt/message" "github.com/floatdrop/moq-go/pkg/moqt/session" - "github.com/floatdrop/moq-go/pkg/moqt/wire" "github.com/floatdrop/moq-go/pkg/relay" "github.com/floatdrop/moq-go/pkg/relay/internal/relaytest" ) @@ -45,7 +44,7 @@ func TestPublishSubscribeE2E(t *testing.T) { const publisherAlias = uint64(7) pubReq, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), TrackAlias: publisherAlias, }) @@ -56,7 +55,7 @@ func TestPublishSubscribeE2E(t *testing.T) { subSess := dialAnotherClient(t, pubSess) subReq, err := subSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), }) if err != nil { @@ -190,7 +189,7 @@ func TestSubscriptionAggregation(t *testing.T) { // subscribe path has somewhere to dial. It then accepts inbound // SUBSCRIBEs from the relay; we count them. pubNS, err := pubSess.PublishNamespace(t.Context(), &message.PublishNamespace{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), }) if err != nil { t.Fatalf("PublishNamespace: %v", err) @@ -223,7 +222,7 @@ func TestSubscriptionAggregation(t *testing.T) { // First downstream subscriber → triggers upstream SUBSCRIBE. sub1 := dialAnotherClient(t, pubSess) subReq1, err := sub1.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), }) if err != nil { @@ -244,7 +243,7 @@ func TestSubscriptionAggregation(t *testing.T) { // upstream; the publisher must NOT see a second SUBSCRIBE. sub2 := dialAnotherClient(t, pubSess) subReq2, err := sub2.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), }) if err != nil { @@ -276,7 +275,7 @@ func TestPublishNamespaceRouting(t *testing.T) { defer teardown() nsReq, err := subSess.SubscribeNamespace(t.Context(), &message.SubscribeNamespace{ - TrackNamespacePrefix: wire.TrackNamespace{[]byte("video")}, + TrackNamespacePrefix: ns("video"), }) if err != nil { t.Fatalf("SubscribeNamespace: %v", err) @@ -285,7 +284,7 @@ func TestPublishNamespaceRouting(t *testing.T) { pubSess := dialAnotherClient(t, subSess) pubNS, err := pubSess.PublishNamespace(t.Context(), &message.PublishNamespace{ - Namespace: wire.TrackNamespace{[]byte("video"), []byte("cam1")}, + Namespace: ns("video", "cam1"), }) if err != nil { t.Fatalf("PublishNamespace: %v", err) @@ -319,7 +318,7 @@ func TestDeliveryTimeouts(t *testing.T) { defer teardown() pubReq, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), TrackAlias: 1, Parameters: message.Parameters{ @@ -334,7 +333,7 @@ func TestDeliveryTimeouts(t *testing.T) { subSess := dialAnotherClient(t, pubSess) subReq, err := subSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), }) if err != nil { @@ -361,7 +360,7 @@ func TestGracefulMigration(t *testing.T) { pubSess, teardown := connectRelay(t, relay.Config{}) pubNS, err := pubSess.PublishNamespace(t.Context(), &message.PublishNamespace{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), }) if err != nil { t.Fatalf("PublishNamespace: %v", err) diff --git a/pkg/relay/late_publisher_test.go b/pkg/relay/late_publisher_test.go index 2e9cafe4..b490ac52 100644 --- a/pkg/relay/late_publisher_test.go +++ b/pkg/relay/late_publisher_test.go @@ -100,19 +100,19 @@ func requireNoSubscribe(t *testing.T, subs <-chan acceptedSubscribe) { // downstream subscriber. func TestRelay_LatePublishNamespaceJoinsOnDemandSubscription(t *testing.T) { t.Parallel() - ns := wire.TrackNamespace{[]byte("video")} + video := ns("video") early, teardown := connectRelay(t, relay.Config{}) defer teardown() - if _, err := early.PublishNamespace(t.Context(), &message.PublishNamespace{Namespace: ns}); err != nil { + if _, err := early.PublishNamespace(t.Context(), &message.PublishNamespace{Namespace: video}); err != nil { t.Fatalf("early PublishNamespace: %v", err) } earlySubs := acceptSubscribes(t, early) - subSess := subscribeCam1(t, early) + subSess := newCam1Subscriber(t, early) awaitAcceptedSubscribe(t, earlySubs, "video/cam1") - late, lateSubs := publishNamespaceLate(t, early, ns) + late, lateSubs := publishNamespaceLate(t, early, video) got := awaitAcceptedSubscribe(t, lateSubs, "video/cam1") - publishSubgroupObject(t, late, got.alias, 5, -1) + publishObjects(t, late, got.alias, 5, 1) if !awaitSubgroupObject(t, subSess, 2*time.Second) { t.Fatal("the late publisher's Object never reached the subscriber") } @@ -122,9 +122,9 @@ func TestRelay_LatePublishNamespaceJoinsOnDemandSubscription(t *testing.T) { // a PUBLISH from another session. func TestRelay_LatePublishNamespaceJoinsPublishedTrack(t *testing.T) { t.Parallel() - pubSess, _ := publishWithTrackProps(t, nil) - subscribeCam1(t, pubSess) - _, lateSubs := publishNamespaceLate(t, pubSess, wire.TrackNamespace{[]byte("video")}) + pubSess, _ := newCam1Publisher(t, nil) + newCam1Subscriber(t, pubSess) + _, lateSubs := publishNamespaceLate(t, pubSess, ns("video")) awaitAcceptedSubscribe(t, lateSubs, "video/cam1") } @@ -133,8 +133,8 @@ func TestRelay_LatePublishNamespaceJoinsPublishedTrack(t *testing.T) { // on-demand upstream would have no downstream whose departure releases it. func TestRelay_LatePublishNamespaceSkipsTrackWithoutSubscribers(t *testing.T) { t.Parallel() - pubSess, _ := publishWithTrackProps(t, nil) - _, lateSubs := publishNamespaceLate(t, pubSess, wire.TrackNamespace{[]byte("video")}) + pubSess, _ := newCam1Publisher(t, nil) + _, lateSubs := publishNamespaceLate(t, pubSess, ns("video")) requireNoSubscribe(t, lateSubs) } @@ -142,9 +142,9 @@ func TestRelay_LatePublishNamespaceSkipsTrackWithoutSubscribers(t *testing.T) { // whose namespace the PUBLISH_NAMESPACE covers are sent to the new publisher. func TestRelay_LatePublishNamespaceIgnoresOtherNamespaces(t *testing.T) { t.Parallel() - pubSess, _ := publishWithTrackProps(t, nil) - subscribeCam1(t, pubSess) - _, lateSubs := publishNamespaceLate(t, pubSess, wire.TrackNamespace{[]byte("audio")}) + pubSess, _ := newCam1Publisher(t, nil) + newCam1Subscriber(t, pubSess) + _, lateSubs := publishNamespaceLate(t, pubSess, ns("audio")) requireNoSubscribe(t, lateSubs) } @@ -179,15 +179,15 @@ func awaitRequest(t *testing.T, reqs <-chan *session.Request) *session.Request { // with nothing that would ever release it. func TestRelay_LatePublisherReleasedWhenSubscriberLeavesMidSubscribe(t *testing.T) { t.Parallel() - ns := wire.TrackNamespace{[]byte("video")} + video := ns("video") early, teardown := connectRelay(t, relay.Config{}) defer teardown() - if _, err := early.PublishNamespace(t.Context(), &message.PublishNamespace{Namespace: ns}); err != nil { + if _, err := early.PublishNamespace(t.Context(), &message.PublishNamespace{Namespace: video}); err != nil { t.Fatalf("early PublishNamespace: %v", err) } earlySubs := acceptSubscribes(t, early) subSess := dialAnotherClient(t, early) - sub, err := subSess.Subscribe(t.Context(), &message.Subscribe{Namespace: ns, Name: []byte("cam1")}) + sub, err := subSess.Subscribe(t.Context(), &message.Subscribe{Namespace: video, Name: []byte("cam1")}) if err != nil { t.Fatalf("Subscribe: %v", err) } @@ -195,7 +195,7 @@ func TestRelay_LatePublisherReleasedWhenSubscriberLeavesMidSubscribe(t *testing. late := dialAnotherClient(t, early) lateReqs := acceptOneSubscribeRequest(t, late) - if _, err := late.PublishNamespace(t.Context(), &message.PublishNamespace{Namespace: ns}); err != nil { + if _, err := late.PublishNamespace(t.Context(), &message.PublishNamespace{Namespace: video}); err != nil { t.Fatalf("late PublishNamespace: %v", err) } r := awaitRequest(t, lateReqs) @@ -225,16 +225,16 @@ func TestRelay_LatePublisherReleasedWhenSubscriberLeavesMidSubscribe(t *testing. // cancels the request". SUBSCRIBEs for existing tracks stop with it. func TestRelay_WithdrawnPublishNamespaceGetsNoMoreSubscribes(t *testing.T) { t.Parallel() - ns := wire.TrackNamespace{[]byte("video")} + video := ns("video") early, teardown := connectRelay(t, relay.Config{}) defer teardown() - if _, err := early.PublishNamespace(t.Context(), &message.PublishNamespace{Namespace: ns}); err != nil { + if _, err := early.PublishNamespace(t.Context(), &message.PublishNamespace{Namespace: video}); err != nil { t.Fatalf("early PublishNamespace: %v", err) } earlySubs := acceptSubscribes(t, early) subSess := dialAnotherClient(t, early) for _, name := range []string{"cam1", "cam2"} { - sub, err := subSess.Subscribe(t.Context(), &message.Subscribe{Namespace: ns, Name: []byte(name)}) + sub, err := subSess.Subscribe(t.Context(), &message.Subscribe{Namespace: video, Name: []byte(name)}) if err != nil { t.Fatalf("Subscribe %s: %v", name, err) } @@ -244,7 +244,7 @@ func TestRelay_WithdrawnPublishNamespaceGetsNoMoreSubscribes(t *testing.T) { late := dialAnotherClient(t, early) lateReqs := acceptOneSubscribeRequest(t, late) - nsPub, err := late.PublishNamespace(t.Context(), &message.PublishNamespace{Namespace: ns}) + nsPub, err := late.PublishNamespace(t.Context(), &message.PublishNamespace{Namespace: video}) if err != nil { t.Fatalf("late PublishNamespace: %v", err) } @@ -263,11 +263,11 @@ func TestRelay_WithdrawnPublishNamespaceGetsNoMoreSubscribes(t *testing.T) { // PUBLISH_NAMESPACE is handled. It must still be subscribed once the // downstream is registered. func TestRelay_PublishNamespaceDuringPendingSubscribe(t *testing.T) { - ns := wire.TrackNamespace{[]byte("video")} + video := ns("video") name := "cam-late-pending" early, teardown := connectRelay(t, relay.Config{}) defer teardown() - if _, err := early.PublishNamespace(t.Context(), &message.PublishNamespace{Namespace: ns}); err != nil { + if _, err := early.PublishNamespace(t.Context(), &message.PublishNamespace{Namespace: video}); err != nil { t.Fatalf("early PublishNamespace: %v", err) } acceptSubscribes(t, early) @@ -280,7 +280,7 @@ func TestRelay_PublishNamespaceDuringPendingSubscribe(t *testing.T) { return } once.Do(func() { - if _, err := late.PublishNamespace(t.Context(), &message.PublishNamespace{Namespace: ns}); err != nil { + if _, err := late.PublishNamespace(t.Context(), &message.PublishNamespace{Namespace: video}); err != nil { t.Errorf("late PublishNamespace: %v", err) } // Let the relay handle it while the track still has no @@ -291,7 +291,7 @@ func TestRelay_PublishNamespaceDuringPendingSubscribe(t *testing.T) { t.Cleanup(restore) subSess := dialAnotherClient(t, early) - sub, err := subSess.Subscribe(t.Context(), &message.Subscribe{Namespace: ns, Name: []byte(name)}) + sub, err := subSess.Subscribe(t.Context(), &message.Subscribe{Namespace: video, Name: []byte(name)}) if err != nil { t.Fatalf("Subscribe: %v", err) } @@ -305,12 +305,12 @@ func TestRelay_PublishNamespaceDuringPendingSubscribe(t *testing.T) { // the requesting session. func TestRelay_LatePublishNamespaceSkipsItsOwnDownstream(t *testing.T) { t.Parallel() - pubSess, _ := publishWithTrackProps(t, nil) - subSess := subscribeCam1(t, pubSess) + pubSess, _ := newCam1Publisher(t, nil) + subSess := newCam1Subscriber(t, pubSess) own := acceptSubscribes(t, subSess) if _, err := subSess.PublishNamespace( t.Context(), - &message.PublishNamespace{Namespace: wire.TrackNamespace{[]byte("video")}}, + &message.PublishNamespace{Namespace: ns("video")}, ); err != nil { t.Fatalf("PublishNamespace: %v", err) } @@ -322,8 +322,8 @@ func TestRelay_LatePublishNamespaceSkipsItsOwnDownstream(t *testing.T) { // session one SUBSCRIBE for it, not one per namespace. func TestRelay_OverlappingPublishNamespacesSubscribeOnce(t *testing.T) { t.Parallel() - pubSess, _ := publishWithTrackProps(t, nil) - subscribeCam1(t, pubSess) + pubSess, _ := newCam1Publisher(t, nil) + newCam1Subscriber(t, pubSess) late := dialAnotherClient(t, pubSess) reqs := make(chan *session.Request, 4) @@ -369,16 +369,16 @@ func requireNoSubscribeRequest(t *testing.T, reqs <-chan *session.Request) { // reach the late upstream yet, so the relay must resume it once registered. func TestRelay_LatePublisherResumedWhenForwardChangesMidSubscribe(t *testing.T) { t.Parallel() - ns := wire.TrackNamespace{[]byte("video")} + video := ns("video") early, teardown := connectRelay(t, relay.Config{}) defer teardown() - if _, err := early.PublishNamespace(t.Context(), &message.PublishNamespace{Namespace: ns}); err != nil { + if _, err := early.PublishNamespace(t.Context(), &message.PublishNamespace{Namespace: video}); err != nil { t.Fatalf("early PublishNamespace: %v", err) } earlySubs := acceptSubscribes(t, early) subSess := dialAnotherClient(t, early) sub, err := subSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: ns, + Namespace: video, Name: []byte("cam1"), Parameters: message.Parameters{message.ForwardParam(false)}, }) @@ -391,7 +391,7 @@ func TestRelay_LatePublisherResumedWhenForwardChangesMidSubscribe(t *testing.T) late := dialAnotherClient(t, early) lateReqs := acceptOneSubscribeRequest(t, late) - if _, err := late.PublishNamespace(t.Context(), &message.PublishNamespace{Namespace: ns}); err != nil { + if _, err := late.PublishNamespace(t.Context(), &message.PublishNamespace{Namespace: video}); err != nil { t.Fatalf("late PublishNamespace: %v", err) } r := awaitRequest(t, lateReqs) @@ -422,10 +422,10 @@ func TestRelay_LatePublisherResumedWhenForwardChangesMidSubscribe(t *testing.T) // SUBSCRIBE; it does not drop it. func TestRelay_SkippedLatePublisherSubscribedWhenFirstSubscriberArrives(t *testing.T) { t.Parallel() - pubSess, _ := publishWithTrackProps(t, nil) - _, lateSubs := publishNamespaceLate(t, pubSess, wire.TrackNamespace{[]byte("video")}) + pubSess, _ := newCam1Publisher(t, nil) + _, lateSubs := publishNamespaceLate(t, pubSess, ns("video")) requireNoSubscribe(t, lateSubs) // skipped: no subscriber yet - subscribeCam1(t, pubSess) + newCam1Subscriber(t, pubSess) awaitAcceptedSubscribe(t, lateSubs, "video/cam1") } @@ -434,7 +434,7 @@ func TestRelay_SkippedLatePublisherSubscribedWhenFirstSubscriberArrives(t *testi // subscriber that later joins the reused upstream set. func TestRelay_RefusingLatePublisherNotReaskedPerSubscriber(t *testing.T) { t.Parallel() - pubSess, _ := publishWithTrackProps(t, nil) + pubSess, _ := newCam1Publisher(t, nil) late := dialAnotherClient(t, pubSess) reqs := make(chan *session.Request, 8) go func() { @@ -449,14 +449,14 @@ func TestRelay_RefusingLatePublisherNotReaskedPerSubscriber(t *testing.T) { }() if _, err := late.PublishNamespace( t.Context(), - &message.PublishNamespace{Namespace: wire.TrackNamespace{[]byte("video")}}, + &message.PublishNamespace{Namespace: ns("video")}, ); err != nil { t.Fatalf("PublishNamespace: %v", err) } - subscribeCam1(t, pubSess) + newCam1Subscriber(t, pubSess) awaitRequest(t, reqs) // the deferred SUBSCRIBE, refused - subscribeCam1(t, pubSess) - subscribeCam1(t, pubSess) + newCam1Subscriber(t, pubSess) + newCam1Subscriber(t, pubSess) requireNoSubscribeRequest(t, reqs) } @@ -465,9 +465,9 @@ func TestRelay_RefusingLatePublisherNotReaskedPerSubscriber(t *testing.T) { // next subscriber gets it SUBSCRIBEd again. func TestRelay_LatePublisherResubscribedForNextSubscriber(t *testing.T) { t.Parallel() - pubSess, _ := publishWithTrackProps(t, nil) - _, lateSubs := publishNamespaceLate(t, pubSess, wire.TrackNamespace{[]byte("video")}) - first := subscribeCam1Req(t, dialAnotherClient(t, pubSess)) + pubSess, _ := newCam1Publisher(t, nil) + _, lateSubs := publishNamespaceLate(t, pubSess, ns("video")) + first := subscribeCam1(t, dialAnotherClient(t, pubSess)) up := awaitAcceptedSubscribe(t, lateSubs, "video/cam1") _ = first.Close() select { @@ -475,7 +475,7 @@ func TestRelay_LatePublisherResubscribedForNextSubscriber(t *testing.T) { case <-time.After(2 * time.Second): t.Fatal("late upstream not released after the last subscriber left") } - subscribeCam1(t, pubSess) + newCam1Subscriber(t, pubSess) awaitAcceptedSubscribe(t, lateSubs, "video/cam1") } @@ -485,12 +485,12 @@ func TestRelay_LatePublisherResubscribedForNextSubscriber(t *testing.T) { // subscriptions). func TestRelay_WithdrawnSkippedPublisherNotAsked(t *testing.T) { t.Parallel() - pubSess, _ := publishWithTrackProps(t, nil) + pubSess, _ := newCam1Publisher(t, nil) late := dialAnotherClient(t, pubSess) lateSubs := acceptSubscribes(t, late) nsPub, err := late.PublishNamespace( t.Context(), - &message.PublishNamespace{Namespace: wire.TrackNamespace{[]byte("video")}}, + &message.PublishNamespace{Namespace: ns("video")}, ) if err != nil { t.Fatalf("PublishNamespace: %v", err) @@ -498,7 +498,7 @@ func TestRelay_WithdrawnSkippedPublisherNotAsked(t *testing.T) { requireNoSubscribe(t, lateSubs) // skipped: no subscriber yet _ = nsPub.Close() time.Sleep(100 * time.Millisecond) - subscribeCam1(t, pubSess) + newCam1Subscriber(t, pubSess) requireNoSubscribe(t, lateSubs) } @@ -524,7 +524,7 @@ func latePublisherAnswering( }() if _, err := late.PublishNamespace( t.Context(), - &message.PublishNamespace{Namespace: wire.TrackNamespace{[]byte("video")}}, + &message.PublishNamespace{Namespace: ns("video")}, ); err != nil { t.Fatalf("PublishNamespace: %v", err) } @@ -536,7 +536,7 @@ func latePublisherAnswering( // after the interval asks again. func TestRelay_RetryableLatePublisherRefusalIsRetried(t *testing.T) { t.Parallel() - pubSess, _ := publishWithTrackProps(t, nil) + pubSess, _ := newCam1Publisher(t, nil) reqs := latePublisherAnswering(t, pubSess, func(r *session.Request) { _ = message.Marshal(r.Stream, &message.RequestError{ ErrorCode: moqt.RequestExcessiveLoad, @@ -545,10 +545,10 @@ func TestRelay_RetryableLatePublisherRefusalIsRetried(t *testing.T) { }) _ = r.Stream.Close() }) - subscribeCam1(t, pubSess) + newCam1Subscriber(t, pubSess) awaitRequest(t, reqs) time.Sleep(50 * time.Millisecond) // let the refusal land - subscribeCam1(t, pubSess) + newCam1Subscriber(t, pubSess) awaitRequest(t, reqs) } @@ -557,13 +557,13 @@ func TestRelay_RetryableLatePublisherRefusalIsRetried(t *testing.T) { // (§2.5.1) and, like a refusal, not asked again per subscriber. func TestRelay_MandatoryPropertyLatePublisherNotReasked(t *testing.T) { t.Parallel() - pubSess, _ := publishWithTrackProps(t, nil) + pubSess, _ := newCam1Publisher(t, nil) reqs := latePublisherAnswering(t, pubSess, func(r *session.Request) { _, _ = r.AcceptSubscribe(&message.SubscribeOK{TrackProperties: mandatoryProps()}) }) - subscribeCam1(t, pubSess) + newCam1Subscriber(t, pubSess) awaitRequest(t, reqs) - subscribeCam1(t, pubSess) - subscribeCam1(t, pubSess) + newCam1Subscriber(t, pubSess) + newCam1Subscriber(t, pubSess) requireNoSubscribeRequest(t, reqs) } diff --git a/pkg/relay/limit_integration_test.go b/pkg/relay/limit_integration_test.go index d5afab80..137bb968 100644 --- a/pkg/relay/limit_integration_test.go +++ b/pkg/relay/limit_integration_test.go @@ -8,7 +8,6 @@ import ( "github.com/floatdrop/moq-go/pkg/moqt" "github.com/floatdrop/moq-go/pkg/moqt/message" "github.com/floatdrop/moq-go/pkg/moqt/session" - "github.com/floatdrop/moq-go/pkg/moqt/wire" "github.com/floatdrop/moq-go/pkg/relay" ) @@ -22,7 +21,7 @@ func TestRelay_SubscriptionLimit(t *testing.T) { defer teardown() pubStream, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), TrackAlias: 7, }) @@ -34,7 +33,7 @@ func TestRelay_SubscriptionLimit(t *testing.T) { subSess := dialAnotherClient(t, pubSess) newSub := func() *message.Subscribe { return &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), } } @@ -77,7 +76,7 @@ func TestRelay_NamespaceRequestLimit(t *testing.T) { defer teardown() ns1, err := pubSess.PublishNamespace(t.Context(), &message.PublishNamespace{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), }) if err != nil { t.Fatalf("first PublishNamespace: %v", err) @@ -85,7 +84,7 @@ func TestRelay_NamespaceRequestLimit(t *testing.T) { defer ns1.Close() _, err = pubSess.PublishNamespace(t.Context(), &message.PublishNamespace{ - Namespace: wire.TrackNamespace{[]byte("audio")}, + Namespace: ns("audio"), }) requireRejectedWithCode(t, err, moqt.RequestExcessiveLoad) requireRetryInvited(t, err) diff --git a/pkg/relay/malformed_track_test.go b/pkg/relay/malformed_track_test.go index b4528527..4d671547 100644 --- a/pkg/relay/malformed_track_test.go +++ b/pkg/relay/malformed_track_test.go @@ -1,28 +1,34 @@ package relay_test import ( + "errors" + "fmt" + "io" + "sync/atomic" "testing" "time" "github.com/floatdrop/moq-go/pkg/moqt" "github.com/floatdrop/moq-go/pkg/moqt/message" "github.com/floatdrop/moq-go/pkg/moqt/session" - "github.com/floatdrop/moq-go/pkg/moqt/wire" "github.com/floatdrop/moq-go/pkg/relay" ) -// §2.4.2: "If a relay detects a Malformed Track, it MUST immediately terminate -// downstream subscriptions with PUBLISH_DONE and reset any fetch streams with -// Status Code MALFORMED_TRACK. Object(s) triggering Malformed Track status -// MUST NOT be cached." As a subscriber it "MUST cancel any corresponding -// subscription or fetches for that Track from that publisher". +// Malformed tracks (§2.4.2): the relay ends downstream subscriptions with +// PUBLISH_DONE MALFORMED_TRACK, resets fetch streams, and cancels its own +// subscription upstream. Unknown Mandatory Track Properties (§2.5.1) refuse +// the track the same way. -// mandatoryObjectProps is an Object Property the track may not carry: a -// Mandatory Track Property used as an Object Property (§2.5.1). -func mandatoryObjectProps() []byte { - return message.AppendTrackProperties([]wire.KVPair{{Type: 0x4000, IntVal: 1}}) +// mandatoryProps carries the unknown Mandatory Track Property 0x4000. As Object +// Properties it makes the track malformed (§2.5.1). +func mandatoryProps() []byte { + return message.AppendTrackProperties(trackProp(message.MandatoryTrackPropertyMin, 1)) } +// malformedProps does not parse as Properties: a varint type with nothing +// after it. +var malformedProps = []byte{0x01} + // requireUpstreamCancelled waits for the relay to cancel the publisher's // PUBLISH: a read on its request stream fails rather than blocking. func requireUpstreamCancelled(t *testing.T, pub *session.Publication) { @@ -42,6 +48,9 @@ func requireUpstreamCancelled(t *testing.T, pub *session.Publication) { } } +// TestRelay_MalformedObjectEndsTrack: each kind of malformed Object ends the +// downstream subscription with MALFORMED_TRACK and cancels the upstream one +// (§2.4.2). func TestRelay_MalformedObjectEndsTrack(t *testing.T) { t.Parallel() for _, tc := range []struct { @@ -56,7 +65,7 @@ func TestRelay_MalformedObjectEndsTrack(t *testing.T) { t.Errorf("OpenSubgroup: %v", err) return } - _ = sg.WriteObject(&message.SubgroupObject{Properties: mandatoryObjectProps(), Payload: []byte("x")}) + _ = sg.WriteObject(&message.SubgroupObject{Properties: mandatoryProps(), Payload: []byte("x")}) _ = sg.Close() }}, // §2.4.2 condition 4: an Object after the final Object in the Group. @@ -75,7 +84,7 @@ func TestRelay_MalformedObjectEndsTrack(t *testing.T) { {"datagram Object Properties", func(t *testing.T, pubSess *session.Session, alias uint64) { if err := pubSess.SendDatagram(&message.ObjectDatagram{ Type: message.DatagramPropertiesBit, TrackAlias: alias, GroupID: 1, - Properties: mandatoryObjectProps(), ObjectPayload: []byte("x"), + Properties: mandatoryProps(), ObjectPayload: []byte("x"), }); err != nil { t.Errorf("SendDatagram: %v", err) } @@ -87,8 +96,8 @@ func TestRelay_MalformedObjectEndsTrack(t *testing.T) { defer teardown() pub := publishVideoTrack(t, pubSess, "cam1", 7) subSess := dialAnotherClient(t, pubSess) - subReq := subscribeCam1Req(t, subSess) - go drainAllStreams(t.Context(), subSess) + subReq := subscribeCam1(t, subSess) + go drainAll(t.Context(), subSess) go tc.send(t, pubSess, 7) if pd := awaitPublishDone(t, subReq); pd.StatusCode != moqt.PublishDoneMalformedTrack { @@ -100,18 +109,15 @@ func TestRelay_MalformedObjectEndsTrack(t *testing.T) { } } -// TestRelay_MalformedObjectEndsTrackWithStreamsOpen: "immediately terminate -// downstream subscriptions with PUBLISH_DONE" must not wait on the -// subscription's other outbound streams. Here the publisher leaves a second -// subgroup stream open and idle; PUBLISH_DONE, which follows the last open -// stream (§10.12), still arrives because the relay resets it. +// TestRelay_MalformedObjectEndsTrackWithStreamsOpen: PUBLISH_DONE is not held +// back by another idle outbound stream (§10.12); the relay resets it. func TestRelay_MalformedObjectEndsTrackWithStreamsOpen(t *testing.T) { t.Parallel() pubSess, teardown := connectRelay(t, relay.Config{}) defer teardown() pub := publishVideoTrack(t, pubSess, "cam1", 7) subSess := dialAnotherClient(t, pubSess) - subReq := subscribeCam1Req(t, subSess) + subReq := subscribeCam1(t, subSess) idle, err := pubSess.OpenSubgroup(message.SubgroupHeader{ SubgroupIDMode: message.SubgroupIDExplicit, TrackAlias: 7, GroupID: 1, @@ -132,7 +138,7 @@ func TestRelay_MalformedObjectEndsTrackWithStreamsOpen(t *testing.T) { if _, err := ds.(*session.IncomingSubgroupStream).ReadObject(); err != nil { t.Fatalf("ReadObject: %v", err) } - go drainAllStreams(t.Context(), subSess) + go drainAll(t.Context(), subSess) go func() { sg, err := pubSess.OpenSubgroup(message.SubgroupHeader{ @@ -141,7 +147,7 @@ func TestRelay_MalformedObjectEndsTrackWithStreamsOpen(t *testing.T) { if err != nil { return } - _ = sg.WriteObject(&message.SubgroupObject{Properties: mandatoryObjectProps(), Payload: []byte("x")}) + _ = sg.WriteObject(&message.SubgroupObject{Properties: mandatoryProps(), Payload: []byte("x")}) _ = sg.Close() }() if pd := awaitPublishDone(t, subReq); pd.StatusCode != moqt.PublishDoneMalformedTrack { @@ -149,3 +155,255 @@ func TestRelay_MalformedObjectEndsTrackWithStreamsOpen(t *testing.T) { } requireUpstreamCancelled(t, pub) } + +// TestRelay_PublishTrackPropertiesRejected: a PUBLISH with an unknown Mandatory +// Track Property is UNSUPPORTED_EXTENSION (§2.5.1); one whose Track Properties +// do not parse is MALFORMED_TRACK. +func TestRelay_PublishTrackPropertiesRejected(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + props []byte + want moqt.RequestErrorCode + }{ + {"unknown mandatory", mandatoryProps(), moqt.RequestUnsupportedExtension}, + {"malformed", malformedProps, moqt.RequestMalformedTrack}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + sess, teardown := connectRelay(t, relay.Config{}) + defer teardown() + _, err := sess.Publish(t.Context(), &message.Publish{ + Namespace: ns("video"), + Name: []byte("cam1"), + TrackProperties: tc.props, + }) + requireRejectedWithCode(t, err, tc.want) + }) + } +} + +// TestRelay_PublishKnownMandatoryPropertyAccepted: a Mandatory Track Property +// the relay is configured to know is accepted. +func TestRelay_PublishKnownMandatoryPropertyAccepted(t *testing.T) { + t.Parallel() + sess, teardown := connectRelay(t, relay.Config{ + KnownMandatoryTrackProperties: []message.PropertyType{message.MandatoryTrackPropertyMin}, + }) + defer teardown() + pub, err := sess.Publish(t.Context(), &message.Publish{ + Namespace: ns("video"), + Name: []byte("cam1"), + TrackProperties: mandatoryProps(), + }) + if err != nil { + t.Fatalf("Publish with a configured mandatory property: %v", err) + } + _ = pub.Close() +} + +// TestRelay_UpstreamSubscribeOKTrackPropertiesRejected: the relay refuses the +// downstream SUBSCRIBE when the upstream SUBSCRIBE_OK carries an unknown +// Mandatory Track Property (§2.5.1) or malformed Track Properties. +func TestRelay_UpstreamSubscribeOKTrackPropertiesRejected(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + props []byte + want moqt.RequestErrorCode + }{ + {"unknown mandatory", mandatoryProps(), moqt.RequestUnsupportedExtension}, + {"malformed", malformedProps, moqt.RequestMalformedTrack}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + video := ns("video") + upSess, teardown := connectRelay(t, relay.Config{}) + defer teardown() + if _, err := upSess.PublishNamespace(t.Context(), &message.PublishNamespace{Namespace: video}); err != nil { + t.Fatalf("PublishNamespace: %v", err) + } + go func() { + r, err := upSess.AcceptRequest(t.Context()) + if err != nil { + return + } + _, _ = r.AcceptSubscribe(&message.SubscribeOK{TrackProperties: tc.props}) + }() + subSess := dialAnotherClient(t, upSess) + _, err := subSess.Subscribe(t.Context(), &message.Subscribe{Namespace: video, Name: []byte("cam1")}) + requireRejectedWithCode(t, err, tc.want) + }) + } +} + +// TestRelay_UpstreamMandatoryPropertyWinsOverOtherFailure: with two +// publishers for the namespace, one answering SUBSCRIBE_OK with an unknown +// Mandatory Track Property and the other refusing, the downstream subscriber +// still gets UNSUPPORTED_EXTENSION (§2.5.1), whichever publisher is tried last. +func TestRelay_UpstreamMandatoryPropertyWinsOverOtherFailure(t *testing.T) { + t.Parallel() + for _, mandatoryFirst := range []bool{true, false} { + t.Run(fmt.Sprintf("mandatoryFirst=%v", mandatoryFirst), func(t *testing.T) { + t.Parallel() + video := ns("video") + first, teardown := connectRelay(t, relay.Config{}) + defer teardown() + second := dialAnotherClient(t, first) + serve := func(sess *session.Session, mandatory bool) { + if _, err := sess.PublishNamespace( + t.Context(), + &message.PublishNamespace{Namespace: video}, + ); err != nil { + t.Fatalf("PublishNamespace: %v", err) + } + go func() { + r, err := sess.AcceptRequest(t.Context()) + if err != nil { + return + } + if mandatory { + _, _ = r.AcceptSubscribe(&message.SubscribeOK{TrackProperties: mandatoryProps()}) + return + } + _ = r.RejectError(moqt.RequestDoesNotExist, "not here") + }() + } + serve(first, mandatoryFirst) + serve(second, !mandatoryFirst) + subSess := dialAnotherClient(t, first) + _, err := subSess.Subscribe(t.Context(), &message.Subscribe{Namespace: video, Name: []byte("cam1")}) + requireRejectedWithCode(t, err, moqt.RequestUnsupportedExtension) + }) + } +} + +// TestRelay_UpstreamFetchOKUnknownMandatoryPropertyResetsStream: an upstream +// FETCH_OK with an unknown Mandatory Track Property (§2.5.1), or Track +// Properties that do not parse, resets the downstream fetch stream the relay +// already answered; no Object reaches the subscriber, not even cached ones. +func TestRelay_UpstreamFetchOKUnknownMandatoryPropertyResetsStream(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + props []byte + }{ + {"unknown Mandatory Track Property", mandatoryProps()}, + {"Track Properties that do not parse", malformedProps}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + refusedFetchResetsStream(t, tc.props, nil) + }) + } +} + +// TestRelay_UpstreamFetchMalformedObjectResetsStream: an upstream FETCH +// response Object that makes the track malformed resets the downstream fetch +// stream (§2.4.2). +func TestRelay_UpstreamFetchMalformedObjectResetsStream(t *testing.T) { + t.Parallel() + refusedFetchResetsStream(t, nil, mandatoryProps()) +} + +// refusedFetchResetsStream requires a stitched FETCH to be reset when the +// upstream answers it with FETCH_OK carrying upstreamProps and, if objProps is +// non-nil, one Object carrying objProps. The upstream misbehaves only once +// armed, so the FETCHes waiting for the live tail to be cached succeed. +func refusedFetchResetsStream(t *testing.T, upstreamProps, objProps []byte) { + var armed atomic.Bool + pubSess, teardown := connectRelay(t, relay.Config{}) + defer teardown() + video := ns("video") + name := []byte("cam1") + if _, err := pubSess.PublishNamespace(t.Context(), &message.PublishNamespace{Namespace: video}); err != nil { + t.Fatalf("PublishNamespace: %v", err) + } + go func() { + for { + req, err := pubSess.AcceptRequest(t.Context()) + if err != nil { + return + } + switch first := req.First.(type) { + case *message.Subscribe: + if req.Reply(&message.SubscribeOK{TrackAlias: 42}) != nil { + return + } + for g := stitchLiveLo; g <= stitchLiveHi; g++ { + sg, err := openSubgroupWaiting(t, pubSess, message.SubgroupHeader{ + SubgroupIDMode: message.SubgroupIDImplicitZero, TrackAlias: 42, GroupID: g, + }) + if err != nil { + return + } + _ = sg.WriteObject(&message.SubgroupObject{Payload: []byte{byte('a' + g)}}) + _ = sg.Close() + } + case *message.Fetch: + _ = req.Reply(&message.FetchOK{ + EndLocation: message.Location{Group: stitchLiveLo - 1}, + TrackProperties: upstreamProps, + }) + if objProps == nil || !armed.Load() { + continue + } + out, err := pubSess.OpenFetchStream(message.FetchHeader{RequestID: first.RequestID}) + if err != nil { + return + } + _ = out.WriteObject(&message.FetchObject{ + SerializationFlags: message.FetchFlagGroupIDDelta | message.FetchFlagObjectIDDelta | + message.FetchFlagPriority | message.FetchFlagProperties, + Properties: objProps, ObjectPayload: []byte("x"), + }) + _ = out.Close() + } + } + }() + + live := dialAnotherClient(t, pubSess) + if _, err := live.Subscribe(t.Context(), &message.Subscribe{Namespace: video, Name: name}); err != nil { + t.Fatalf("Subscribe: %v", err) + } + go drainAll(t.Context(), live) + fc := dialAnotherClient(t, pubSess) + deadline := time.Now().Add(5 * time.Second) + for { + if objs, served := fetchRange(t, fc, video, name, + message.Location{Group: stitchLiveLo}, message.Location{Group: stitchLiveHi}); served && len(objs) > 0 { + break + } + if time.Now().After(deadline) { + t.Fatal("the relay never cached the live tail") + } + time.Sleep(20 * time.Millisecond) + } + + // Reaches below the cache, so the relay stitches from the upstream. + armed.Store(true) + fr, err := fc.Fetch(t.Context(), &message.Fetch{ + Namespace: video, Name: name, + Parameters: message.Parameters{fetchRangeFilter(message.Location{}, message.Location{Group: stitchLiveHi})}, + }) + if err != nil { + t.Fatalf("Fetch: %v", err) + } + defer fr.Close() + ds, err := fc.AcceptDataStream(t.Context()) + if err != nil { + t.Fatalf("AcceptDataStream: %v", err) + } + fs, ok := ds.(*session.IncomingFetchStream) + if !ok { + t.Fatalf("AcceptDataStream = %T, want a FETCH stream", ds) + } + obj, err := fs.ReadDecoded() + switch { + case err == nil: + t.Fatalf("the relay forwarded Object {%d,%d} of a track it refused", + obj.GroupID, obj.ObjectID) + case errors.Is(err, io.EOF): + t.Fatal("the FETCH stream completed; want it reset over what the upstream sent") + } +} diff --git a/pkg/relay/mandatory_fetch_test.go b/pkg/relay/mandatory_fetch_test.go deleted file mode 100644 index 7a34799a..00000000 --- a/pkg/relay/mandatory_fetch_test.go +++ /dev/null @@ -1,152 +0,0 @@ -package relay_test - -import ( - "errors" - "io" - "sync/atomic" - "testing" - "time" - - "github.com/floatdrop/moq-go/pkg/moqt/message" - "github.com/floatdrop/moq-go/pkg/moqt/session" - "github.com/floatdrop/moq-go/pkg/moqt/wire" - "github.com/floatdrop/moq-go/pkg/relay" -) - -// TestRelay_UpstreamFetchOKUnknownMandatoryPropertyResetsStream pins §2.5.1 -// for FETCH_OK: a relay receiving a Mandatory Track Property it does not -// understand "MUST cancel the fetch", and "If the relay has already forwarded -// data on a fetch stream, it MUST reset the stream." The relay answers FETCH_OK -// downstream before it stitches from upstream, so the downstream fetch stream -// is reset, not completed from the cache with an unknown range. -// -// Track Properties that do not parse are refused the same way. No Object of the -// track reaches the subscriber, not even the cached ones. -func TestRelay_UpstreamFetchOKUnknownMandatoryPropertyResetsStream(t *testing.T) { - t.Parallel() - for _, tc := range []struct { - name string - props []byte - }{ - {"unknown Mandatory Track Property", message.AppendTrackProperties([]wire.KVPair{{Type: 0x4000, IntVal: 1}})}, - {"Track Properties that do not parse", []byte{0x01}}, - } { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - refusedFetchResetsStream(t, tc.props, nil) - }) - } -} - -// TestRelay_UpstreamFetchMalformedObjectResetsStream: §2.4.2 "If a relay -// detects a Malformed Track, it MUST [...] reset any fetch streams with -// Status Code MALFORMED_TRACK." Here the upstream's FETCH response carries an -// Object whose Properties make the track malformed; the downstream fetch -// stream is reset rather than served, and the track's live subscribers get -// PUBLISH_DONE (see TestRelay_MalformedObjectEndsTrack). -func TestRelay_UpstreamFetchMalformedObjectResetsStream(t *testing.T) { - t.Parallel() - refusedFetchResetsStream(t, nil, mandatoryObjectProps()) -} - -// refusedFetchResetsStream: the upstream answers the relay's FETCH with -// FETCH_OK carrying upstreamProps and, when objProps is non-nil, a response -// stream whose one Object carries objProps — only once armed, so the FETCHes -// that wait for the live tail to be cached do not end the track first. -func refusedFetchResetsStream(t *testing.T, upstreamProps, objProps []byte) { - var armed atomic.Bool - pubSess, teardown := connectRelay(t, relay.Config{}) - defer teardown() - ns := wire.TrackNamespace{[]byte("video")} - name := []byte("cam1") - if _, err := pubSess.PublishNamespace(t.Context(), &message.PublishNamespace{Namespace: ns}); err != nil { - t.Fatalf("PublishNamespace: %v", err) - } - go func() { - for { - req, err := pubSess.AcceptRequest(t.Context()) - if err != nil { - return - } - switch first := req.First.(type) { - case *message.Subscribe: - if req.Reply(&message.SubscribeOK{TrackAlias: 42}) != nil { - return - } - for g := stitchLiveLo; g <= stitchLiveHi; g++ { - sg, err := openSubgroupWaiting(t, pubSess, message.SubgroupHeader{ - SubgroupIDMode: message.SubgroupIDImplicitZero, TrackAlias: 42, GroupID: g, - }) - if err != nil { - return - } - _ = sg.WriteObject(&message.SubgroupObject{Payload: []byte{byte('a' + g)}}) - _ = sg.Close() - } - case *message.Fetch: - _ = req.Reply(&message.FetchOK{ - EndLocation: message.Location{Group: stitchLiveLo - 1}, - TrackProperties: upstreamProps, - }) - if objProps == nil || !armed.Load() { - continue - } - out, err := pubSess.OpenFetchStream(message.FetchHeader{RequestID: first.RequestID}) - if err != nil { - return - } - _ = out.WriteObject(&message.FetchObject{ - SerializationFlags: message.FetchFlagGroupIDDelta | message.FetchFlagObjectIDDelta | - message.FetchFlagPriority | message.FetchFlagProperties, - Properties: objProps, ObjectPayload: []byte("x"), - }) - _ = out.Close() - } - } - }() - - live := dialAnotherClient(t, pubSess) - if _, err := live.Subscribe(t.Context(), &message.Subscribe{Namespace: ns, Name: name}); err != nil { - t.Fatalf("Subscribe: %v", err) - } - go drainAll(t.Context(), live) - fc := dialAnotherClient(t, pubSess) - deadline := time.Now().Add(5 * time.Second) - for { - if objs, served := fetchRange(t, fc, ns, name, - message.Location{Group: stitchLiveLo}, message.Location{Group: stitchLiveHi}); served && len(objs) > 0 { - break - } - if time.Now().After(deadline) { - t.Fatal("the relay never cached the live tail") - } - time.Sleep(20 * time.Millisecond) - } - - // Reaches below the cache, so the relay stitches from the upstream. - armed.Store(true) - fr, err := fc.Fetch(t.Context(), &message.Fetch{ - Namespace: ns, Name: name, - Parameters: message.Parameters{fetchRangeFilter(message.Location{}, message.Location{Group: stitchLiveHi})}, - }) - if err != nil { - t.Fatalf("Fetch: %v", err) - } - defer fr.Close() - ds, err := fc.AcceptDataStream(t.Context()) - if err != nil { - t.Fatalf("AcceptDataStream: %v", err) - } - fs, ok := ds.(*session.IncomingFetchStream) - if !ok { - t.Fatalf("AcceptDataStream = %T, want a FETCH stream", ds) - } - obj, err := fs.ReadDecoded() - switch { - case err == nil: - t.Fatalf("the relay forwarded Object {%d,%d} of a track it refused", - obj.GroupID, obj.ObjectID) - case errors.Is(err, io.EOF): - t.Fatal("the FETCH stream completed; want it reset over what the upstream sent") - } -} diff --git a/pkg/relay/mandatory_property_test.go b/pkg/relay/mandatory_property_test.go deleted file mode 100644 index fbf1bc86..00000000 --- a/pkg/relay/mandatory_property_test.go +++ /dev/null @@ -1,142 +0,0 @@ -package relay_test - -import ( - "fmt" - "testing" - - "github.com/floatdrop/moq-go/pkg/moqt" - "github.com/floatdrop/moq-go/pkg/moqt/message" - "github.com/floatdrop/moq-go/pkg/moqt/session" - "github.com/floatdrop/moq-go/pkg/moqt/wire" - "github.com/floatdrop/moq-go/pkg/relay" -) - -// §2.5.1: "When an endpoint receives a Mandatory Track Property in PUBLISH, -// SUBSCRIBE_OK, or FETCH_OK that it does not understand, it MUST NOT process -// or forward that track: For PUBLISH messages: the subscriber MUST respond with -// REQUEST_ERROR with error code UNSUPPORTED_EXTENSION. For SUBSCRIBE_OK -// messages: the subscriber MUST cancel the subscription ... If the subscriber -// is a relay with pending downstream subscribers, it MUST send REQUEST_ERROR -// with error code UNSUPPORTED_EXTENSION to the downstream subscribers." - -func mandatoryProps() []byte { - return message.AppendTrackProperties([]wire.KVPair{ - {Type: message.MandatoryTrackPropertyMin, IntVal: 1}, - }) -} - -// malformedProps does not parse as Properties: a varint type with nothing -// after it. The relay cannot rule out an unknown Mandatory Track Property in -// it, so it refuses the track as malformed rather than forwarding it opaquely. -var malformedProps = []byte{0x01} - -func TestRelay_PublishTrackPropertiesRejected(t *testing.T) { - t.Parallel() - for _, tc := range []struct { - name string - props []byte - want moqt.RequestErrorCode - }{ - {"unknown mandatory", mandatoryProps(), moqt.RequestUnsupportedExtension}, - {"malformed", malformedProps, moqt.RequestMalformedTrack}, - } { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - sess, teardown := connectRelay(t, relay.Config{}) - defer teardown() - _, err := sess.Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("video")}, - Name: []byte("cam1"), - TrackProperties: tc.props, - }) - requireRejectedWithCode(t, err, tc.want) - }) - } -} - -func TestRelay_PublishKnownMandatoryPropertyAccepted(t *testing.T) { - t.Parallel() - sess, teardown := connectRelay(t, relay.Config{ - KnownMandatoryTrackProperties: []message.PropertyType{message.MandatoryTrackPropertyMin}, - }) - defer teardown() - pub, err := sess.Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("video")}, - Name: []byte("cam1"), - TrackProperties: mandatoryProps(), - }) - if err != nil { - t.Fatalf("Publish with a configured mandatory property: %v", err) - } - _ = pub.Close() -} - -func TestRelay_UpstreamSubscribeOKTrackPropertiesRejected(t *testing.T) { - t.Parallel() - for _, tc := range []struct { - name string - props []byte - want moqt.RequestErrorCode - }{ - {"unknown mandatory", mandatoryProps(), moqt.RequestUnsupportedExtension}, - {"malformed", malformedProps, moqt.RequestMalformedTrack}, - } { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - ns := wire.TrackNamespace{[]byte("video")} - upSess, teardown := connectRelay(t, relay.Config{}) - defer teardown() - if _, err := upSess.PublishNamespace(t.Context(), &message.PublishNamespace{Namespace: ns}); err != nil { - t.Fatalf("PublishNamespace: %v", err) - } - go func() { - r, err := upSess.AcceptRequest(t.Context()) - if err != nil { - return - } - _, _ = r.AcceptSubscribe(&message.SubscribeOK{TrackProperties: tc.props}) - }() - subSess := dialAnotherClient(t, upSess) - _, err := subSess.Subscribe(t.Context(), &message.Subscribe{Namespace: ns, Name: []byte("cam1")}) - requireRejectedWithCode(t, err, tc.want) - }) - } -} - -// TestRelay_UpstreamMandatoryPropertyWinsOverOtherFailure: with two -// publishers for the namespace, one answering SUBSCRIBE_OK with an unknown -// Mandatory Track Property and the other refusing, the downstream subscriber -// still gets UNSUPPORTED_EXTENSION (§2.5.1), whichever publisher is tried last. -func TestRelay_UpstreamMandatoryPropertyWinsOverOtherFailure(t *testing.T) { - t.Parallel() - for _, mandatoryFirst := range []bool{true, false} { - t.Run(fmt.Sprintf("mandatoryFirst=%v", mandatoryFirst), func(t *testing.T) { - t.Parallel() - ns := wire.TrackNamespace{[]byte("video")} - first, teardown := connectRelay(t, relay.Config{}) - defer teardown() - second := dialAnotherClient(t, first) - serve := func(sess *session.Session, mandatory bool) { - if _, err := sess.PublishNamespace(t.Context(), &message.PublishNamespace{Namespace: ns}); err != nil { - t.Fatalf("PublishNamespace: %v", err) - } - go func() { - r, err := sess.AcceptRequest(t.Context()) - if err != nil { - return - } - if mandatory { - _, _ = r.AcceptSubscribe(&message.SubscribeOK{TrackProperties: mandatoryProps()}) - return - } - _ = r.RejectError(moqt.RequestDoesNotExist, "not here") - }() - } - serve(first, mandatoryFirst) - serve(second, !mandatoryFirst) - subSess := dialAnotherClient(t, first) - _, err := subSess.Subscribe(t.Context(), &message.Subscribe{Namespace: ns, Name: []byte("cam1")}) - requireRejectedWithCode(t, err, moqt.RequestUnsupportedExtension) - }) - } -} diff --git a/pkg/relay/max_cache_duration_test.go b/pkg/relay/max_cache_duration_test.go deleted file mode 100644 index 8f2791c1..00000000 --- a/pkg/relay/max_cache_duration_test.go +++ /dev/null @@ -1,297 +0,0 @@ -package relay_test - -import ( - "context" - "errors" - "io" - "testing" - "time" - - "github.com/floatdrop/moq-go/pkg/moqt/message" - "github.com/floatdrop/moq-go/pkg/moqt/session" - "github.com/floatdrop/moq-go/pkg/moqt/wire" -) - -// §12.3 MAX_CACHE_DURATION: "If present, the relay MUST NOT start forwarding -// any individual Object received through this subscription or fetch after the -// specified number of milliseconds has elapsed since the beginning of the -// Object was received." Both ways a relay forwards an Object are covered: from -// its cache (FETCH) and from a live subscriber's queue. - -const maxCacheMs = 100 - -func maxCacheDurationProp() []wire.KVPair { - return []wire.KVPair{{Type: message.PropertyMaxCacheDuration, IntVal: maxCacheMs}} -} - -// TestRelay_MaxCacheDurationExpiresCachedObject: a FETCH after the duration -// must not be served the Object from the cache. -func TestRelay_MaxCacheDurationExpiresCachedObject(t *testing.T) { - t.Parallel() - pubSess, alias := publishWithTrackProps(t, maxCacheDurationProp()) - subSess := subscribeCam1(t, pubSess) - publishSubgroupObject(t, pubSess, alias, 3, -1) - if !awaitSubgroupObject(t, subSess, 2*time.Second) { - t.Fatal("object not forwarded live") - } - time.Sleep(3 * maxCacheMs * time.Millisecond) - - fetchSess := dialAnotherClient(t, pubSess) - for _, e := range tryFetchElems(t, fetchSess, wire.TrackNamespace{[]byte("video")}, []byte("cam1"), 3, nil) { - if !e.Unknown && e.Group == 3 { - t.Fatalf("FETCH served Object {3,%d} after its MAX_CACHE_DURATION elapsed", e.Object) - } - } -} - -// TestRelay_MaxCacheDurationDropsStaleQueuedObject: an Object that waited in a -// slow subscriber's queue past the duration is not forwarded to it. -func TestRelay_MaxCacheDurationDropsStaleQueuedObject(t *testing.T) { - t.Parallel() - pubSess, alias := publishWithTrackProps(t, maxCacheDurationProp()) - subSess := subscribeCam1(t, pubSess) - go func() { - sg, err := pubSess.OpenSubgroup(message.SubgroupHeader{ - SubgroupIDMode: message.SubgroupIDExplicit, TrackAlias: alias, GroupID: 3, - }) - if err != nil { - return - } - _ = sg.WriteObject(&message.SubgroupObject{Payload: []byte("x")}) - _ = sg.Close() - }() - // Not reading: the relay's writer for this subscriber cannot start - // forwarding until the subscriber accepts, by which time the Object is - // stale. - time.Sleep(3 * maxCacheMs * time.Millisecond) - - ctx, cancel := context.WithTimeout(t.Context(), 500*time.Millisecond) - defer cancel() - ds, err := subSess.AcceptDataStream(ctx) - if err != nil { - return // nothing forwarded: correct - } - sg, ok := ds.(*session.IncomingSubgroupStream) - if !ok { - t.Fatalf("got %T", ds) - } - if obj, err := sg.ReadObject(); err == nil { - t.Fatalf("relay forwarded a stale Object (%d-byte payload) past MAX_CACHE_DURATION", len(obj.Payload)) - } -} - -// TestRelay_MaxCacheDurationSkippedHeadClearsFirstObject: when the subgroup's -// first Object expires before it is forwarded but a later one is sent, the -// stream the subscriber receives must not claim FIRST_OBJECT (§11.4.2: "the -// first object in this subgroup stream is the first object published in the -// subgroup"). The skipped Object's state is unknown (§12.3), not -// non-existent. -func TestRelay_MaxCacheDurationSkippedHeadClearsFirstObject(t *testing.T) { - t.Parallel() - pubSess, alias := publishWithTrackProps(t, maxCacheDurationProp()) - subSess := subscribeCam1(t, pubSess) - sg, err := pubSess.OpenSubgroup(message.SubgroupHeader{ - SubgroupIDMode: message.SubgroupIDExplicit, TrackAlias: alias, GroupID: 3, - }) - if err != nil { - t.Fatalf("OpenSubgroup: %v", err) - } - if err := sg.WriteObject(&message.SubgroupObject{Payload: []byte("head")}); err != nil { - t.Fatalf("write head: %v", err) - } - // The subscriber does not read yet, so the head goes stale in the - // relay's queue; then a fresh Object follows. - time.Sleep(3 * maxCacheMs * time.Millisecond) - go func() { - _ = sg.WriteObject(&message.SubgroupObject{Payload: []byte("tail")}) - _ = sg.Close() - }() - - deadline := time.Now().Add(2 * time.Second) - for time.Now().Before(deadline) { - ctx, cancel := context.WithTimeout(t.Context(), time.Second) - ds, err := subSess.AcceptDataStream(ctx) - cancel() - if err != nil { - t.Fatal("the fresh Object was never forwarded") - } - in := ds.(*session.IncomingSubgroupStream) - obj, err := in.ReadObject() - if err != nil { - continue // a stream that carried only the skipped head - } - if string(obj.Payload) == "head" { - t.Fatal("relay forwarded the stale head Object") - } - if !in.Header.ReplayingSubgroup { - t.Fatal("stream starting after a skipped head claims FIRST_OBJECT") - } - // §11.4.3: the expired head is missing, so no FIN. - for { - if _, err := in.ReadObject(); err != nil { - if errors.Is(err, io.EOF) { - t.Fatal("the stream after an expired head ended with a FIN; want a reset") - } - return - } - } - } -} - -// TestRelay_MaxCacheDurationZeroNeverServesFromCache: a present -// MAX_CACHE_DURATION of 0 differs from an absent one (§12.3: only "If -// MAX_CACHE_DURATION is not sent" may Objects be cached until evicted). This -// relay reads 0 as "never serve from the cache" while still forwarding live -// Objects to current subscribers. -func TestRelay_MaxCacheDurationZeroNeverServesFromCache(t *testing.T) { - t.Parallel() - pubSess, alias := publishWithTrackProps(t, - []wire.KVPair{{Type: message.PropertyMaxCacheDuration, IntVal: 0}}) - subSess := subscribeCam1(t, pubSess) - publishSubgroupObject(t, pubSess, alias, 3, -1) - if !awaitSubgroupObject(t, subSess, 2*time.Second) { - t.Fatal("live subscriber did not receive the Object") - } - fetchSess := dialAnotherClient(t, pubSess) - for _, e := range tryFetchElems(t, fetchSess, wire.TrackNamespace{[]byte("video")}, []byte("cam1"), 3, nil) { - if !e.Unknown && e.Group == 3 { - t.Fatalf("FETCH served Object {3,%d} from the cache despite MAX_CACHE_DURATION=0", e.Object) - } - } -} - -// publishSecondCam1 PUBLISHes video/cam1 again, from a second publisher -// session, with the given Track Properties, and returns that session. -func publishSecondCam1(t *testing.T, pubSess *session.Session, alias uint64, props []wire.KVPair) *session.Session { - t.Helper() - second := dialAnotherClient(t, pubSess) - p, err := second.Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("video")}, Name: []byte("cam1"), TrackAlias: alias, - TrackProperties: message.AppendTrackProperties(props), - }) - if err != nil { - t.Fatalf("second Publish: %v", err) - } - t.Cleanup(func() { _ = p.Close() }) - return second -} - -// fetchCam1 FETCHes video/cam1 up to {lastGroup, 0}, retrying until served. -func fetchCam1(t *testing.T, sess *session.Session, lastGroup uint64) []fetchElem { - t.Helper() - for deadline := time.Now().Add(2 * time.Second); ; time.Sleep(20 * time.Millisecond) { - if elems := tryFetchElems( - t, - sess, - wire.TrackNamespace{[]byte("video")}, - []byte("cam1"), - lastGroup, - nil, - ); elems != nil { - return elems - } - if time.Now().After(deadline) { - t.Fatal("FETCH never served") - } - } -} - -func findElem(elems []fetchElem, group uint64) (fetchElem, bool) { - for _, e := range elems { - if e.Group == group && e.Object == 0 { - return e, true - } - } - return fetchElem{}, false -} - -// TestRelay_MaxCacheDurationPerUpstream: §12.3 limits "any individual Object -// received through this subscription or fetch", so an Object is bound by the -// MAX_CACHE_DURATION of the upstream it arrived on, not by whichever -// publisher's Track Properties the relay saw first. -func TestRelay_MaxCacheDurationPerUpstream(t *testing.T) { - t.Parallel() - pubA, _ := publishWithTrackProps(t, maxCacheDurationProp()) - pubB := publishSecondCam1(t, pubA, 9, nil) - subscribeCam1(t, pubA) - publishSubgroupObject(t, pubB, 9, 5, -1) - time.Sleep(3 * maxCacheMs * time.Millisecond) - - elems := fetchCam1(t, dialAnotherClient(t, pubA), 5) - if e, ok := findElem(elems, 5); !ok || e.Unknown { - t.Fatalf("FETCH elements %+v: the Object from the publisher without MAX_CACHE_DURATION "+ - "expired by the other publisher's value", elems) - } -} - -// TestRelay_MaxCacheDurationExpiredObjectIsUnknown: "Once Objects have expired -// from cache, their state becomes unknown" (§12.3). An Object that expired -// between two that did not is reported with an End of Unknown Range marker, -// not a plain gap, which a FETCH response asserts as non-existence (§11.4.4). -func TestRelay_MaxCacheDurationExpiredObjectIsUnknown(t *testing.T) { - t.Parallel() - pubA, aliasA := publishWithTrackProps(t, maxCacheDurationProp()) - pubB := publishSecondCam1(t, pubA, 9, nil) - subscribeCam1(t, pubA) - publishSubgroupObject(t, pubB, 9, 1, -1) - publishSubgroupObject(t, pubA, aliasA, 2, -1) - publishSubgroupObject(t, pubB, 9, 3, -1) - time.Sleep(3 * maxCacheMs * time.Millisecond) - - elems := fetchCam1(t, dialAnotherClient(t, pubA), 3) - e1, ok1 := findElem(elems, 1) - e2, ok2 := findElem(elems, 2) - e3, ok3 := findElem(elems, 3) - if !ok1 || e1.Unknown || !ok3 || e3.Unknown || !ok2 || !e2.Unknown { - t.Fatalf("FETCH elements %+v, want Objects at groups 1 and 3 and an unknown marker at group 2", elems) - } -} - -// TestRelay_MaxCacheDurationExpiresDuringFetch: the relay "MUST NOT start -// forwarding any individual Object [...] after" MAX_CACHE_DURATION. An Object -// that was fresh when the FETCH began but has expired by the time its turn to -// be written comes (the subscriber is slow to read) is reported unknown -// instead of sent. -func TestRelay_MaxCacheDurationExpiresDuringFetch(t *testing.T) { - t.Parallel() - pubSess, alias := publishWithTrackProps(t, maxCacheDurationProp()) - subscribeCam1(t, pubSess) - for g := uint64(1); g <= 3; g++ { - publishSubgroupObject(t, pubSess, alias, g, -1) - } - fc := dialAnotherClient(t, pubSess) - fr, err := fc.Fetch(t.Context(), &message.Fetch{ - Namespace: wire.TrackNamespace{[]byte("video")}, Name: []byte("cam1"), - Parameters: message.Parameters{fetchRangeFilter(message.Location{Group: 1}, message.Location{Group: 3})}, - }) - if err != nil { - t.Fatalf("Fetch: %v", err) - } - defer fr.Close() - ds, err := fc.AcceptDataStream(t.Context()) - if err != nil { - t.Fatalf("AcceptDataStream: %v", err) - } - fs := ds.(*session.IncomingFetchStream) - time.Sleep(3 * maxCacheMs * time.Millisecond) // the relay's writes wait on this read - - var served []uint64 - for { - obj, err := fs.ReadDecoded() - if err != nil { - if !errors.Is(err, io.EOF) { - t.Fatalf("fetch stream: %v", err) - } - break - } - if !obj.IsEndOfRange() { - served = append(served, obj.GroupID) - } - } - if len(served) > 1 { - t.Fatalf( - "served Objects in groups %v after they expired; at most the first write may have started in time", - served, - ) - } -} diff --git a/pkg/relay/metrics_test.go b/pkg/relay/metrics_test.go index 28d2a465..c75aa21e 100644 --- a/pkg/relay/metrics_test.go +++ b/pkg/relay/metrics_test.go @@ -10,7 +10,6 @@ import ( "github.com/floatdrop/moq-go/pkg/moqt/message" "github.com/floatdrop/moq-go/pkg/moqt/session" - "github.com/floatdrop/moq-go/pkg/moqt/wire" "github.com/floatdrop/moq-go/pkg/relay" ) @@ -124,11 +123,11 @@ func TestMetricsHooks(t *testing.T) { defer stop() const alias = uint64(7) - ns := wire.TrackNamespace{[]byte("video")} + video := ns("video") name := []byte("cam1") pubReq, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: ns, Name: name, TrackAlias: alias, + Namespace: video, Name: name, TrackAlias: alias, }) if err != nil { t.Fatalf("Publish: %v", err) @@ -136,7 +135,7 @@ func TestMetricsHooks(t *testing.T) { defer pubReq.Close() subSess := dialAnotherClient(t, pubSess) - subReq, err := subSess.Subscribe(t.Context(), &message.Subscribe{Namespace: ns, Name: name}) + subReq, err := subSess.Subscribe(t.Context(), &message.Subscribe{Namespace: video, Name: name}) if err != nil { t.Fatalf("Subscribe: %v", err) } @@ -248,7 +247,7 @@ func TestMetricsHooks(t *testing.T) { // FETCH the cached range and confirm FetchServed fires with the count. fetchReq, err := subSess.Fetch(t.Context(), &message.Fetch{ - Namespace: ns, + Namespace: video, Name: name, Parameters: message.Parameters{ fetchRangeFilter(message.Location{}, message.Location{Group: 0, Object: uint64(sgCount - 2)}), @@ -297,20 +296,6 @@ func drainFetch(t *testing.T, sess *session.Session) { } } -func waitFor(t *testing.T, d time.Duration, cond func() bool, msg string) { - t.Helper() - deadline := time.Now().Add(d) - for time.Now().Before(deadline) { - if cond() { - return - } - time.Sleep(10 * time.Millisecond) - } - if !cond() { - t.Fatal(msg) - } -} - // TestLegString and TestResetCauseString pin the metric label values. // // These strings are an external contract, not a debug convenience: they become diff --git a/pkg/relay/namespace_state_test.go b/pkg/relay/namespace_state_test.go index 2c8d1b53..1cd9fa51 100644 --- a/pkg/relay/namespace_state_test.go +++ b/pkg/relay/namespace_state_test.go @@ -12,7 +12,6 @@ import ( "github.com/floatdrop/moq-go/pkg/moqt" "github.com/floatdrop/moq-go/pkg/moqt/message" "github.com/floatdrop/moq-go/pkg/moqt/session" - "github.com/floatdrop/moq-go/pkg/moqt/wire" "github.com/floatdrop/moq-go/pkg/relay" "github.com/floatdrop/moq-go/pkg/relay/discovery" "github.com/floatdrop/moq-go/pkg/relay/internal/relaytest" @@ -24,38 +23,6 @@ import ( // Namespace" (§10.18) — so it is per namespace, not per publisher. §10.9.2 // covers TRACK_NAMESPACE_PREFIX updates. -// streamMessages reads stream on one goroutine for the test's lifetime, so a -// test can assert that nothing arrives without leaving a reader behind. -func streamMessages(t *testing.T, stream session.Stream) <-chan message.Message { - t.Helper() - out := make(chan message.Message, 16) - go func() { - defer close(out) - for { - m, err := message.Parse(stream) - if err != nil { - return - } - out <- m - } - }() - return out -} - -func nextMessage(t *testing.T, msgs <-chan message.Message) message.Message { - t.Helper() - select { - case m, ok := <-msgs: - if !ok { - t.Fatal("stream ended") - } - return m - case <-time.After(2 * time.Second): - t.Fatal("timeout waiting for a message") - } - return nil -} - func requireQuiet(t *testing.T, msgs <-chan message.Message, what string) { t.Helper() select { @@ -68,8 +35,8 @@ func requireQuiet(t *testing.T, msgs <-chan message.Message, what string) { func requireNamespace(t *testing.T, msgs <-chan message.Message, suffix ...string) { t.Helper() m := nextMessage(t, msgs) - ns, ok := m.(*message.Namespace) - if !ok || relaytest.FormatNamespace(ns.TrackNamespaceSuffix) != relaytest.FormatNamespace(nsFields(suffix)) { + n, ok := m.(*message.Namespace) + if !ok || relaytest.FormatNamespace(n.TrackNamespaceSuffix) != relaytest.FormatNamespace(ns(suffix...)) { t.Fatalf("got %T %+v, want NAMESPACE %v", m, m, suffix) } } @@ -78,13 +45,11 @@ func requireNamespaceDone(t *testing.T, msgs <-chan message.Message, suffix ...s t.Helper() m := nextMessage(t, msgs) d, ok := m.(*message.NamespaceDone) - if !ok || relaytest.FormatNamespace(d.TrackNamespaceSuffix) != relaytest.FormatNamespace(nsFields(suffix)) { + if !ok || relaytest.FormatNamespace(d.TrackNamespaceSuffix) != relaytest.FormatNamespace(ns(suffix...)) { t.Fatalf("got %T %+v, want NAMESPACE_DONE %v", m, m, suffix) } } -func nsFields(fields []string) wire.TrackNamespace { return ns(fields...) } - func publishNS(t *testing.T, sess *session.Session, fields ...string) *session.NamespacePublication { t.Helper() p, err := sess.PublishNamespace(t.Context(), &message.PublishNamespace{Namespace: ns(fields...)}) diff --git a/pkg/relay/narrowing_integration_test.go b/pkg/relay/narrowing_integration_test.go index 79048c16..4cca4441 100644 --- a/pkg/relay/narrowing_integration_test.go +++ b/pkg/relay/narrowing_integration_test.go @@ -6,7 +6,6 @@ import ( "github.com/floatdrop/moq-go/pkg/moqt/message" "github.com/floatdrop/moq-go/pkg/moqt/session" - "github.com/floatdrop/moq-go/pkg/moqt/wire" "github.com/floatdrop/moq-go/pkg/relay" ) @@ -22,7 +21,7 @@ func TestFanout_NarrowingUpdateResetsOutOfRangeStream(t *testing.T) { const alias = uint64(7) pubStream, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), TrackAlias: alias, }) @@ -33,7 +32,7 @@ func TestFanout_NarrowingUpdateResetsOutOfRangeStream(t *testing.T) { subSess := dialAnotherClient(t, pubSess) subMsg := &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), Parameters: message.Parameters{ // AbsoluteRange covering groups 0..10 — group 2 is in range. diff --git a/pkg/relay/newgroup_test.go b/pkg/relay/newgroup_test.go index d9a467ff..10eef3ed 100644 --- a/pkg/relay/newgroup_test.go +++ b/pkg/relay/newgroup_test.go @@ -6,48 +6,9 @@ import ( "time" "github.com/floatdrop/moq-go/pkg/moqt/message" - "github.com/floatdrop/moq-go/pkg/moqt/session" - "github.com/floatdrop/moq-go/pkg/moqt/wire" "github.com/floatdrop/moq-go/pkg/relay" ) -// dynamicGroupsProperties builds raw Track Properties advertising -// DYNAMIC_GROUPS (§12.6) with the given value. -func dynamicGroupsProperties(value uint64) []byte { - return message.AppendTrackProperties([]wire.KVPair{ - {Type: message.PropertyDynamicGroups, IntVal: value}, - }) -} - -// watchUpstreamNewGroup reads the publisher's PUBLISH stream looking for the -// relay's upstream REQUEST_UPDATE (§10.9, §10.2.19) and reports the first -// NEW_GROUP_REQUEST value it carries on the returned channel. Any REQUEST_UPDATE -// is answered with REQUEST_OK so the relay's UpdateRequest can complete. -func watchUpstreamNewGroup(t *testing.T, pubStream session.Stream) <-chan uint64 { - t.Helper() - got := make(chan uint64, 1) - go func() { - for { - m, err := message.Parse(pubStream) - if err != nil { - return - } - upd, ok := m.(*message.RequestUpdate) - if !ok { - continue - } - _ = message.Marshal(pubStream, &message.RequestOK{}) - if v, ok := newGroupReqValue(upd.Parameters); ok { - select { - case got <- v: - default: - } - } - } - }() - return got -} - func newGroupReqValue(ps message.Parameters) (uint64, bool) { if p, ok := ps.Find(message.ParamNewGroupRequest); ok { return p.Varint, true @@ -68,7 +29,7 @@ func TestNewGroupRequest_ForwardedUpstreamOnUpdate(t *testing.T) { const publisherAlias = uint64(7) pubStream, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), TrackAlias: publisherAlias, TrackProperties: dynamicGroupsProperties(1), @@ -82,7 +43,7 @@ func TestNewGroupRequest_ForwardedUpstreamOnUpdate(t *testing.T) { subSess := dialAnotherClient(t, pubSess) subMsg := &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), } subStream, err := subSess.Subscribe(t.Context(), subMsg) @@ -124,7 +85,7 @@ func TestNewGroupRequest_BackToBackUpdatesSurvive(t *testing.T) { const publisherAlias = uint64(7) pubStream, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), TrackAlias: publisherAlias, TrackProperties: dynamicGroupsProperties(1), @@ -156,7 +117,7 @@ func TestNewGroupRequest_BackToBackUpdatesSurvive(t *testing.T) { subSess := dialAnotherClient(t, pubSess) subMsg := &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), } subStream, err := subSess.Subscribe(t.Context(), subMsg) @@ -195,7 +156,7 @@ func TestNewGroupRequest_NotForwardedWithoutDynamicGroups(t *testing.T) { const publisherAlias = uint64(7) pubStream, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), TrackAlias: publisherAlias, // No DYNAMIC_GROUPS property. @@ -209,7 +170,7 @@ func TestNewGroupRequest_NotForwardedWithoutDynamicGroups(t *testing.T) { subSess := dialAnotherClient(t, pubSess) subMsg := &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), } subStream, err := subSess.Subscribe(t.Context(), subMsg) diff --git a/pkg/relay/param_scope_test.go b/pkg/relay/param_scope_test.go deleted file mode 100644 index 68d3827c..00000000 --- a/pkg/relay/param_scope_test.go +++ /dev/null @@ -1,107 +0,0 @@ -package relay_test - -import ( - "testing" - "time" - - "github.com/floatdrop/moq-go/pkg/moqt/message" - "github.com/floatdrop/moq-go/pkg/moqt/session" - "github.com/floatdrop/moq-go/pkg/moqt/wire" - "github.com/floatdrop/moq-go/pkg/relay" -) - -// §10.2.1 at the relay, which reads REQUEST_UPDATEs on requests it answers -// itself rather than through a session broker: a parameter outside the -// update's scope, or an unknown one (§10.2), closes the session. - -// sendUpdateRaw writes a REQUEST_UPDATE on stream without awaiting a reply. -func sendUpdateRaw(t *testing.T, sess *session.Session, stream session.Stream, params message.Parameters) { - t.Helper() - go func() { - _ = message.Marshal(stream, &message.RequestUpdate{RequestID: sess.AllocRequestID(), Parameters: params}) - }() -} - -func TestRelay_ParamScopeSubscribeUpdate(t *testing.T) { - t.Parallel() - for _, tc := range []struct { - name string - params message.Parameters - }{ - {"TRACK_NAMESPACE_PREFIX", message.Parameters{message.TrackNamespacePrefixParam(ns("video"))}}, - {"unknown parameter", message.Parameters{message.VarintParam(0x3E, 1)}}, - } { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - pubSess, _ := publishWithTrackProps(t, nil) - subSess := dialAnotherClient(t, pubSess) - sub := subscribeCam1Req(t, subSess) - sendUpdateRaw(t, subSess, sub.Stream, tc.params) - requireSessionClosed(t, subSess, "a REQUEST_UPDATE parameter outside its scope") - }) - } -} - -func TestRelay_ParamScopeNamespaceUpdate(t *testing.T) { - t.Parallel() - sess, teardown := connectRelay(t, relay.Config{}) - defer teardown() - nsSub, err := sess.SubscribeNamespace( - t.Context(), - &message.SubscribeNamespace{TrackNamespacePrefix: wire.TrackNamespace{[]byte("video")}}, - ) - if err != nil { - t.Fatalf("SubscribeNamespace: %v", err) - } - // FORWARD may appear in a REQUEST_UPDATE for a subscription or a - // SUBSCRIBE_TRACKS request (§10.2.18), not a SUBSCRIBE_NAMESPACE one. - sendUpdateRaw(t, sess, nsSub.Stream, message.Parameters{message.ForwardParam(true)}) - requireSessionClosed(t, sess, "FORWARD in a SUBSCRIBE_NAMESPACE update") -} - -func TestRelay_ParamScopeFetchUpdate(t *testing.T) { - t.Parallel() - pubSess, alias := publishWithTrackProps(t, nil) - publishSubgroupObject(t, pubSess, alias, 3, -1) - fetchSess := dialAnotherClient(t, pubSess) - go drainAll(t.Context(), fetchSess) // the relay reads updates once the data is written - // The Object reaches the relay's cache asynchronously; until it does the - // FETCH is refused, so retry. - var fr *session.FetchRequest - deadline := time.Now().Add(2 * time.Second) - for { - var err error - fr, err = fetchSess.Fetch(t.Context(), &message.Fetch{ - Namespace: wire.TrackNamespace{[]byte("video")}, Name: []byte("cam1"), - }) - if err == nil { - break - } - if time.Now().After(deadline) { - t.Fatalf("Fetch: %v", err) - } - time.Sleep(20 * time.Millisecond) - } - // LOCATION_FILTER may appear in a REQUEST_UPDATE for a subscription - // (§10.2.9), not for a FETCH. - sendUpdateRaw(t, fetchSess, fr.Stream, message.Parameters{ - message.LocationFilterParam(&message.LocationFilter{Fields: 2}), - }) - requireSessionClosed(t, fetchSess, "LOCATION_FILTER in a FETCH update") -} - -// TestRelay_MalformedUpdateClosesSession: §10 "If the length does not match -// the length of the Message Body, the receiver MUST close the session with a -// PROTOCOL_VIOLATION", on the follow-ups the relay reads itself. -func TestRelay_MalformedUpdateClosesSession(t *testing.T) { - t.Parallel() - pubSess, _ := publishWithTrackProps(t, nil) - subSess := dialAnotherClient(t, pubSess) - sub := subscribeCam1Req(t, subSess) - enc := wire.NewWriter(nil) - (&message.RequestUpdate{RequestID: subSess.AllocRequestID()}).Append(enc) - go func() { - _ = wire.WriteFrame(sub.Stream, uint64(message.TypeRequestUpdate), append(enc.Bytes(), 0x00)) - }() - requireSessionClosed(t, subSess, "a REQUEST_UPDATE whose Length exceeds its body") -} diff --git a/pkg/relay/prefix_overlap_test.go b/pkg/relay/prefix_overlap_test.go deleted file mode 100644 index c54d5a3c..00000000 --- a/pkg/relay/prefix_overlap_test.go +++ /dev/null @@ -1,148 +0,0 @@ -package relay_test - -import ( - "testing" - "time" - - "github.com/floatdrop/moq-go/pkg/moqt" - "github.com/floatdrop/moq-go/pkg/moqt/message" - "github.com/floatdrop/moq-go/pkg/moqt/wire" - "github.com/floatdrop/moq-go/pkg/relay" -) - -// §10.19 / §10.20: "Within a session, if a publisher receives a -// SUBSCRIBE_NAMESPACE with a Track Namespace Prefix that shares a common -// prefix with an established SUBSCRIBE_NAMESPACE, it MUST respond with -// REQUEST_ERROR with error code PREFIX_OVERLAP", and likewise for -// SUBSCRIBE_TRACKS; the two "have independent overlap spaces". Two tuple -// prefixes overlap when one is a prefix of the other. - -func ns(fields ...string) wire.TrackNamespace { - out := make(wire.TrackNamespace, len(fields)) - for i, f := range fields { - out[i] = []byte(f) - } - return out -} - -func TestRelay_PrefixOverlap(t *testing.T) { - t.Parallel() - - t.Run("nested SUBSCRIBE_NAMESPACE is rejected", func(t *testing.T) { - t.Parallel() - sess, teardown := connectRelay(t, relay.Config{}) - defer teardown() - first, err := sess.SubscribeNamespace( - t.Context(), - &message.SubscribeNamespace{TrackNamespacePrefix: ns("video")}, - ) - if err != nil { - t.Fatalf("first SubscribeNamespace: %v", err) - } - t.Cleanup(func() { _ = first.Close() }) - _, err = sess.SubscribeNamespace( - t.Context(), - &message.SubscribeNamespace{TrackNamespacePrefix: ns("video", "cam1")}, - ) - requireRejectedWithCode(t, err, moqt.RequestPrefixOverlap) - }) - - t.Run("nested SUBSCRIBE_TRACKS is rejected", func(t *testing.T) { - t.Parallel() - sess, teardown := connectRelay(t, relay.Config{}) - defer teardown() - first, err := sess.SubscribeTracks( - t.Context(), - &message.SubscribeTracks{TrackNamespacePrefix: ns("video", "cam1")}, - ) - if err != nil { - t.Fatalf("first SubscribeTracks: %v", err) - } - t.Cleanup(func() { _ = first.Close() }) - _, err = sess.SubscribeTracks(t.Context(), &message.SubscribeTracks{TrackNamespacePrefix: ns("video")}) - requireRejectedWithCode(t, err, moqt.RequestPrefixOverlap) - }) - - t.Run("the empty prefix overlaps everything", func(t *testing.T) { - t.Parallel() - sess, teardown := connectRelay(t, relay.Config{}) - defer teardown() - first, err := sess.SubscribeNamespace( - t.Context(), - &message.SubscribeNamespace{TrackNamespacePrefix: ns("video")}, - ) - if err != nil { - t.Fatalf("first SubscribeNamespace: %v", err) - } - t.Cleanup(func() { _ = first.Close() }) - _, err = sess.SubscribeNamespace(t.Context(), &message.SubscribeNamespace{}) - requireRejectedWithCode(t, err, moqt.RequestPrefixOverlap) - }) - - t.Run("the two types have independent spaces", func(t *testing.T) { - t.Parallel() - sess, teardown := connectRelay(t, relay.Config{}) - defer teardown() - a, err := sess.SubscribeNamespace(t.Context(), &message.SubscribeNamespace{TrackNamespacePrefix: ns("video")}) - if err != nil { - t.Fatalf("SubscribeNamespace: %v", err) - } - t.Cleanup(func() { _ = a.Close() }) - b, err := sess.SubscribeTracks(t.Context(), &message.SubscribeTracks{TrackNamespacePrefix: ns("video")}) - if err != nil { - t.Fatalf("SubscribeTracks with the same prefix: %v", err) - } - t.Cleanup(func() { _ = b.Close() }) - }) - - t.Run("disjoint prefixes are accepted", func(t *testing.T) { - t.Parallel() - sess, teardown := connectRelay(t, relay.Config{}) - defer teardown() - a, err := sess.SubscribeNamespace( - t.Context(), - &message.SubscribeNamespace{TrackNamespacePrefix: ns("video", "a")}, - ) - if err != nil { - t.Fatalf("SubscribeNamespace a: %v", err) - } - t.Cleanup(func() { _ = a.Close() }) - b, err := sess.SubscribeNamespace( - t.Context(), - &message.SubscribeNamespace{TrackNamespacePrefix: ns("video", "b")}, - ) - if err != nil { - t.Fatalf("SubscribeNamespace b (disjoint): %v", err) - } - t.Cleanup(func() { _ = b.Close() }) - }) - - t.Run("a cancelled prefix can be reused", func(t *testing.T) { - t.Parallel() - sess, teardown := connectRelay(t, relay.Config{}) - defer teardown() - first, err := sess.SubscribeNamespace( - t.Context(), - &message.SubscribeNamespace{TrackNamespacePrefix: ns("video")}, - ) - if err != nil { - t.Fatalf("first SubscribeNamespace: %v", err) - } - _ = first.Close() // cancels - deadline := time.Now().Add(2 * time.Second) - for { - again, err := sess.SubscribeNamespace( - t.Context(), - &message.SubscribeNamespace{TrackNamespacePrefix: ns("video")}, - ) - if err == nil { - t.Cleanup(func() { _ = again.Close() }) - return - } - if time.Now().After(deadline) { - t.Fatalf("prefix never released after cancel: %v", err) - } - time.Sleep(20 * time.Millisecond) - } - }) -} diff --git a/pkg/relay/priority_test.go b/pkg/relay/priority_test.go index e8676e59..6400d289 100644 --- a/pkg/relay/priority_test.go +++ b/pkg/relay/priority_test.go @@ -9,7 +9,6 @@ import ( "github.com/floatdrop/moq-go/pkg/moqt/message" "github.com/floatdrop/moq-go/pkg/moqt/session" - "github.com/floatdrop/moq-go/pkg/moqt/wire" "github.com/floatdrop/moq-go/pkg/relay" ) @@ -133,7 +132,7 @@ func TestFanout_AppliesEffectivePriorityOnStreamOpen(t *testing.T) { } const publisherAlias = uint64(7) pubReq, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), TrackAlias: publisherAlias, }) @@ -155,7 +154,7 @@ func TestFanout_AppliesEffectivePriorityOnStreamOpen(t *testing.T) { t.Fatalf("subscriber session.Client: %v", err) } subReq, err := subSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), Parameters: message.Parameters{ message.SubscriberPriorityParam(42), @@ -168,7 +167,7 @@ func TestFanout_AppliesEffectivePriorityOnStreamOpen(t *testing.T) { // Drain the subscriber's accept-side in the background so the relay // can complete its OpenSubgroup synchronously. - go drainAllStreams(t.Context(), subSess) + go drainAll(t.Context(), subSess) pubSubgroup, err := pubSess.OpenSubgroup(message.SubgroupHeader{ SubgroupIDMode: message.SubgroupIDExplicit, diff --git a/pkg/relay/publish_done_code_test.go b/pkg/relay/publish_done_code_test.go deleted file mode 100644 index 1d7cbac0..00000000 --- a/pkg/relay/publish_done_code_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package relay_test - -import ( - "fmt" - "testing" - - "github.com/floatdrop/moq-go/pkg/moqt" - "github.com/floatdrop/moq-go/pkg/relay" -) - -// TestPublishDone_UpstreamCodeByMeaning: §10.12 "The application SHOULD use a -// relevant status code in PUBLISH_DONE". When the track's last upstream ends, -// a code about the track reaches the downstream subscribers as is; one about -// the relay's own upstream subscription (it fell behind, its update failed, -// ...) says nothing true about theirs, and becomes INTERNAL_ERROR. -func TestPublishDone_UpstreamCodeByMeaning(t *testing.T) { - t.Parallel() - for _, tc := range []struct { - upstream, want moqt.PublishDoneCode - }{ - {moqt.PublishDoneTrackEnded, moqt.PublishDoneTrackEnded}, - {moqt.PublishDoneMalformedTrack, moqt.PublishDoneMalformedTrack}, - {moqt.PublishDoneInternalError, moqt.PublishDoneInternalError}, - {moqt.PublishDoneTooFarBehind, moqt.PublishDoneInternalError}, - {moqt.PublishDoneUpdateFailed, moqt.PublishDoneInternalError}, - {moqt.PublishDoneUnauthorized, moqt.PublishDoneInternalError}, - } { - t.Run(fmt.Sprintf("%#x", uint64(tc.upstream)), func(t *testing.T) { - t.Parallel() - pubSess, teardown := connectRelay(t, relay.Config{}) - defer teardown() - pub := publishVideoTrack(t, pubSess, "cam1", 1) - subReq := subscribeCam1Req(t, dialAnotherClient(t, pubSess)) - if err := pub.Done(tc.upstream, "upstream says"); err != nil { - t.Fatalf("Done: %v", err) - } - if pd := awaitPublishDone(t, subReq); pd.StatusCode != tc.want { - t.Fatalf("downstream PUBLISH_DONE %#x, want %#x for an upstream %#x", - pd.StatusCode, tc.want, tc.upstream) - } - }) - } -} diff --git a/pkg/relay/publish_done_count_test.go b/pkg/relay/publish_done_count_test.go deleted file mode 100644 index 0b547d10..00000000 --- a/pkg/relay/publish_done_count_test.go +++ /dev/null @@ -1,121 +0,0 @@ -package relay_test - -import ( - "testing" - "time" - - "github.com/floatdrop/moq-go/pkg/moqt/message" - "github.com/floatdrop/moq-go/pkg/moqt/session" - "github.com/floatdrop/moq-go/pkg/moqt/wire" -) - -// §10.12 PUBLISH_DONE Stream Count: "the number of data streams the publisher -// opened for this subscription, including streams that contained no Objects -// ... and including any fill fetch streams". "If the publisher did not open any -// streams for this subscription, the publisher MUST set Stream Count to 0. If -// the publisher is unable to set Stream Count to the exact number of streams -// opened for the subscription, it MUST set Stream Count to 2^64 - 1." These -// tests pin the relay, acting as publisher to its subscribers, to the exact -// count on each kind of stream it opens. - -// awaitPublishDone reads the next control message on a subscription's request -// stream and requires it to be PUBLISH_DONE. -func awaitPublishDone(t *testing.T, sub *session.Subscription) *message.PublishDone { - t.Helper() - got := make(chan message.Message, 1) - go func() { - msg, _ := message.Parse(sub) - got <- msg - }() - select { - case msg := <-got: - pd, ok := msg.(*message.PublishDone) - if !ok { - t.Fatalf("got %T on the subscription stream, want *message.PublishDone", msg) - } - return pd - case <-time.After(2 * time.Second): - t.Fatal("no PUBLISH_DONE on the subscription stream") - return nil - } -} - -func subscribeCam1Req(t *testing.T, subSess *session.Session, params ...message.Parameter) *session.Subscription { - t.Helper() - subReq, err := subSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, - Name: []byte("cam1"), - Parameters: params, - }) - if err != nil { - t.Fatalf("Subscribe: %v", err) - } - t.Cleanup(func() { subReq.Close() }) - return subReq -} - -func TestPublishDone_StreamCount_NoStreams(t *testing.T) { - t.Parallel() - pubSess, _ := publishWithTrackProps(t, nil) - subReq := subscribeCam1Req(t, dialAnotherClient(t, pubSess)) - - _ = pubSess.Close(0, "publisher leaving") - if pd := awaitPublishDone(t, subReq); pd.StreamCount != 0 { - t.Errorf("StreamCount = %d, want 0 (no streams opened)", pd.StreamCount) - } -} - -func TestPublishDone_StreamCount_SubgroupStreams(t *testing.T) { - t.Parallel() - pubSess, alias := publishWithTrackProps(t, nil) - subSess := dialAnotherClient(t, pubSess) - subReq := subscribeCam1Req(t, subSess) - - for _, group := range []uint64{3, 4} { - publishSubgroupObject(t, pubSess, alias, group, -1) - if !awaitSubgroupObject(t, subSess, 2*time.Second) { - t.Fatalf("group %d was not forwarded", group) - } - } - - _ = pubSess.Close(0, "publisher leaving") - if pd := awaitPublishDone(t, subReq); pd.StreamCount != 2 { - t.Errorf("StreamCount = %d, want 2 (one subgroup stream per group)", pd.StreamCount) - } -} - -func TestPublishDone_StreamCount_FillStream(t *testing.T) { - t.Parallel() - pubSess, alias := publishWithTrackProps(t, nil) - // A live subscriber first, so the relay caches group 3 for the fill. - liveSess := dialAnotherClient(t, pubSess) - liveReq := subscribeCam1Req(t, liveSess) - // Read the live subscription's own PUBLISH_DONE: the relay notifies - // subscribers one at a time, and an unread one blocks the unbuffered - // test pipe before it reaches the fill subscriber. - go func() { _, _ = message.Parse(liveReq) }() - publishSubgroupObject(t, pubSess, alias, 3, -1) - if !awaitSubgroupObject(t, liveSess, 2*time.Second) { - t.Fatal("group 3 was not forwarded") - } - - subSess := dialAnotherClient(t, pubSess) - subReq := subscribeCam1Req(t, subSess, - message.NextObjectFilter(), - message.FillParametersParam(message.Parameters{message.UnfilteredFilter()}), - ) - ds, err := subSess.AcceptDataStream(t.Context()) - if err != nil { - t.Fatalf("AcceptDataStream: %v", err) - } - fs, ok := ds.(*session.IncomingFetchStream) - if !ok { - t.Fatalf("got %T, want the fill *IncomingFetchStream", ds) - } - _ = decodeFetchStream(t, fs, message.GroupOrderAscending) // to FIN - - _ = pubSess.Close(0, "publisher leaving") - if pd := awaitPublishDone(t, subReq); pd.StreamCount != 1 { - t.Errorf("StreamCount = %d, want 1 (the fill fetch stream)", pd.StreamCount) - } -} diff --git a/pkg/relay/publish_done_timing_test.go b/pkg/relay/publish_done_test.go similarity index 61% rename from pkg/relay/publish_done_timing_test.go rename to pkg/relay/publish_done_test.go index 838e3666..33cfc4fc 100644 --- a/pkg/relay/publish_done_timing_test.go +++ b/pkg/relay/publish_done_test.go @@ -2,6 +2,7 @@ package relay_test import ( "errors" + "fmt" "io" "math" "testing" @@ -13,10 +14,115 @@ import ( "github.com/floatdrop/moq-go/pkg/relay" ) -// TestPublishDone_AfterStreamsClose pins §10.12: "A sender MUST NOT send -// PUBLISH_DONE until it has closed all streams it will ever open". The -// publisher ends the track while its subgroup stream is still open, so the -// relay's copy to the subscriber is open too; PUBLISH_DONE must wait for it. +// PUBLISH_DONE (§10.12) as the relay sends it downstream: its status code, +// its Stream Count, and its timing after every stream closes. + +// TestPublishDone_UpstreamCodeByMeaning: an upstream PUBLISH_DONE code about +// the track is passed downstream as is; one about the relay's own upstream +// subscription becomes INTERNAL_ERROR (§10.12). +func TestPublishDone_UpstreamCodeByMeaning(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + upstream, want moqt.PublishDoneCode + }{ + {moqt.PublishDoneTrackEnded, moqt.PublishDoneTrackEnded}, + {moqt.PublishDoneMalformedTrack, moqt.PublishDoneMalformedTrack}, + {moqt.PublishDoneInternalError, moqt.PublishDoneInternalError}, + {moqt.PublishDoneTooFarBehind, moqt.PublishDoneInternalError}, + {moqt.PublishDoneUpdateFailed, moqt.PublishDoneInternalError}, + {moqt.PublishDoneUnauthorized, moqt.PublishDoneInternalError}, + } { + t.Run(fmt.Sprintf("%#x", uint64(tc.upstream)), func(t *testing.T) { + t.Parallel() + pubSess, teardown := connectRelay(t, relay.Config{}) + defer teardown() + pub := publishVideoTrack(t, pubSess, "cam1", 1) + subReq := subscribeCam1(t, dialAnotherClient(t, pubSess)) + if err := pub.Done(tc.upstream, "upstream says"); err != nil { + t.Fatalf("Done: %v", err) + } + if pd := awaitPublishDone(t, subReq); pd.StatusCode != tc.want { + t.Fatalf("downstream PUBLISH_DONE %#x, want %#x for an upstream %#x", + pd.StatusCode, tc.want, tc.upstream) + } + }) + } +} + +// The StreamCount tests pin the exact count of data streams the relay opened +// for the subscription, fill fetch streams included (§10.12). + +// TestPublishDone_StreamCount_NoStreams: no streams opened, count 0. +func TestPublishDone_StreamCount_NoStreams(t *testing.T) { + t.Parallel() + pubSess, _ := newCam1Publisher(t, nil) + subReq := subscribeCam1(t, dialAnotherClient(t, pubSess)) + + _ = pubSess.Close(0, "publisher leaving") + if pd := awaitPublishDone(t, subReq); pd.StreamCount != 0 { + t.Errorf("StreamCount = %d, want 0 (no streams opened)", pd.StreamCount) + } +} + +// TestPublishDone_StreamCount_SubgroupStreams: one subgroup stream per group. +func TestPublishDone_StreamCount_SubgroupStreams(t *testing.T) { + t.Parallel() + pubSess, alias := newCam1Publisher(t, nil) + subSess := dialAnotherClient(t, pubSess) + subReq := subscribeCam1(t, subSess) + + for _, group := range []uint64{3, 4} { + publishObjects(t, pubSess, alias, group, 1) + if !awaitSubgroupObject(t, subSess, 2*time.Second) { + t.Fatalf("group %d was not forwarded", group) + } + } + + _ = pubSess.Close(0, "publisher leaving") + if pd := awaitPublishDone(t, subReq); pd.StreamCount != 2 { + t.Errorf("StreamCount = %d, want 2 (one subgroup stream per group)", pd.StreamCount) + } +} + +// TestPublishDone_StreamCount_FillStream: the fill fetch stream counts. +func TestPublishDone_StreamCount_FillStream(t *testing.T) { + t.Parallel() + pubSess, alias := newCam1Publisher(t, nil) + // A live subscriber first, so the relay caches group 3 for the fill. + liveSess := dialAnotherClient(t, pubSess) + liveReq := subscribeCam1(t, liveSess) + // Read the live subscription's own PUBLISH_DONE: the relay notifies + // subscribers one at a time, and an unread one blocks the unbuffered + // test pipe before it reaches the fill subscriber. + go func() { _, _ = message.Parse(liveReq) }() + publishObjects(t, pubSess, alias, 3, 1) + if !awaitSubgroupObject(t, liveSess, 2*time.Second) { + t.Fatal("group 3 was not forwarded") + } + + subSess := dialAnotherClient(t, pubSess) + subReq := subscribeCam1(t, subSess, + message.NextObjectFilter(), + message.FillParametersParam(message.Parameters{message.UnfilteredFilter()}), + ) + ds, err := subSess.AcceptDataStream(t.Context()) + if err != nil { + t.Fatalf("AcceptDataStream: %v", err) + } + fs, ok := ds.(*session.IncomingFetchStream) + if !ok { + t.Fatalf("got %T, want the fill *IncomingFetchStream", ds) + } + _ = decodeFetchStream(t, fs, message.GroupOrderAscending) // to FIN + + _ = pubSess.Close(0, "publisher leaving") + if pd := awaitPublishDone(t, subReq); pd.StreamCount != 1 { + t.Errorf("StreamCount = %d, want 1 (the fill fetch stream)", pd.StreamCount) + } +} + +// TestPublishDone_AfterStreamsClose: PUBLISH_DONE waits for the relay's open +// subgroup stream to the subscriber to close (§10.12). func TestPublishDone_AfterStreamsClose(t *testing.T) { t.Parallel() const stillOpen = 300 * time.Millisecond @@ -25,7 +131,7 @@ func TestPublishDone_AfterStreamsClose(t *testing.T) { defer teardown() pub := publishVideoTrack(t, pubSess, "cam1", 1) subSess := dialAnotherClient(t, pubSess) - subReq := subscribeCam1Req(t, subSess) + subReq := subscribeCam1(t, subSess) sg, err := pub.OpenSubgroup(message.SubgroupHeader{SubgroupIDMode: message.SubgroupIDExplicit}) if err != nil { @@ -78,27 +184,24 @@ func TestPublishDone_AfterStreamsClose(t *testing.T) { } } -// TestPublishDone_EndedSubscriptionStopsAtNextObject: a subscription the -// relay ends while its upstream stays live (UPDATE_FAILED, §10.9) takes no -// further Object. Its open stream is reset at the next Object, so its -// PUBLISH_DONE, which waits for the stream (§10.12), is not held for as long -// as the upstream subgroup runs. +// TestPublishDone_EndedSubscriptionStopsAtNextObject: a subscription ended with +// UPDATE_FAILED (§10.9) while its upstream stays live has its open stream reset +// at the next Object, so PUBLISH_DONE is not held while the upstream runs. func TestPublishDone_EndedSubscriptionStopsAtNextObject(t *testing.T) { t.Parallel() pubSess, teardown := connectRelay(t, relay.Config{}) defer teardown() pub := publishVideoTrack(t, pubSess, "cam1", 1) subSess := dialAnotherClient(t, pubSess) - subReq := subscribeCam1Req(t, subSess) + subReq := subscribeCam1(t, subSess) sg, err := pub.OpenSubgroup(message.SubgroupHeader{SubgroupIDMode: message.SubgroupIDExplicit}) if err != nil { t.Fatalf("OpenSubgroup: %v", err) } - // One writer: Object 0, then, once the update is refused, an Object every - // 50ms, as a live upstream would, until the test ends. The relay latches - // the termination just after it sends REQUEST_ERROR, so the first Object - // after the refusal can still beat it; a later one cannot. + // Object 0, then, once the update is refused, an Object every 50ms as a + // live upstream would. The first one after the refusal may still beat + // the relay's termination; a later one cannot. rejected := make(chan struct{}) stop := make(chan struct{}) defer close(stop) @@ -162,7 +265,7 @@ func TestPublishDone_AfterGapReopen(t *testing.T) { defer teardown() pub := publishVideoTrack(t, pubSess, "cam1", 1) subSess := dialAnotherClient(t, pubSess) - subReq := subscribeCam1Req(t, subSess) + subReq := subscribeCam1(t, subSess) sg, err := pub.OpenSubgroup(message.SubgroupHeader{SubgroupIDMode: message.SubgroupIDExplicit}) if err != nil { @@ -222,7 +325,7 @@ func TestPublishDone_AfterDeliveryTimeout(t *testing.T) { defer teardown() pub := publishVideoTrack(t, pubSess, "cam1", 1) subSess := dialAnotherClient(t, pubSess) - subReq := subscribeCam1Req(t, subSess, message.ObjectDeliveryTimeoutParam(timeout)) + subReq := subscribeCam1(t, subSess, message.ObjectDeliveryTimeoutParam(timeout)) sg, err := pub.OpenSubgroup(message.SubgroupHeader{SubgroupIDMode: message.SubgroupIDExplicit}) if err != nil { @@ -272,23 +375,16 @@ func TestPublishDone_AfterFailedFill(t *testing.T) { pub := publishVideoTrack(t, pubSess, "cam1", 1) // Content first, so a fill has something to cover. cacher := dialAnotherClient(t, pubSess) - subscribeCam1Req(t, cacher) - sg, err := pub.OpenSubgroup(message.SubgroupHeader{SubgroupIDMode: message.SubgroupIDExplicit}) - if err != nil { - t.Fatalf("OpenSubgroup: %v", err) - } - go func() { - _ = sg.WriteObject(&message.SubgroupObject{Payload: []byte("x")}) - _ = sg.Close() - }() + subscribeCam1(t, cacher) + publishSubgroupWith(t, pub, 0, 1, nil) if !awaitSubgroupObject(t, cacher, 2*time.Second) { t.Fatal("the object never reached the relay") } - // A LOCATION_FILTER inside FILL_PARAMETERS that does not parse (five - // fields) fails the fill after SUBSCRIBE_OK. + // A five-field LOCATION_FILTER in FILL_PARAMETERS does not parse, which + // fails the fill after SUBSCRIBE_OK. subSess := dialAnotherClient(t, pubSess) - subReq := subscribeCam1Req(t, subSess, message.FillParametersParam(message.Parameters{ + subReq := subscribeCam1(t, subSess, message.FillParametersParam(message.Parameters{ message.BytesParam(message.ParamLocationFilter, []byte{0, 0, 0, 0, 0}), })) ds, err := subSess.AcceptDataStream(t.Context()) diff --git a/pkg/relay/publish_forward_test.go b/pkg/relay/publish_forward_test.go deleted file mode 100644 index 2bb8a402..00000000 --- a/pkg/relay/publish_forward_test.go +++ /dev/null @@ -1,97 +0,0 @@ -package relay_test - -import ( - "testing" - "time" - - "github.com/floatdrop/moq-go/pkg/moqt/message" - "github.com/floatdrop/moq-go/pkg/moqt/session" - "github.com/floatdrop/moq-go/pkg/moqt/wire" - "github.com/floatdrop/moq-go/pkg/relay" -) - -// §9.5: "When it receives an authorized PUBLISH message for a Track that has -// Established downstream subscriptions, it MUST respond with PUBLISH_OK. If at -// least one downstream subscriber for the Track has Forward State=1, the Relay -// MUST change the Forward State to 1 with REQUEST_UPDATE." §9.2 makes the same -// true when a Forward=1 subscriber arrives later. - -// pausedPublish PUBLISHes video/cam1 with FORWARD=0 from a new session and -// reports each FORWARD value the relay sends back in REQUEST_UPDATE. -func pausedPublish(t *testing.T, via *session.Session) <-chan bool { - t.Helper() - pubSess := dialAnotherClient(t, via) - pub, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("video")}, - Name: []byte("cam1"), - Parameters: message.Parameters{message.ForwardParam(false)}, - }) - if err != nil { - t.Fatalf("Publish FORWARD=0: %v", err) - } - t.Cleanup(func() { _ = pub.Close() }) - forwards := make(chan bool, 4) - b := pub.Broker() - go func() { - _ = b.Serve(t.Context(), func(m message.Message) bool { - if upd, ok := m.(*message.RequestUpdate); ok { - if f, found := upd.Parameters.Find(message.ParamForward); found { - forwards <- f.Byte == 1 - } - } - return true - }) - }() - return forwards -} - -func requireForwardOn(t *testing.T, forwards <-chan bool, when string) { - t.Helper() - select { - case on := <-forwards: - if !on { - t.Fatalf("relay sent FORWARD=0 %s, want FORWARD=1", when) - } - case <-time.After(2 * time.Second): - t.Fatalf("relay never sent REQUEST_UPDATE FORWARD=1 %s", when) - } -} - -// TestRelay_PausedPublishResumedForExistingSubscriber: a Forward=1 subscriber -// already exists when a publisher PUBLISHes the track with FORWARD=0. -func TestRelay_PausedPublishResumedForExistingSubscriber(t *testing.T) { - t.Parallel() - pubSess, _ := publishWithTrackProps(t, nil) // establishes the track - _ = subscribeCam1(t, pubSess) // Forward State 1 - forwards := pausedPublish(t, pubSess) - requireForwardOn(t, forwards, "for a PUBLISH with an existing Forward=1 subscriber") -} - -// TestRelay_PausedPublishResumedForLaterSubscriber: the Forward=1 subscriber -// arrives after the FORWARD=0 PUBLISH. -func TestRelay_PausedPublishResumedForLaterSubscriber(t *testing.T) { - t.Parallel() - anchor, teardown := connectRelay(t, relay.Config{}) - defer teardown() - forwards := pausedPublish(t, anchor) - _ = subscribeCam1(t, anchor) - requireForwardOn(t, forwards, "when a Forward=1 subscriber joined") -} - -// TestRelay_PublishInvalidForwardClosesSession pins §10.2.18 for PUBLISH: a -// FORWARD value other than 0 or 1 "MUST close the session with -// PROTOCOL_VIOLATION". -func TestRelay_PublishInvalidForwardClosesSession(t *testing.T) { - t.Parallel() - anchor, teardown := connectRelay(t, relay.Config{}) - defer teardown() - pubSess := dialAnotherClient(t, anchor) - go func() { - _, _ = pubSess.Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("video")}, - Name: []byte("cam1"), - Parameters: message.Parameters{message.ByteParam(message.ParamForward, 2)}, - }) - }() - requireSessionClosed(t, pubSess, "a PUBLISH with FORWARD=2") -} diff --git a/pkg/relay/publish_skipped_test.go b/pkg/relay/publish_skipped_test.go index ca35a67c..06604e3f 100644 --- a/pkg/relay/publish_skipped_test.go +++ b/pkg/relay/publish_skipped_test.go @@ -6,7 +6,6 @@ import ( "github.com/floatdrop/moq-go/pkg/moqt/message" "github.com/floatdrop/moq-go/pkg/moqt/session" - "github.com/floatdrop/moq-go/pkg/moqt/wire" "github.com/floatdrop/moq-go/pkg/relay" "github.com/floatdrop/moq-go/pkg/relay/internal/relaytest" ) @@ -29,7 +28,7 @@ func TestPublishSkipped_EmittedWhenSubscriberOutOfStreamCredit(t *testing.T) { subSess := dialAnotherClientWithLimits(t, primary, -1 /*client*/, 0 /*server*/) subStream, err := subSess.SubscribeTracks(t.Context(), &message.SubscribeTracks{ - TrackNamespacePrefix: wire.TrackNamespace{[]byte("video")}, + TrackNamespacePrefix: ns("video"), }) if err != nil { t.Fatalf("SubscribeTracks: %v", err) @@ -38,7 +37,7 @@ func TestPublishSkipped_EmittedWhenSubscriberOutOfStreamCredit(t *testing.T) { pubSess := dialAnotherClient(t, primary) pubStream, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("video"), []byte("cam7")}, + Namespace: ns("video", "cam7"), Name: []byte("rtp"), TrackAlias: 99, }) @@ -89,7 +88,7 @@ func TestPublishSkipped_NotStickyAcrossRePublish(t *testing.T) { subSess := dialAnotherClientWithLimits(t, primary, -1 /*client*/, 0 /*server*/) subStream, err := subSess.SubscribeTracks(t.Context(), &message.SubscribeTracks{ - TrackNamespacePrefix: wire.TrackNamespace{[]byte("video")}, + TrackNamespacePrefix: ns("video"), }) if err != nil { t.Fatalf("SubscribeTracks: %v", err) @@ -98,7 +97,7 @@ func TestPublishSkipped_NotStickyAcrossRePublish(t *testing.T) { pub := func() session.Stream { s, err := dialAnotherClient(t, primary).Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("video"), []byte("cam7")}, + Namespace: ns("video", "cam7"), Name: []byte("rtp"), TrackAlias: 99, }) diff --git a/pkg/relay/rangefilter_relay_test.go b/pkg/relay/rangefilter_relay_test.go index 5db34b7e..b5898b6b 100644 --- a/pkg/relay/rangefilter_relay_test.go +++ b/pkg/relay/rangefilter_relay_test.go @@ -1,10 +1,12 @@ package relay_test import ( + "context" "errors" "io" "math" "reflect" + "slices" "testing" "time" @@ -32,7 +34,7 @@ func TestSubscribe_RangeFilterProhibitedWhenConfiguredOff(t *testing.T) { defer teardown() pubReq, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("video")}, Name: []byte("cam1"), TrackAlias: 7, + Namespace: ns("video"), Name: []byte("cam1"), TrackAlias: 7, }) if err != nil { t.Fatalf("Publish: %v", err) @@ -41,7 +43,7 @@ func TestSubscribe_RangeFilterProhibitedWhenConfiguredOff(t *testing.T) { subSess := dialAnotherClient(t, pubSess) _, err = subSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), Parameters: message.Parameters{ message.RangeFilterParam(&message.RangeFilter{ @@ -64,7 +66,7 @@ func TestFanout_ObjectIDRangeFilter(t *testing.T) { const publisherAlias = uint64(7) pubReq, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("video")}, Name: []byte("cam1"), TrackAlias: publisherAlias, + Namespace: ns("video"), Name: []byte("cam1"), TrackAlias: publisherAlias, }) if err != nil { t.Fatalf("Publish: %v", err) @@ -73,7 +75,7 @@ func TestFanout_ObjectIDRangeFilter(t *testing.T) { subSess := dialAnotherClient(t, pubSess) subReq, err := subSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), Parameters: message.Parameters{ message.RangeFilterParam(&message.RangeFilter{ @@ -166,7 +168,7 @@ func TestSubscribeTracks_TrackPropertyFilter(t *testing.T) { const propType = 0x40 // even → single-integer Track Property subStream, err := subSess.SubscribeTracks(t.Context(), &message.SubscribeTracks{ - TrackNamespacePrefix: wire.TrackNamespace{[]byte("video")}, + TrackNamespacePrefix: ns("video"), Parameters: message.Parameters{ message.RangeFilterParam(&message.RangeFilter{ Type: message.ParamTrackPropertyFilter, PropertyType: propType, @@ -229,7 +231,7 @@ func TestFetch_ObjectIDRangeFilter(t *testing.T) { const publisherAlias = uint64(7) pubReq, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("video")}, Name: []byte("cam1"), TrackAlias: publisherAlias, + Namespace: ns("video"), Name: []byte("cam1"), TrackAlias: publisherAlias, }) if err != nil { t.Fatalf("Publish: %v", err) @@ -239,20 +241,20 @@ func TestFetch_ObjectIDRangeFilter(t *testing.T) { // An unfiltered subscriber lets the fanout accept + cache the objects. subSess := dialAnotherClient(t, pubSess) subReq, err := subSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, Name: []byte("cam1"), + Namespace: ns("video"), Name: []byte("cam1"), }) if err != nil { t.Fatalf("Subscribe: %v", err) } defer subReq.Close() - go drainAllStreams(t.Context(), subSess) + go drainAll(t.Context(), subSess) publishObjects(t, pubSess, publisherAlias, 0 /*group*/, 4 /*count → IDs 0..3*/) time.Sleep(50 * time.Millisecond) // let the cache settle fetchSess := dialAnotherClient(t, pubSess) _, objs := fetchAndDrain(t, fetchSess, - wire.TrackNamespace{[]byte("video")}, []byte("cam1"), + ns("video"), []byte("cam1"), message.Location{Group: 0, Object: 0}, message.Location{Group: 0, Object: 3}, message.GroupOrderAscending, message.RangeFilterParam(&message.RangeFilter{ @@ -294,7 +296,7 @@ func TestFetch_SubgroupFilterSelectsOneLayer(t *testing.T) { const publisherAlias = uint64(7) pubReq, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("video")}, Name: []byte("cam1"), TrackAlias: publisherAlias, + Namespace: ns("video"), Name: []byte("cam1"), TrackAlias: publisherAlias, }) if err != nil { t.Fatalf("Publish: %v", err) @@ -304,13 +306,13 @@ func TestFetch_SubgroupFilterSelectsOneLayer(t *testing.T) { // An unfiltered subscriber lets the fanout accept + cache the objects. subSess := dialAnotherClient(t, pubSess) subReq, err := subSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, Name: []byte("cam1"), + Namespace: ns("video"), Name: []byte("cam1"), }) if err != nil { t.Fatalf("Subscribe: %v", err) } defer subReq.Close() - go drainAllStreams(t.Context(), subSess) + go drainAll(t.Context(), subSess) // Each layer numbers its objects from its own base, because IDs must be // unique within a group and consecutive within a subgroup at once. Base @@ -322,7 +324,7 @@ func TestFetch_SubgroupFilterSelectsOneLayer(t *testing.T) { fetchSess := dialAnotherClient(t, pubSess) _, objs := fetchAndDrain(t, fetchSess, - wire.TrackNamespace{[]byte("video")}, []byte("cam1"), + ns("video"), []byte("cam1"), message.Location{Group: 0, Object: 0}, message.Location{Group: 0, Object: math.MaxUint64}, // whole group message.GroupOrderAscending, message.RangeFilterParam(&message.RangeFilter{ @@ -371,3 +373,172 @@ func publishLayer( t.Fatalf("sg.Close g=%d sg=%d: %v", group, subgroup, err) } } + +// A zero-length Range Filter is no filter; in REQUEST_UPDATE it removes that +// filter type, a non-zero one replaces it, and an omitted one is unchanged +// (§5.1.4). + +// TestSubscribe_ZeroLengthRangeFilterIsNoFilter: a zero-length Range Filter on +// SUBSCRIBE is accepted as no filter. +func TestSubscribe_ZeroLengthRangeFilterIsNoFilter(t *testing.T) { + t.Parallel() + pubSess, teardown := connectRelay(t, relay.Config{}) + defer teardown() + publishVideoTrack(t, pubSess, "cam1", 1) + subSess := dialAnotherClient(t, pubSess) + subscribeCam1(t, subSess, message.BytesParam(message.ParamObjectIDFilter, nil)) +} + +// TestRequestUpdate_ZeroLengthRemovesOneRangeFilterType: an update removing +// OBJECTID_FILTER lets every Object through again, while the SUBGROUP_FILTER it +// does not name still holds. +func TestRequestUpdate_ZeroLengthRemovesOneRangeFilterType(t *testing.T) { + t.Parallel() + pubSess, teardown := connectRelay(t, relay.Config{}) + defer teardown() + pub := publishVideoTrack(t, pubSess, "cam1", 1) + subSess := dialAnotherClient(t, pubSess) + subReq := subscribeCam1(t, subSess, + message.RangeFilterParam(&message.RangeFilter{ + Type: message.ParamObjectIDFilter, Ranges: []message.Range{{Start: 1, End: 1}}, + }), + message.RangeFilterParam(&message.RangeFilter{ + Type: message.ParamSubgroupFilter, Ranges: []message.Range{{Start: 0, End: 0}}, + }), + ) + if _, err := subSess.UpdateRequest(t.Context(), subReq, + message.Parameters{message.BytesParam(message.ParamObjectIDFilter, nil)}); err != nil { + t.Fatalf("UpdateRequest removing OBJECTID_FILTER: %v", err) + } + + // Subgroup 0 of Group 0: three Objects, all of which now pass. Subgroup 1 + // (in Group 1, so its Object IDs do not collide): still outside the + // SUBGROUP_FILTER. + for _, sgID := range []uint64{0, 1} { + sg, err := pub.OpenSubgroup(message.SubgroupHeader{ + SubgroupIDMode: message.SubgroupIDExplicit, GroupID: sgID, SubgroupID: sgID, + }) + if err != nil { + t.Fatalf("OpenSubgroup %d: %v", sgID, err) + } + go func() { + for range 3 { + if sg.WriteObject(&message.SubgroupObject{Payload: []byte("x")}) != nil { + return + } + } + _ = sg.Close() + }() + } + + acceptCtx, cancelAccept := context.WithTimeout(t.Context(), 2*time.Second) + defer cancelAccept() + ds, err := subSess.AcceptDataStream(acceptCtx) + if err != nil { + t.Fatalf("AcceptDataStream: %v (no stream for subgroup 0)", err) + } + in, ok := ds.(*session.IncomingSubgroupStream) + if !ok { + t.Fatalf("AcceptDataStream = %T, want a subgroup stream", ds) + } + if in.Header.SubgroupID != 0 { + t.Fatalf("got subgroup %d; the SUBGROUP_FILTER the update did not name admits only 0", in.Header.SubgroupID) + } + n := 0 + for { + if _, err := in.ReadObject(); err != nil { + break + } + n++ + } + if n != 3 { + t.Fatalf("subgroup 0 carried %d Objects after OBJECTID_FILTER was removed, want 3", n) + } + ctx, cancel := context.WithTimeout(t.Context(), 300*time.Millisecond) + defer cancel() + if ds, err := subSess.AcceptDataStream(ctx); err == nil { + t.Fatalf("got a second data stream (%T); subgroup 1 is outside the kept SUBGROUP_FILTER", ds) + } +} + +// TestRequestUpdate_RangeFilterLimitCountsMergedSet: MAX_FILTER_RANGES applies +// to the filters merged from an update, not the update alone (§5.1.4). +func TestRequestUpdate_RangeFilterLimitCountsMergedSet(t *testing.T) { + t.Parallel() + pubSess, teardown := connectRelay(t, relay.Config{MaxFilterRanges: 2}) + defer teardown() + publishVideoTrack(t, pubSess, "cam1", 1) + subSess := dialAnotherClient(t, pubSess) + subReq := subscribeCam1(t, subSess, + message.RangeFilterParam(&message.RangeFilter{ + Type: message.ParamSubgroupFilter, Ranges: []message.Range{{Start: 0, End: 0}}, + }), + message.RangeFilterParam(&message.RangeFilter{ + Type: message.ParamPriorityFilter, Ranges: []message.Range{{Start: 0, End: 10}}, + }), + ) + // One range on its own, a third one merged. + _, err := subSess.UpdateRequest(t.Context(), subReq, message.Parameters{ + message.RangeFilterParam(&message.RangeFilter{ + Type: message.ParamObjectIDFilter, Ranges: []message.Range{{Start: 1, End: 1}}, + }), + }) + requireRejectedWithCode(t, err, moqt.RequestInvalidFilter) +} + +// TestSubscribe_FillInheritsRangeFilters: the fill fetch stream inherits the +// subscription's Range Filters (§5.1.3); one inside FILL_PARAMETERS replaces or, +// zero-length, removes that type (§5.1.4). +func TestSubscribe_FillInheritsRangeFilters(t *testing.T) { + t.Parallel() + objectIDs := func(ranges ...message.Range) message.Parameter { + return message.RangeFilterParam(&message.RangeFilter{Type: message.ParamObjectIDFilter, Ranges: ranges}) + } + for _, tc := range []struct { + name string + inner message.Parameters // besides the whole-track Location filter + want []decodedFetchObject + }{ + {"inherited", nil, []decodedFetchObject{{group: 0, object: 1}}}, + {"overridden", message.Parameters{objectIDs(message.Range{Start: 2, End: 2})}, + []decodedFetchObject{{group: 0, object: 2}}}, + {"removed", message.Parameters{message.BytesParam(message.ParamObjectIDFilter, nil)}, + []decodedFetchObject{{group: 0, object: 0}, {group: 0, object: 1}, {group: 0, object: 2}, {group: 1, object: 0}}}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + pubSess, _, alias := publishAndCache(t) + publishObjects(t, pubSess, alias, 0, 3) + publishObjects(t, pubSess, alias, 1, 1) + time.Sleep(50 * time.Millisecond) + + subSess := dialAnotherClient(t, pubSess) + inner := append(message.Parameters{message.UnfilteredFilter()}, tc.inner...) + subscribeCam1(t, subSess, + message.NextObjectFilter(), + objectIDs(message.Range{Start: 1, End: 1}), + message.FillParametersParam(inner), + ) + + ds, err := subSess.AcceptDataStream(t.Context()) + if err != nil { + t.Fatalf("AcceptDataStream: %v", err) + } + fs, ok := ds.(*session.IncomingFetchStream) + if !ok { + t.Fatalf("got %T, want the fill stream", ds) + } + got := decodeFetchStream(t, fs, message.GroupOrderAscending) + ids := func(objs []decodedFetchObject) [][2]uint64 { + var out [][2]uint64 + for _, o := range objs { + out = append(out, [2]uint64{o.group, o.object}) + } + return out + } + if !slices.Equal(ids(got), ids(tc.want)) { + t.Fatalf("fill delivered %v, want %v", ids(got), ids(tc.want)) + } + }) + } +} diff --git a/pkg/relay/rangefilter_update_relay_test.go b/pkg/relay/rangefilter_update_relay_test.go deleted file mode 100644 index a53d03fc..00000000 --- a/pkg/relay/rangefilter_update_relay_test.go +++ /dev/null @@ -1,129 +0,0 @@ -package relay_test - -import ( - "context" - "testing" - "time" - - "github.com/floatdrop/moq-go/pkg/moqt" - "github.com/floatdrop/moq-go/pkg/moqt/message" - "github.com/floatdrop/moq-go/pkg/moqt/session" - "github.com/floatdrop/moq-go/pkg/relay" -) - -// §5.1.4: "When Length is 0, there is no filter and no further fields are -// present. This can be used in REQUEST_UPDATE to remove a filter." and "In -// REQUEST_UPDATE, Length of 0 removes the filter; non-zero replaces it -// entirely. If a filter parameter is omitted from REQUEST_UPDATE, it is -// unchanged." - -// TestSubscribe_ZeroLengthRangeFilterIsNoFilter: on a SUBSCRIBE a zero-length -// Range Filter is no filter, not a malformed one. -func TestSubscribe_ZeroLengthRangeFilterIsNoFilter(t *testing.T) { - t.Parallel() - pubSess, teardown := connectRelay(t, relay.Config{}) - defer teardown() - publishVideoTrack(t, pubSess, "cam1", 1) - subSess := dialAnotherClient(t, pubSess) - subscribeCam1Req(t, subSess, message.BytesParam(message.ParamObjectIDFilter, nil)) -} - -// TestRequestUpdate_ZeroLengthRemovesOneRangeFilterType: an update removing -// OBJECTID_FILTER lets every Object through again, while the SUBGROUP_FILTER it -// does not name still holds. -func TestRequestUpdate_ZeroLengthRemovesOneRangeFilterType(t *testing.T) { - t.Parallel() - pubSess, teardown := connectRelay(t, relay.Config{}) - defer teardown() - pub := publishVideoTrack(t, pubSess, "cam1", 1) - subSess := dialAnotherClient(t, pubSess) - subReq := subscribeCam1Req(t, subSess, - message.RangeFilterParam(&message.RangeFilter{ - Type: message.ParamObjectIDFilter, Ranges: []message.Range{{Start: 1, End: 1}}, - }), - message.RangeFilterParam(&message.RangeFilter{ - Type: message.ParamSubgroupFilter, Ranges: []message.Range{{Start: 0, End: 0}}, - }), - ) - if _, err := subSess.UpdateRequest(t.Context(), subReq, - message.Parameters{message.BytesParam(message.ParamObjectIDFilter, nil)}); err != nil { - t.Fatalf("UpdateRequest removing OBJECTID_FILTER: %v", err) - } - - // Subgroup 0 of Group 0: three Objects, all of which now pass. Subgroup 1 - // (in Group 1, so its Object IDs do not collide): still outside the - // SUBGROUP_FILTER. - for _, sgID := range []uint64{0, 1} { - sg, err := pub.OpenSubgroup(message.SubgroupHeader{ - SubgroupIDMode: message.SubgroupIDExplicit, GroupID: sgID, SubgroupID: sgID, - }) - if err != nil { - t.Fatalf("OpenSubgroup %d: %v", sgID, err) - } - go func() { - for range 3 { - if sg.WriteObject(&message.SubgroupObject{Payload: []byte("x")}) != nil { - return - } - } - _ = sg.Close() - }() - } - - acceptCtx, cancelAccept := context.WithTimeout(t.Context(), 2*time.Second) - defer cancelAccept() - ds, err := subSess.AcceptDataStream(acceptCtx) - if err != nil { - t.Fatalf("AcceptDataStream: %v (no stream for subgroup 0)", err) - } - in, ok := ds.(*session.IncomingSubgroupStream) - if !ok { - t.Fatalf("AcceptDataStream = %T, want a subgroup stream", ds) - } - if in.Header.SubgroupID != 0 { - t.Fatalf("got subgroup %d; the SUBGROUP_FILTER the update did not name admits only 0", in.Header.SubgroupID) - } - n := 0 - for { - if _, err := in.ReadObject(); err != nil { - break - } - n++ - } - if n != 3 { - t.Fatalf("subgroup 0 carried %d Objects after OBJECTID_FILTER was removed, want 3", n) - } - ctx, cancel := context.WithTimeout(t.Context(), 300*time.Millisecond) - defer cancel() - if ds, err := subSess.AcceptDataStream(ctx); err == nil { - t.Fatalf("got a second data stream (%T); subgroup 1 is outside the kept SUBGROUP_FILTER", ds) - } -} - -// TestRequestUpdate_RangeFilterLimitCountsMergedSet: MAX_FILTER_RANGES "limits -// the total number of Ranges allowed in all Range Filter parameters for a -// given subscription" (§5.1.4). After an update merges into the filters the -// subscription keeps, the limit applies to the merged set, not to the update -// alone. -func TestRequestUpdate_RangeFilterLimitCountsMergedSet(t *testing.T) { - t.Parallel() - pubSess, teardown := connectRelay(t, relay.Config{MaxFilterRanges: 2}) - defer teardown() - publishVideoTrack(t, pubSess, "cam1", 1) - subSess := dialAnotherClient(t, pubSess) - subReq := subscribeCam1Req(t, subSess, - message.RangeFilterParam(&message.RangeFilter{ - Type: message.ParamSubgroupFilter, Ranges: []message.Range{{Start: 0, End: 0}}, - }), - message.RangeFilterParam(&message.RangeFilter{ - Type: message.ParamPriorityFilter, Ranges: []message.Range{{Start: 0, End: 10}}, - }), - ) - // One range on its own, a third one merged. - _, err := subSess.UpdateRequest(t.Context(), subReq, message.Parameters{ - message.RangeFilterParam(&message.RangeFilter{ - Type: message.ParamObjectIDFilter, Ranges: []message.Range{{Start: 1, End: 1}}, - }), - }) - requireRejectedWithCode(t, err, moqt.RequestInvalidFilter) -} diff --git a/pkg/relay/relay_bench_test.go b/pkg/relay/relay_bench_test.go index e1dd49b9..4091296c 100644 --- a/pkg/relay/relay_bench_test.go +++ b/pkg/relay/relay_bench_test.go @@ -26,7 +26,6 @@ import ( "github.com/floatdrop/moq-go/pkg/moqt/message" "github.com/floatdrop/moq-go/pkg/moqt/session" - "github.com/floatdrop/moq-go/pkg/moqt/wire" "github.com/floatdrop/moq-go/pkg/relay" ) @@ -46,7 +45,7 @@ const ( ) var ( - benchNS = wire.TrackNamespace{[]byte("video")} + benchNS = ns("video") benchName = []byte("cam1") ) diff --git a/pkg/relay/relay_test.go b/pkg/relay/relay_test.go index f408d8e2..024794e3 100644 --- a/pkg/relay/relay_test.go +++ b/pkg/relay/relay_test.go @@ -5,125 +5,14 @@ import ( "errors" "net" "sync" - "sync/atomic" "testing" "time" "github.com/floatdrop/moq-go/pkg/moqt/session" - "github.com/floatdrop/moq-go/pkg/moqt/session/sessiontest" "github.com/floatdrop/moq-go/pkg/relay" "github.com/floatdrop/moq-go/pkg/relay/discovery" ) -// pipeListener is an in-process [relay.Listener] backed by [sessiontest]. -// Each call to Dial returns the client-side conn and pushes the server-side -// conn into a queue Accept consumes. Closing the listener stops Accept with -// [net.ErrClosed] so the relay treats it as a clean shutdown. -type pipeListener struct { - conns chan session.Conn - done chan struct{} - - // closeDelay stalls Close, modelling a real listener whose socket teardown - // is not instantaneous. Stop calls Close first, so this delays the GOAWAY - // broadcast behind it — which makes a session that is racing to tear itself - // down (because it wrongly inherited a cancelled context) lose the race - // deterministically instead of ~half the time. - closeDelay time.Duration - - // faultFor, when non-nil, is consulted for each server-side conn in dial - // order from 1; a non-nil return wraps that conn in [sessiontest.Faulty]. - // Wrapping the relay's side is the only way to reach its "write failed" - // branches, because an in-process pipe never fails a write on its own, and - // selecting by dial ordinal keeps a test from faulting the other clients - // it needs working. See [faultConn] for the common single-client case. - faultFor func(conn int) sessiontest.FaultFunc - - dialled atomic.Int64 -} - -// faultConn builds a [pipeListener.faultFor] that faults only the nth dialled -// conn, counting from 1 — connectRelay's client is 1 and each subsequent -// dialAnotherClient takes the next ordinal. -func faultConn(n int, fault sessiontest.FaultFunc) func(int) sessiontest.FaultFunc { - return func(conn int) sessiontest.FaultFunc { - if conn == n { - return fault - } - return nil - } -} - -func newPipeListener() *pipeListener { - return &pipeListener{ - conns: make(chan session.Conn, 4), - done: make(chan struct{}), - } -} - -// Dial creates a fresh conn pair, queues the server side for Accept, and -// returns the client side to the caller. Returns an error if the listener is -// closed. -func (l *pipeListener) Dial() (session.Conn, error) { - return l.DialWithLimits(-1, -1) -} - -// DialWithLimits is [pipeListener.Dial] with explicit bidi-stream credit caps. -// clientBidi caps the dialled client's outbound bidi credit; serverBidi caps -// the relay-side (server) conn's outbound bidi credit toward this client — -// the latter is what bounds how many PUBLISH streams the relay can open to a -// SUBSCRIBE_TRACKS subscriber, the PUBLISH_SKIPPED (§10.21) trigger. A -// negative limit means unlimited. -func (l *pipeListener) DialWithLimits(clientBidi, serverBidi int) (session.Conn, error) { - clientConn, serverConn := sessiontest.NewConnPairWithLimits(clientBidi, serverBidi) - if l.faultFor != nil { - if fault := l.faultFor(int(l.dialled.Add(1))); fault != nil { - serverConn = sessiontest.Faulty(serverConn, fault) - } - } - select { - case l.conns <- serverConn: - return clientConn, nil - case <-l.done: - return nil, net.ErrClosed - } -} - -func (l *pipeListener) Accept(ctx context.Context) (session.Conn, error) { - select { - case c := <-l.conns: - return c, nil - case <-ctx.Done(): - return nil, ctx.Err() - case <-l.done: - return nil, net.ErrClosed - } -} - -func (l *pipeListener) Addr() net.Addr { return nil } - -// isClosed reports whether Close has run, so a test can assert what had already -// happened at the moment some other shutdown step ran. -func (l *pipeListener) isClosed() bool { - select { - case <-l.done: - return true - default: - return false - } -} - -func (l *pipeListener) Close() error { - if l.closeDelay > 0 { - time.Sleep(l.closeDelay) - } - select { - case <-l.done: - default: - close(l.done) - } - return nil -} - // TestRelay_StartStopNoSessions verifies the relay can be started and stopped // without any sessions connecting. Stop must close the listener and return // promptly, and Start must return nil for a clean shutdown. diff --git a/pkg/relay/relay_upstream_goaway_test.go b/pkg/relay/relay_upstream_goaway_test.go deleted file mode 100644 index cde8d69f..00000000 --- a/pkg/relay/relay_upstream_goaway_test.go +++ /dev/null @@ -1,89 +0,0 @@ -package relay - -import ( - "context" - "log/slog" - "sync" - "testing" - "time" - - "github.com/floatdrop/moq-go/pkg/moqt" - "github.com/floatdrop/moq-go/pkg/moqt/session" - "github.com/floatdrop/moq-go/pkg/moqt/session/sessiontest" - "github.com/floatdrop/moq-go/pkg/moqt/wire" - "github.com/floatdrop/moq-go/pkg/relay/discovery" -) - -// TestResolveUpstreamsSkipsGoingAwayRelay: a pooled session to a remote relay -// that sent GOAWAY takes no new requests (§10.4), so it must not take one of -// the UpstreamFanIn slots either; resolution falls through to the next-ranked -// relay while the draining one is still listed in Discovery. -func TestResolveUpstreamsSkipsGoingAwayRelay(t *testing.T) { - t.Parallel() - ctx := t.Context() - ns := wire.TrackNamespace{[]byte("video")} - store := discovery.NewMemoryStore() - defer store.Close() - addrs := []string{"relay-B", "relay-C"} - for _, a := range addrs { - if err := store.PublishNamespace(ctx, discovery.NamespaceInfo{Prefix: ns, RelayAddr: a}); err != nil { - t.Fatalf("PublishNamespace %s: %v", a, err) - } - } - - // Each remote relay is a bare server session the test drives. - var ( - mu sync.Mutex - remotes = map[string]*session.Session{} - ) - dialer := func(_ context.Context, addr string) (session.Conn, error) { - cli, srv := sessiontest.NewConnPair() - go func() { - // Not the dial's context: the pool cancels it once the dial - // returns, which can be before this side of the handshake ends. - s, err := session.Server(t.Context(), srv) - if err != nil { - return - } - mu.Lock() - remotes[addr] = s - mu.Unlock() - }() - return cli, nil - } - p := newUpstreamPool(upstreamPoolConfig{ - dialer: dialer, discovery: store, relayAddr: "relay-A", log: slog.Default(), fanIn: 1, - serveSession: func(*session.Session, func()) {}, - }) - defer p.close() - - first := p.resolveUpstreams(ctx, ns) - if len(first) != 1 { - t.Fatalf("resolveUpstreams = %d sessions, want 1 (fan-in 1)", len(first)) - } - top := rankedAddrs(ns, addrs)[0] - var remote *session.Session - for deadline := time.Now().Add(2 * time.Second); remote == nil; time.Sleep(5 * time.Millisecond) { - // The server side of the handshake can finish after the client's. - mu.Lock() - remote = remotes[top] - mu.Unlock() - if remote == nil && time.Now().After(deadline) { - t.Fatalf("no server session for the top-ranked %s", top) - } - } - if err := remote.SendGoaway(10*time.Second, ""); err != nil { - t.Fatalf("SendGoaway: %v", err) - } - select { - case <-first[0].GoawayReceived(): - case <-time.After(2 * time.Second): - t.Fatal("the pooled session never saw the GOAWAY") - } - - second := p.resolveUpstreams(ctx, ns) - if len(second) != 1 || second[0] == first[0] { - t.Fatalf("resolveUpstreams after GOAWAY returned the draining %s again; want the next-ranked relay", top) - } - _ = remote.Close(moqt.SessionNoError, "done") -} diff --git a/pkg/relay/relay_upstream_affinity_test.go b/pkg/relay/relay_upstream_test.go similarity index 59% rename from pkg/relay/relay_upstream_affinity_test.go rename to pkg/relay/relay_upstream_test.go index 59fbba53..5ad5a472 100644 --- a/pkg/relay/relay_upstream_affinity_test.go +++ b/pkg/relay/relay_upstream_test.go @@ -1,11 +1,17 @@ package relay import ( + "context" "fmt" "log/slog" "slices" + "sync" "testing" + "time" + "github.com/floatdrop/moq-go/pkg/moqt" + "github.com/floatdrop/moq-go/pkg/moqt/session" + "github.com/floatdrop/moq-go/pkg/moqt/session/sessiontest" "github.com/floatdrop/moq-go/pkg/moqt/wire" "github.com/floatdrop/moq-go/pkg/relay/discovery" ) @@ -105,3 +111,77 @@ func TestNewUpstreamPoolFanInPassthrough(t *testing.T) { p.close() } } + +// TestResolveUpstreamsSkipsGoingAwayRelay: a pooled session to a remote relay +// that sent GOAWAY takes no new requests (§10.4), so it must not take one of +// the UpstreamFanIn slots either; resolution falls through to the next-ranked +// relay while the draining one is still listed in Discovery. +func TestResolveUpstreamsSkipsGoingAwayRelay(t *testing.T) { + t.Parallel() + ctx := t.Context() + ns := wire.TrackNamespace{[]byte("video")} + store := discovery.NewMemoryStore() + defer store.Close() + addrs := []string{"relay-B", "relay-C"} + for _, a := range addrs { + if err := store.PublishNamespace(ctx, discovery.NamespaceInfo{Prefix: ns, RelayAddr: a}); err != nil { + t.Fatalf("PublishNamespace %s: %v", a, err) + } + } + + // Each remote relay is a bare server session the test drives. + var ( + mu sync.Mutex + remotes = map[string]*session.Session{} + ) + dialer := func(_ context.Context, addr string) (session.Conn, error) { + cli, srv := sessiontest.NewConnPair() + go func() { + // Not the dial's context: the pool cancels it once the dial + // returns, which can be before this side of the handshake ends. + s, err := session.Server(t.Context(), srv) + if err != nil { + return + } + mu.Lock() + remotes[addr] = s + mu.Unlock() + }() + return cli, nil + } + p := newUpstreamPool(upstreamPoolConfig{ + dialer: dialer, discovery: store, relayAddr: "relay-A", log: slog.Default(), fanIn: 1, + serveSession: func(*session.Session, func()) {}, + }) + defer p.close() + + first := p.resolveUpstreams(ctx, ns) + if len(first) != 1 { + t.Fatalf("resolveUpstreams = %d sessions, want 1 (fan-in 1)", len(first)) + } + top := rankedAddrs(ns, addrs)[0] + var remote *session.Session + for deadline := time.Now().Add(2 * time.Second); remote == nil; time.Sleep(5 * time.Millisecond) { + // The server side of the handshake can finish after the client's. + mu.Lock() + remote = remotes[top] + mu.Unlock() + if remote == nil && time.Now().After(deadline) { + t.Fatalf("no server session for the top-ranked %s", top) + } + } + if err := remote.SendGoaway(10*time.Second, ""); err != nil { + t.Fatalf("SendGoaway: %v", err) + } + select { + case <-first[0].GoawayReceived(): + case <-time.After(2 * time.Second): + t.Fatal("the pooled session never saw the GOAWAY") + } + + second := p.resolveUpstreams(ctx, ns) + if len(second) != 1 || second[0] == first[0] { + t.Fatalf("resolveUpstreams after GOAWAY returned the draining %s again; want the next-ranked relay", top) + } + _ = remote.Close(moqt.SessionNoError, "done") +} diff --git a/pkg/relay/request_fin_test.go b/pkg/relay/request_stream_test.go similarity index 64% rename from pkg/relay/request_fin_test.go rename to pkg/relay/request_stream_test.go index fd4fec85..dab98db0 100644 --- a/pkg/relay/request_fin_test.go +++ b/pkg/relay/request_stream_test.go @@ -9,27 +9,24 @@ import ( "github.com/floatdrop/moq-go/pkg/moqt" "github.com/floatdrop/moq-go/pkg/moqt/message" "github.com/floatdrop/moq-go/pkg/moqt/session" - "github.com/floatdrop/moq-go/pkg/moqt/wire" "github.com/floatdrop/moq-go/pkg/relay" "github.com/floatdrop/moq-go/pkg/relay/internal/relaytest" ) -// §3.3.2: "A FIN only indicates that an endpoint will send no further messages -// in that direction; it is not a request cancellation." "A requester, with the -// exception of the sender of PUBLISH, MAY FIN immediately after sending a -// message if it will not send a REQUEST_UPDATE." §3.3.3: "An endpoint that has -// already sent a FIN on its sending direction and subsequently wishes to cancel -// sends STOP_SENDING on the receiving direction." +// Request stream lifecycle at the relay. A FIN is not a cancellation (§3.3.2); +// after a FIN the cancel is STOP_SENDING (§3.3.3). Only the request's sender +// may send REQUEST_UPDATE (§10.9), and only the publisher PUBLISH_STATE_NOTIFY +// (§10.10). // TestRelay_SubscriberFINKeepsSubscription: a subscriber that FINs its side of // the SUBSCRIBE stream still receives objects, and its later STOP_SENDING is // what ends the subscription. func TestRelay_SubscriberFINKeepsSubscription(t *testing.T) { t.Parallel() - pubSess, alias := publishWithTrackProps(t, nil) + pubSess, alias := newCam1Publisher(t, nil) subSess := dialAnotherClient(t, pubSess) subReq, err := subSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), }) if err != nil { @@ -39,7 +36,7 @@ func TestRelay_SubscriberFINKeepsSubscription(t *testing.T) { t.Fatalf("FIN: %v", err) } - publishSubgroupObject(t, pubSess, alias, 3, -1) + publishObjects(t, pubSess, alias, 3, 1) if !awaitSubgroupObject(t, subSess, 2*time.Second) { t.Fatal("a FIN'd subscription stopped receiving objects") } @@ -50,16 +47,7 @@ func TestRelay_SubscriberFINKeepsSubscription(t *testing.T) { deadline := time.Now().Add(2 * time.Second) misses := 0 for group := uint64(4); ; group++ { - go func() { - sg, err := pubSess.OpenSubgroup(message.SubgroupHeader{ - SubgroupIDMode: message.SubgroupIDExplicit, TrackAlias: alias, GroupID: group, - }) - if err != nil { - return - } - _ = sg.WriteObject(&message.SubgroupObject{Payload: []byte("x")}) - _ = sg.Close() - }() + go sendObjects(pubSess, alias, group, 1) // Three misses in a row, so a merely slow forward cannot pass. if !awaitSubgroupObject(t, subSess, 200*time.Millisecond) { if misses++; misses == 3 { @@ -74,15 +62,14 @@ func TestRelay_SubscriberFINKeepsSubscription(t *testing.T) { } } -// TestRelay_PublishNamespaceFINStaysAdvertised: a publisher that FINs its -// PUBLISH_NAMESPACE stream has not withdrawn it (§6.2: "withdrawn by -// cancelling the request"), so SUBSCRIBEs for the namespace still reach it. +// TestRelay_PublishNamespaceFINStaysAdvertised: a FIN'd PUBLISH_NAMESPACE is +// not withdrawn (§6.2), so SUBSCRIBEs still reach its publisher. func TestRelay_PublishNamespaceFINStaysAdvertised(t *testing.T) { t.Parallel() - ns := wire.TrackNamespace{[]byte("video")} + video := ns("video") pubSess, teardown := connectRelay(t, relay.Config{}) defer teardown() - np, err := pubSess.PublishNamespace(t.Context(), &message.PublishNamespace{Namespace: ns}) + np, err := pubSess.PublishNamespace(t.Context(), &message.PublishNamespace{Namespace: video}) if err != nil { t.Fatalf("PublishNamespace: %v", err) } @@ -102,7 +89,7 @@ func TestRelay_PublishNamespaceFINStaysAdvertised(t *testing.T) { }() subSess := dialAnotherClient(t, pubSess) go func() { - _, _ = subSess.Subscribe(t.Context(), &message.Subscribe{Namespace: ns, Name: []byte("cam1")}) + _, _ = subSess.Subscribe(t.Context(), &message.Subscribe{Namespace: video, Name: []byte("cam1")}) }() select { case m := <-got: @@ -114,22 +101,13 @@ func TestRelay_PublishNamespaceFINStaysAdvertised(t *testing.T) { } } -// TestRelay_FetchRequesterFINCompletesRequest: a FETCH requester that FINs will -// send no REQUEST_UPDATE, and the relay has nothing more to send on the request -// stream, so the relay FINs back and the request completes. +// TestRelay_FetchRequesterFINCompletesRequest: after the FETCH requester's FIN +// the relay FINs back and the request completes. func TestRelay_FetchRequesterFINCompletesRequest(t *testing.T) { t.Parallel() - pubSess, alias := publishWithTrackProps(t, nil) - liveSess := dialAnotherClient(t, pubSess) - live, err := liveSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, - Name: []byte("cam1"), - }) - if err != nil { - t.Fatalf("Subscribe: %v", err) - } - t.Cleanup(func() { _ = live.Close() }) - publishSubgroupObject(t, pubSess, alias, 3, -1) + pubSess, alias := newCam1Publisher(t, nil) + liveSess := newCam1Subscriber(t, pubSess) + publishObjects(t, pubSess, alias, 3, 1) if !awaitSubgroupObject(t, liveSess, 2*time.Second) { t.Fatal("object not forwarded") } @@ -137,7 +115,7 @@ func TestRelay_FetchRequesterFINCompletesRequest(t *testing.T) { fetchSess := dialAnotherClient(t, pubSess) loc := message.Location{Group: 3} fr, err := fetchSess.Fetch(t.Context(), &message.Fetch{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), Parameters: message.Parameters{fetchRangeFilter(loc, loc)}, }) @@ -168,15 +146,14 @@ func TestRelay_FetchRequesterFINCompletesRequest(t *testing.T) { } } -// TestRelay_SubscribeNamespaceFINKeepsSubscription: a SUBSCRIBE_NAMESPACE -// ends only by "resetting or sending STOP_SENDING on the stream" (§6.1), so a -// subscriber that FINs still hears about a publisher that arrives later. +// TestRelay_SubscribeNamespaceFINKeepsSubscription: a FIN'd SUBSCRIBE_NAMESPACE +// still hears about a later publisher (§6.1). func TestRelay_SubscribeNamespaceFINKeepsSubscription(t *testing.T) { t.Parallel() subSess, teardown := connectRelay(t, relay.Config{}) defer teardown() nsSub, err := subSess.SubscribeNamespace(t.Context(), &message.SubscribeNamespace{ - TrackNamespacePrefix: wire.TrackNamespace{[]byte("video")}, + TrackNamespacePrefix: ns("video"), }) if err != nil { t.Fatalf("SubscribeNamespace: %v", err) @@ -188,7 +165,7 @@ func TestRelay_SubscribeNamespaceFINKeepsSubscription(t *testing.T) { pubSess := dialAnotherClient(t, subSess) if _, err := pubSess.PublishNamespace(t.Context(), &message.PublishNamespace{ - Namespace: wire.TrackNamespace{[]byte("video"), []byte("cam1")}, + Namespace: ns("video", "cam1"), }); err != nil { t.Fatalf("PublishNamespace: %v", err) } @@ -204,7 +181,7 @@ func TestRelay_SubscribeTracksFINKeepsSubscription(t *testing.T) { subSess, teardown := connectRelay(t, relay.Config{}) defer teardown() ts, err := subSess.SubscribeTracks(t.Context(), &message.SubscribeTracks{ - TrackNamespacePrefix: wire.TrackNamespace{[]byte("video")}, + TrackNamespacePrefix: ns("video"), }) if err != nil { t.Fatalf("SubscribeTracks: %v", err) @@ -216,7 +193,7 @@ func TestRelay_SubscribeTracksFINKeepsSubscription(t *testing.T) { pubSess := dialAnotherClient(t, subSess) pubReq, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("video"), []byte("cam7")}, + Namespace: ns("video", "cam7"), Name: []byte("rtp"), }) if err != nil { @@ -242,15 +219,14 @@ func TestRelay_SubscribeTracksFINKeepsSubscription(t *testing.T) { } } -// TestRelay_PublishNamespaceStopSendingAfterFINWithdraws: after a FIN, the -// publisher's STOP_SENDING is the §3.3.3 cancel — the relay withdraws the -// namespace and tells the subscribers it notified with NAMESPACE_DONE. +// TestRelay_PublishNamespaceStopSendingAfterFINWithdraws: STOP_SENDING after a +// FIN withdraws the namespace (§3.3.3); subscribers get NAMESPACE_DONE. func TestRelay_PublishNamespaceStopSendingAfterFINWithdraws(t *testing.T) { t.Parallel() subSess, teardown := connectRelay(t, relay.Config{}) defer teardown() nsSub, err := subSess.SubscribeNamespace(t.Context(), &message.SubscribeNamespace{ - TrackNamespacePrefix: wire.TrackNamespace{[]byte("video")}, + TrackNamespacePrefix: ns("video"), }) if err != nil { t.Fatalf("SubscribeNamespace: %v", err) @@ -258,7 +234,7 @@ func TestRelay_PublishNamespaceStopSendingAfterFINWithdraws(t *testing.T) { pubSess := dialAnotherClient(t, subSess) np, err := pubSess.PublishNamespace(t.Context(), &message.PublishNamespace{ - Namespace: wire.TrackNamespace{[]byte("video"), []byte("cam1")}, + Namespace: ns("video", "cam1"), }) if err != nil { t.Fatalf("PublishNamespace: %v", err) @@ -274,3 +250,66 @@ func TestRelay_PublishNamespaceStopSendingAfterFINWithdraws(t *testing.T) { t.Fatalf("got %T after the publisher's STOP_SENDING, want NAMESPACE_DONE", got) } } + +// TestRelay_SubscriberPublishStateNotifyClosesSession: PUBLISH_STATE_NOTIFY +// from the subscriber closes the session (§10.10). +func TestRelay_SubscriberPublishStateNotifyClosesSession(t *testing.T) { + t.Parallel() + pubSess, _ := newCam1Publisher(t, nil) + subSess := dialAnotherClient(t, pubSess) + subReq, err := subSess.Subscribe(t.Context(), &message.Subscribe{ + Namespace: ns("video"), + Name: []byte("cam1"), + }) + if err != nil { + t.Fatalf("Subscribe: %v", err) + } + go func() { _ = message.Marshal(subReq.Stream, &message.PublishStateNotify{}) }() + requireSessionClosed(t, subSess, "a subscriber's PUBLISH_STATE_NOTIFY") +} + +// TestRelay_UpstreamRequestUpdateOnSubscribeClosesSession: on the relay's own +// upstream SUBSCRIBE the publisher is not the request's sender, so its +// REQUEST_UPDATE is a PROTOCOL_VIOLATION (§10.9). +func TestRelay_UpstreamRequestUpdateOnSubscribeClosesSession(t *testing.T) { + t.Parallel() + video := ns("video") + upSess, teardown := connectRelay(t, relay.Config{}) + defer teardown() + if _, err := upSess.PublishNamespace(t.Context(), &message.PublishNamespace{Namespace: video}); err != nil { + t.Fatalf("PublishNamespace: %v", err) + } + go func() { + r, err := upSess.AcceptRequest(t.Context()) + if err != nil { + return + } + if _, err := r.AcceptSubscribe(nil); err != nil { + return + } + _ = message.Marshal(r.Stream, &message.RequestUpdate{RequestID: upSess.AllocRequestID()}) + }() + live := dialAnotherClient(t, upSess) + go func() { + _, _ = live.Subscribe(t.Context(), &message.Subscribe{Namespace: video, Name: []byte("cam1")}) + }() + requireSessionClosed(t, upSess, "a REQUEST_UPDATE on the relay's own SUBSCRIBE") +} + +// TestRelay_PublisherRequestUpdateOnPublishIsAllowed: the publisher of an +// accepted PUBLISH may send REQUEST_UPDATE (§10.9); the relay declines it +// without closing the session. +func TestRelay_PublisherRequestUpdateOnPublishIsAllowed(t *testing.T) { + t.Parallel() + pubSess, _ := newCam1Publisher(t, nil) + pub := publish(t, pubSess, &message.Publish{Namespace: ns("video"), Name: []byte("cam2")}) + _, err := pub.Update(t.Context(), message.Parameters{message.ForwardParam(true)}) + if rej, ok := errors.AsType[*session.RequestRejectedError](err); !ok || rej.Code != moqt.RequestNotSupported { + t.Fatalf("Update on an accepted PUBLISH = %v, want REQUEST_ERROR NOT_SUPPORTED", err) + } + select { + case <-pubSess.Done(): + t.Fatalf("relay closed the session on a legal REQUEST_UPDATE: %v", pubSess.Err()) + case <-time.After(200 * time.Millisecond): + } +} diff --git a/pkg/relay/session_cleanup_test.go b/pkg/relay/session_cleanup_test.go index fad259d8..d1fe4ac8 100644 --- a/pkg/relay/session_cleanup_test.go +++ b/pkg/relay/session_cleanup_test.go @@ -8,7 +8,6 @@ import ( "github.com/floatdrop/moq-go/pkg/moqt" "github.com/floatdrop/moq-go/pkg/moqt/message" "github.com/floatdrop/moq-go/pkg/moqt/session" - "github.com/floatdrop/moq-go/pkg/moqt/wire" "github.com/floatdrop/moq-go/pkg/relay" ) @@ -28,7 +27,7 @@ func TestSessionCleanup_PublisherSessionDeath(t *testing.T) { defer teardown() if _, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), TrackAlias: 1, }); err != nil { @@ -49,7 +48,7 @@ func TestSessionCleanup_PublisherSessionDeath(t *testing.T) { deadline := time.Now().Add(2 * time.Second) for { sub, err := subSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), }) if err == nil { @@ -83,7 +82,7 @@ func TestSessionCleanup_SubscriberSessionDeath(t *testing.T) { defer teardown() pubStream, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), TrackAlias: 1, }) @@ -94,7 +93,7 @@ func TestSessionCleanup_SubscriberSessionDeath(t *testing.T) { subSess := dialAnotherClient(t, pubSess) if _, err := subSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), }); err != nil { t.Fatalf("Subscribe: %v", err) diff --git a/pkg/relay/session_handler_fault_test.go b/pkg/relay/session_handler_fault_test.go index aa9c1a37..9f132fd6 100644 --- a/pkg/relay/session_handler_fault_test.go +++ b/pkg/relay/session_handler_fault_test.go @@ -9,7 +9,6 @@ import ( "github.com/floatdrop/moq-go/pkg/moqt" "github.com/floatdrop/moq-go/pkg/moqt/message" "github.com/floatdrop/moq-go/pkg/moqt/session/sessiontest" - "github.com/floatdrop/moq-go/pkg/moqt/wire" "github.com/floatdrop/moq-go/pkg/relay" ) @@ -52,7 +51,7 @@ func TestSessionHandler_FailedRejectWriteKeepsTheSessionAlive(t *testing.T) { sub := func() *message.Subscribe { return &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), } } diff --git a/pkg/relay/session_handler_test.go b/pkg/relay/session_handler_test.go index e5af3e1a..64deea6c 100644 --- a/pkg/relay/session_handler_test.go +++ b/pkg/relay/session_handler_test.go @@ -3,208 +3,15 @@ package relay_test import ( "context" "errors" - "runtime" - "sync" "sync/atomic" "testing" - "time" "github.com/floatdrop/moq-go/pkg/moqt" "github.com/floatdrop/moq-go/pkg/moqt/message" "github.com/floatdrop/moq-go/pkg/moqt/session" - "github.com/floatdrop/moq-go/pkg/moqt/wire" "github.com/floatdrop/moq-go/pkg/relay" ) -// connectRelay starts a relay backed by the in-process pipeListener, dials a -// client session into it, and returns the client *session.Session plus a -// teardown closure that stops the relay and waits for clean shutdown. -// -// The caller supplies a complete relay.Config; GoawayTimeout is forced to a -// small value so teardown is quick if the caller didn't set it. Pass the -// zero relay.Config{} for the common "no special configuration" case, or set -// individual fields (Authorizer, SendQueueSize, MaxDropsBeforeReset, -// Discovery, RelayAddr, MaxCacheSize, …) as the test requires. -// connectRelay accepts testing.TB so it serves both tests and benchmarks. -// testing.TB does not expose Context() (that lives only on *testing.T / -// *testing.B), so the relay-Start and client-handshake context is created -// here and cancelled via tb.Cleanup. -func connectRelay(tb testing.TB, cfg relay.Config) (clientSess *session.Session, teardown func()) { - tb.Helper() - return connectRelayOn(tb, cfg, newPipeListener()) -} - -// connectRelayOn is [connectRelay] against a caller-supplied listener, for -// tests that need to configure it first — setting pipeListener.fault to make -// the relay's own writes fail, for one. -func connectRelayOn( - tb testing.TB, - cfg relay.Config, - l *pipeListener, -) (clientSess *session.Session, teardown func()) { - tb.Helper() - if cfg.GoawayTimeout == 0 { - cfg.GoawayTimeout = 50 * time.Millisecond - } - ctx, cancel := context.WithCancel(context.Background()) - tb.Cleanup(cancel) - r := relay.New(l, cfg) - startErr := make(chan error, 1) - go func() { startErr <- r.Start(ctx) }() - - clientConn, err := l.Dial() - if err != nil { - tb.Fatalf("Dial: %v", err) - } - sess, err := session.Client(ctx, clientConn) - if err != nil { - tb.Fatalf("session.Client: %v", err) - } - - pipeListenerMu.Lock() - pipeListenerOf[sess] = l - pipeListenerMu.Unlock() - - // Track every client session associated with this relay so the - // teardown closes them before Relay.Stop tries to drain. Without - // this, Relay.Stop's r.handlers.Wait() blocks forever waiting on - // the relay-side handlePublish goroutines, which themselves block - // on DrainAndWait reading from a publisher stream the client side - // never closed. Under `go test -count=N` those leaked goroutines - // accumulate across runs and eventually wedge the process at the - // per-test timeout. Pinned here rather than asking each test to - // close its dialled sessions because dialAnotherClient hands out - // sessions without a natural cleanup hook. - clientsForRelay := newClientSessionTracker() - clientsForRelay.add(sess) - pipeListenerClientsMu.Lock() - pipeListenerClients[sess] = clientsForRelay - pipeListenerClientsMu.Unlock() - - return sess, func() { - pipeListenerMu.Lock() - delete(pipeListenerOf, sess) - pipeListenerMu.Unlock() - pipeListenerClientsMu.Lock() - delete(pipeListenerClients, sess) - pipeListenerClientsMu.Unlock() - - // Stop the relay FIRST. Tests that exercise GOAWAY-driven - // cooperative migration (e.g. TestGracefulMigration) expect - // the GOAWAY broadcast to reach their clients while those - // clients are still alive — closing the clients up front - // would preempt that contract. - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - stopDone := make(chan error, 1) - go func() { stopDone <- r.Stop(ctx) }() - - // A relay handler blocked in DrainAndWait reading a client stream - // the test never closed keeps Relay.Stop's (unbounded) - // r.handlers.Wait() from returning; closing the client gives that - // read an EOF so the handler exits. Give cooperative-migration - // clients a brief window to close themselves first, then force-close - // every tracked client. - // - // Exactly ONE goroutine — this one — ever receives from stopDone. - // An earlier version ran a second goroutine that also selected on - // stopDone to drive the force-close; when Stop finished within the - // window the two receivers raced for the single buffered value, and - // if the helper won, the teardown below blocked on stopDone forever - // (a ~10-minute CI hang that looked like a flake). Driving the - // window inline keeps stopDone single-consumer. - const cooperativeWindow = 250 * time.Millisecond - select { - case <-stopDone: - // Stop drained within the window (clients closed cooperatively - // or no handler was wedged); no force-close needed to unblock it. - case <-time.After(cooperativeWindow): - clientsForRelay.closeAll() - // Closing the clients should let every wedged handler exit well - // within Stop's 5s ctx budget. Bound the wait so a genuine - // deadlock fails fast with a goroutine dump instead of hanging - // until the package test timeout (~10 min). - select { - case <-stopDone: - case <-time.After(8 * time.Second): - dumpGoroutines(tb, "relay teardown: Relay.Stop did not return "+ - "within 8s after closing all clients (wedged session handler?)") - return - } - } - clientsForRelay.closeAll() // idempotent final sweep - - select { - case err := <-startErr: - if err != nil { - tb.Errorf("Start returned: %v", err) - } - case <-time.After(time.Second): - tb.Error("Start did not return after Stop") - } - } -} - -// pipeListenerClients tracks the set of client sessions opened against -// a given pipe listener (keyed by the *first* client session, matching -// the existing pipeListenerOf indexing). dialAnotherClient appends to -// the set so the teardown can close every client. See -// connectRelay for why this is needed. -var ( - pipeListenerClientsMu sync.Mutex - pipeListenerClients = make(map[*session.Session]*clientSessionTracker) -) - -type clientSessionTracker struct { - mu sync.Mutex - sessions []*session.Session -} - -func newClientSessionTracker() *clientSessionTracker { - return &clientSessionTracker{} -} - -func (t *clientSessionTracker) add(s *session.Session) { - t.mu.Lock() - t.sessions = append(t.sessions, s) - t.mu.Unlock() -} - -func (t *clientSessionTracker) closeAll() { - t.mu.Lock() - sessions := append([]*session.Session(nil), t.sessions...) - t.sessions = nil - t.mu.Unlock() - for _, s := range sessions { - _ = s.Close(moqt.SessionNoError, "test teardown") - } -} - -// dumpGoroutines fails the test with msg and a full goroutine stack dump. Used -// by the relay teardown when Relay.Stop does not return in bounded time, so a -// wedged session handler surfaces as a fast, diagnosable failure with the -// blocking stacks attached instead of a silent ~10-minute package timeout. -func dumpGoroutines(tb testing.TB, msg string) { - tb.Helper() - buf := make([]byte, 1<<20) - n := runtime.Stack(buf, true) - tb.Errorf("%s; goroutine dump follows:\n%s", msg, buf[:n]) -} - -// requireRejectedWithCode asserts that err is a *session.RequestRejectedError -// carrying the expected REQUEST_ERROR code. Failures point at the actual code -// so debugging a misrouted dispatch is obvious. -func requireRejectedWithCode(t *testing.T, err error, want moqt.RequestErrorCode) { - t.Helper() - var rejected *session.RequestRejectedError - if !errors.As(err, &rejected) { - t.Fatalf("want *RequestRejectedError, got %T: %v", err, err) - } - if rejected.Code != want { - t.Fatalf("rejected code = %#x, want %#x; reason=%q", uint64(rejected.Code), uint64(want), rejected.Reason) - } -} - // (Earlier scaffolding tests for SUBSCRIBE / PUBLISH "rejects with // NotSupported" were removed once those handlers became real. See // session_pubsub_test.go for the success / no-upstream / aggregation @@ -231,7 +38,7 @@ func TestSessionHandler_AuthDenialMapsToRequestError(t *testing.T) { defer teardown() _, err := clientSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), }) requireRejectedWithCode(t, err, moqt.RequestUnauthorized) @@ -263,7 +70,7 @@ func TestSessionHandler_DispatchSurvivesPerRequestRejection(t *testing.T) { for range 3 { _, err := clientSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), }) requireRejectedWithCode(t, err, moqt.RequestDoesNotExist) diff --git a/pkg/relay/session_namespace_test.go b/pkg/relay/session_namespace_test.go index c863a77f..9475e6c0 100644 --- a/pkg/relay/session_namespace_test.go +++ b/pkg/relay/session_namespace_test.go @@ -2,14 +2,11 @@ package relay_test import ( "context" - "sync" "testing" "time" "github.com/floatdrop/moq-go/pkg/moqt" "github.com/floatdrop/moq-go/pkg/moqt/message" - "github.com/floatdrop/moq-go/pkg/moqt/session" - "github.com/floatdrop/moq-go/pkg/moqt/wire" "github.com/floatdrop/moq-go/pkg/relay" "github.com/floatdrop/moq-go/pkg/relay/internal/relaytest" ) @@ -23,7 +20,7 @@ func TestPublishNamespace_AcceptedAndRegistered(t *testing.T) { defer teardown() stream, err := clientSess.PublishNamespace(t.Context(), &message.PublishNamespace{ - Namespace: wire.TrackNamespace{[]byte("video"), []byte("cam1")}, + Namespace: ns("video", "cam1"), }) if err != nil { t.Fatalf("PublishNamespace: %v", err) @@ -46,7 +43,7 @@ func TestSubscribeNamespace_AcceptedAndDeliversInitialNamespaces(t *testing.T) { // First publisher: video/cam1. pubStream, err := pubSess.PublishNamespace(t.Context(), &message.PublishNamespace{ - Namespace: wire.TrackNamespace{[]byte("video"), []byte("cam1")}, + Namespace: ns("video", "cam1"), }) if err != nil { t.Fatalf("PublishNamespace: %v", err) @@ -60,7 +57,7 @@ func TestSubscribeNamespace_AcceptedAndDeliversInitialNamespaces(t *testing.T) { subSess := dialAnotherClient(t, pubSess) subStream, err := subSess.SubscribeNamespace(t.Context(), &message.SubscribeNamespace{ - TrackNamespacePrefix: wire.TrackNamespace{[]byte("video")}, + TrackNamespacePrefix: ns("video"), }) if err != nil { t.Fatalf("SubscribeNamespace: %v", err) @@ -88,7 +85,7 @@ func TestPublishNamespace_FanoutsToMatchingSubscriber(t *testing.T) { defer teardown() subStream, err := subSess.SubscribeNamespace(t.Context(), &message.SubscribeNamespace{ - TrackNamespacePrefix: wire.TrackNamespace{[]byte("video")}, + TrackNamespacePrefix: ns("video"), }) if err != nil { t.Fatalf("SubscribeNamespace: %v", err) @@ -98,7 +95,7 @@ func TestPublishNamespace_FanoutsToMatchingSubscriber(t *testing.T) { pubSess := dialAnotherClient(t, subSess) pubStream, err := pubSess.PublishNamespace(t.Context(), &message.PublishNamespace{ - Namespace: wire.TrackNamespace{[]byte("video"), []byte("cam2")}, + Namespace: ns("video", "cam2"), }) if err != nil { t.Fatalf("PublishNamespace: %v", err) @@ -140,7 +137,7 @@ func TestSubscribeTracks_AcceptedWithoutForwarding(t *testing.T) { defer teardown() subStream, err := subSess.SubscribeTracks(t.Context(), &message.SubscribeTracks{ - TrackNamespacePrefix: wire.TrackNamespace{[]byte("video")}, + TrackNamespacePrefix: ns("video"), }) if err != nil { t.Fatalf("SubscribeTracks: %v", err) @@ -162,7 +159,7 @@ func TestPublishNamespace_AuthDenialUsesPolicyCode(t *testing.T) { defer teardown() _, err := clientSess.PublishNamespace(t.Context(), &message.PublishNamespace{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), }) requireRejectedWithCode(t, err, moqt.RequestUnauthorized) if got := auth.publishNamespaceCalls.Load(); got != 1 { @@ -183,7 +180,7 @@ func TestSubscribeNamespace_AuthDenialUsesPolicyCode(t *testing.T) { defer teardown() _, err := clientSess.SubscribeNamespace(t.Context(), &message.SubscribeNamespace{ - TrackNamespacePrefix: wire.TrackNamespace{[]byte("video")}, + TrackNamespacePrefix: ns("video"), }) requireRejectedWithCode(t, err, moqt.RequestUnauthorized) if got := auth.subscribeNamespaceCalls.Load(); got != 1 { @@ -200,7 +197,7 @@ func TestSubscribeTracks_AuthDenialUsesPolicyCode(t *testing.T) { defer teardown() _, err := clientSess.SubscribeTracks(t.Context(), &message.SubscribeTracks{ - TrackNamespacePrefix: wire.TrackNamespace{[]byte("video")}, + TrackNamespacePrefix: ns("video"), }) requireRejectedWithCode(t, err, moqt.RequestUnauthorized) if got := auth.subscribeTracksCalls.Load(); got != 1 { @@ -210,86 +207,6 @@ func TestSubscribeTracks_AuthDenialUsesPolicyCode(t *testing.T) { // ----- shared helpers -------------------------------------------------- -// dialAnotherClient opens a fresh client session on the same in-process -// relay that `existing` is connected to. The implementation reaches into -// the testing package's connectRelay scope via a side-channel: we keep a -// per-test cache of (relayInstance, listener) tuples in the test file. -// -// In practice the simplest approach is: a brand-new pipe listener and relay -// per call would defeat the purpose, so we instead store the listener on a -// global init in connectRelay. Refactor: we expose newListenerForExisting() -// via a package-level map keyed on the *session.Session of the first client. -// -// To keep the diff focused, the implementation uses a global mutex-guarded -// map updated by connectRelay below. -var ( - pipeListenerMu sync.Mutex - pipeListenerOf = make(map[*session.Session]*pipeListener) -) - -// dialAnotherClient accepts testing.TB so it serves both tests and -// benchmarks. The handshake uses context.Background() rather than a -// per-test context (testing.TB has no Context()); the returned session is -// registered on the relay's client tracker, so connectRelay's teardown -// closes it. -func dialAnotherClient(tb testing.TB, existing *session.Session) *session.Session { - tb.Helper() - pipeListenerMu.Lock() - l, ok := pipeListenerOf[existing] - pipeListenerMu.Unlock() - if !ok { - tb.Fatal("dialAnotherClient: no pipeListener registered for the existing session; was connectRelay used?") - } - conn, err := l.Dial() - if err != nil { - tb.Fatalf("listener.Dial: %v", err) - } - sess, err := session.Client(context.Background(), conn) - if err != nil { - tb.Fatalf("session.Client: %v", err) - } - // Register on the same client tracker as the primary session so - // the teardown closes this dialled client too. See - // connectRelay for why this matters across - // -count=N runs. - pipeListenerClientsMu.Lock() - if tracker, ok := pipeListenerClients[existing]; ok { - tracker.add(sess) - } - pipeListenerClientsMu.Unlock() - return sess -} - -// dialAnotherClientWithLimits is [dialAnotherClient] with explicit bidi-stream -// credit caps on the new connection. serverBidi bounds how many bidi streams -// the relay can open toward this client — set it low to force the relay's -// PUBLISH fan-out into the PUBLISH_SKIPPED (§10.21) path. -func dialAnotherClientWithLimits(t *testing.T, existing *session.Session, clientBidi, serverBidi int) *session.Session { - t.Helper() - pipeListenerMu.Lock() - l, ok := pipeListenerOf[existing] - pipeListenerMu.Unlock() - if !ok { - t.Fatal( - "dialAnotherClientWithLimits: no pipeListener registered for the existing session; was connectRelay used?", - ) - } - conn, err := l.DialWithLimits(clientBidi, serverBidi) - if err != nil { - t.Fatalf("listener.DialWithLimits: %v", err) - } - sess, err := session.Client(t.Context(), conn) - if err != nil { - t.Fatalf("session.Client: %v", err) - } - pipeListenerClientsMu.Lock() - if tracker, ok := pipeListenerClients[existing]; ok { - tracker.add(sess) - } - pipeListenerClientsMu.Unlock() - return sess -} - // TestNamespaceStreams_AnswerRequestUpdate pins §10.9 on the namespace // request streams: the relay previously held them open with a drain that // discarded follow-ups unparsed, so a peer's REQUEST_UPDATE was never @@ -303,7 +220,7 @@ func TestNamespaceStreams_AnswerRequestUpdate(t *testing.T) { defer teardown() nsPub, err := pubSess.PublishNamespace(t.Context(), &message.PublishNamespace{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), }) if err != nil { t.Fatalf("PublishNamespace: %v", err) @@ -312,7 +229,7 @@ func TestNamespaceStreams_AnswerRequestUpdate(t *testing.T) { subSess := dialAnotherClient(t, pubSess) nsSub, err := subSess.SubscribeNamespace(t.Context(), &message.SubscribeNamespace{ - TrackNamespacePrefix: wire.TrackNamespace{[]byte("video")}, + TrackNamespacePrefix: ns("video"), }) if err != nil { t.Fatalf("SubscribeNamespace: %v", err) @@ -335,3 +252,128 @@ func TestNamespaceStreams_AnswerRequestUpdate(t *testing.T) { t.Fatalf("SUBSCRIBE_NAMESPACE REQUEST_UPDATE unanswered (§10.9): %v", err) } } + +// TestRelay_PrefixOverlap: within a session, a SUBSCRIBE_NAMESPACE or +// SUBSCRIBE_TRACKS whose prefix overlaps an established one of the same type is +// PREFIX_OVERLAP; the two types have independent spaces (§10.19, §10.20). +func TestRelay_PrefixOverlap(t *testing.T) { + t.Parallel() + + t.Run("nested SUBSCRIBE_NAMESPACE is rejected", func(t *testing.T) { + t.Parallel() + sess, teardown := connectRelay(t, relay.Config{}) + defer teardown() + first, err := sess.SubscribeNamespace( + t.Context(), + &message.SubscribeNamespace{TrackNamespacePrefix: ns("video")}, + ) + if err != nil { + t.Fatalf("first SubscribeNamespace: %v", err) + } + t.Cleanup(func() { _ = first.Close() }) + _, err = sess.SubscribeNamespace( + t.Context(), + &message.SubscribeNamespace{TrackNamespacePrefix: ns("video", "cam1")}, + ) + requireRejectedWithCode(t, err, moqt.RequestPrefixOverlap) + }) + + t.Run("nested SUBSCRIBE_TRACKS is rejected", func(t *testing.T) { + t.Parallel() + sess, teardown := connectRelay(t, relay.Config{}) + defer teardown() + first, err := sess.SubscribeTracks( + t.Context(), + &message.SubscribeTracks{TrackNamespacePrefix: ns("video", "cam1")}, + ) + if err != nil { + t.Fatalf("first SubscribeTracks: %v", err) + } + t.Cleanup(func() { _ = first.Close() }) + _, err = sess.SubscribeTracks(t.Context(), &message.SubscribeTracks{TrackNamespacePrefix: ns("video")}) + requireRejectedWithCode(t, err, moqt.RequestPrefixOverlap) + }) + + t.Run("the empty prefix overlaps everything", func(t *testing.T) { + t.Parallel() + sess, teardown := connectRelay(t, relay.Config{}) + defer teardown() + first, err := sess.SubscribeNamespace( + t.Context(), + &message.SubscribeNamespace{TrackNamespacePrefix: ns("video")}, + ) + if err != nil { + t.Fatalf("first SubscribeNamespace: %v", err) + } + t.Cleanup(func() { _ = first.Close() }) + _, err = sess.SubscribeNamespace(t.Context(), &message.SubscribeNamespace{}) + requireRejectedWithCode(t, err, moqt.RequestPrefixOverlap) + }) + + t.Run("the two types have independent spaces", func(t *testing.T) { + t.Parallel() + sess, teardown := connectRelay(t, relay.Config{}) + defer teardown() + a, err := sess.SubscribeNamespace(t.Context(), &message.SubscribeNamespace{TrackNamespacePrefix: ns("video")}) + if err != nil { + t.Fatalf("SubscribeNamespace: %v", err) + } + t.Cleanup(func() { _ = a.Close() }) + b, err := sess.SubscribeTracks(t.Context(), &message.SubscribeTracks{TrackNamespacePrefix: ns("video")}) + if err != nil { + t.Fatalf("SubscribeTracks with the same prefix: %v", err) + } + t.Cleanup(func() { _ = b.Close() }) + }) + + t.Run("disjoint prefixes are accepted", func(t *testing.T) { + t.Parallel() + sess, teardown := connectRelay(t, relay.Config{}) + defer teardown() + a, err := sess.SubscribeNamespace( + t.Context(), + &message.SubscribeNamespace{TrackNamespacePrefix: ns("video", "a")}, + ) + if err != nil { + t.Fatalf("SubscribeNamespace a: %v", err) + } + t.Cleanup(func() { _ = a.Close() }) + b, err := sess.SubscribeNamespace( + t.Context(), + &message.SubscribeNamespace{TrackNamespacePrefix: ns("video", "b")}, + ) + if err != nil { + t.Fatalf("SubscribeNamespace b (disjoint): %v", err) + } + t.Cleanup(func() { _ = b.Close() }) + }) + + t.Run("a cancelled prefix can be reused", func(t *testing.T) { + t.Parallel() + sess, teardown := connectRelay(t, relay.Config{}) + defer teardown() + first, err := sess.SubscribeNamespace( + t.Context(), + &message.SubscribeNamespace{TrackNamespacePrefix: ns("video")}, + ) + if err != nil { + t.Fatalf("first SubscribeNamespace: %v", err) + } + _ = first.Close() // cancels + deadline := time.Now().Add(2 * time.Second) + for { + again, err := sess.SubscribeNamespace( + t.Context(), + &message.SubscribeNamespace{TrackNamespacePrefix: ns("video")}, + ) + if err == nil { + t.Cleanup(func() { _ = again.Close() }) + return + } + if time.Now().After(deadline) { + t.Fatalf("prefix never released after cancel: %v", err) + } + time.Sleep(20 * time.Millisecond) + } + }) +} diff --git a/pkg/relay/session_pubsub_test.go b/pkg/relay/session_pubsub_test.go index 5ee26a18..20169a60 100644 --- a/pkg/relay/session_pubsub_test.go +++ b/pkg/relay/session_pubsub_test.go @@ -25,7 +25,7 @@ func TestPublish_AcceptedAndRegistered(t *testing.T) { defer teardown() stream, err := clientSess.Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), TrackAlias: 1, }) @@ -47,7 +47,7 @@ func TestSubscribe_RejectsWhenNoUpstream(t *testing.T) { defer teardown() _, err := clientSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), }) requireRejectedWithCode(t, err, moqt.RequestDoesNotExist) @@ -64,7 +64,7 @@ func TestSubscribe_ServedFromExistingUpstream(t *testing.T) { defer teardown() pubStream, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), TrackAlias: 42, TrackProperties: opaqueProps("hello props"), @@ -77,7 +77,7 @@ func TestSubscribe_ServedFromExistingUpstream(t *testing.T) { subSess := dialAnotherClient(t, pubSess) subStream, err := subSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), }) if err != nil { @@ -116,7 +116,7 @@ func TestPublish_ForwardsToSubscribeTracks(t *testing.T) { defer teardown() subStream, err := subSess.SubscribeTracks(t.Context(), &message.SubscribeTracks{ - TrackNamespacePrefix: wire.TrackNamespace{[]byte("video")}, + TrackNamespacePrefix: ns("video"), }) if err != nil { t.Fatalf("SubscribeTracks: %v", err) @@ -126,7 +126,7 @@ func TestPublish_ForwardsToSubscribeTracks(t *testing.T) { pubSess := dialAnotherClient(t, subSess) pubStream, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("video"), []byte("cam7")}, + Namespace: ns("video", "cam7"), Name: []byte("rtp"), TrackAlias: 99, }) @@ -180,7 +180,7 @@ func TestPublish_ForwardedAliasDoesNotCollide(t *testing.T) { pub1, teardown := connectRelay(t, relay.Config{}) defer teardown() pub1Req, err := pub1.Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), }) if err != nil { @@ -190,7 +190,7 @@ func TestPublish_ForwardedAliasDoesNotCollide(t *testing.T) { subSess := dialAnotherClient(t, pub1) subReq, err := subSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), }) if err != nil { @@ -199,7 +199,7 @@ func TestPublish_ForwardedAliasDoesNotCollide(t *testing.T) { defer subReq.Close() tracksReq, err := subSess.SubscribeTracks(t.Context(), &message.SubscribeTracks{ - TrackNamespacePrefix: wire.TrackNamespace{[]byte("video")}, + TrackNamespacePrefix: ns("video"), }) if err != nil { t.Fatalf("SubscribeTracks: %v", err) @@ -208,7 +208,7 @@ func TestPublish_ForwardedAliasDoesNotCollide(t *testing.T) { pub2 := dialAnotherClient(t, pub1) pub2Req, err := pub2.Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("video"), []byte("cam7")}, + Namespace: ns("video", "cam7"), Name: []byte("rtp"), TrackAlias: subReq.OK.TrackAlias, // the alias the subscriber already holds for cam1 }) @@ -242,7 +242,7 @@ func TestPublish_ForwardsSubscribeTracksParams(t *testing.T) { defer teardown() subStream, err := subSess.SubscribeTracks(t.Context(), &message.SubscribeTracks{ - TrackNamespacePrefix: wire.TrackNamespace{[]byte("video")}, + TrackNamespacePrefix: ns("video"), Parameters: message.Parameters{ message.ForwardParam(false), message.GroupOrderParam(message.GroupOrderDescending), @@ -255,7 +255,7 @@ func TestPublish_ForwardsSubscribeTracksParams(t *testing.T) { pubSess := dialAnotherClient(t, subSess) pubStream, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("video"), []byte("cam7")}, + Namespace: ns("video", "cam7"), Name: []byte("rtp"), TrackAlias: 99, }) @@ -290,17 +290,13 @@ func TestSubscribeTracks_InvalidGroupOrderClosesSession(t *testing.T) { defer teardown() _, _ = subSess.SubscribeTracks(t.Context(), &message.SubscribeTracks{ - TrackNamespacePrefix: wire.TrackNamespace{[]byte("video")}, + TrackNamespacePrefix: ns("video"), Parameters: message.Parameters{ message.GroupOrderParam(message.GroupOrder(0x07)), // out of range }, }) - select { - case <-subSess.Done(): - case <-time.After(2 * time.Second): - t.Fatal("session not closed after out-of-range GROUP_ORDER SUBSCRIBE_TRACKS (§10.2.8)") - } + requireSessionClosed(t, subSess, "out-of-range GROUP_ORDER SUBSCRIBE_TRACKS (§10.2.8)") } // TestPublish_DuplicateAliasRejected pins the §11.1 duplicate-alias rule: @@ -313,7 +309,7 @@ func TestPublish_DuplicateAliasRejected(t *testing.T) { defer teardown() stream1, err := clientSess.Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), TrackAlias: 7, }) @@ -323,7 +319,7 @@ func TestPublish_DuplicateAliasRejected(t *testing.T) { defer stream1.Close() _, err = clientSess.Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam2"), TrackAlias: 7, }) @@ -345,7 +341,7 @@ func TestSubscribe_OnDemandUpstreamSubscribe(t *testing.T) { // goroutine that accepts the upstream SUBSCRIBE the relay will issue // and replies SUBSCRIBE_OK. pubNSStream, err := pubSess.PublishNamespace(t.Context(), &message.PublishNamespace{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), }) if err != nil { t.Fatalf("PublishNamespace: %v", err) @@ -380,7 +376,7 @@ func TestSubscribe_OnDemandUpstreamSubscribe(t *testing.T) { subSess := dialAnotherClient(t, pubSess) subStream, err := subSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), }) if err != nil { @@ -446,7 +442,7 @@ func TestSubscribe_UpstreamForwardPausedWhenDownstreamForwardZero(t *testing.T) defer teardown() pubNSStream, err := pubSess.PublishNamespace(t.Context(), &message.PublishNamespace{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), }) if err != nil { t.Fatalf("PublishNamespace: %v", err) @@ -457,7 +453,7 @@ func TestSubscribe_UpstreamForwardPausedWhenDownstreamForwardZero(t *testing.T) subSess := dialAnotherClient(t, pubSess) subStream, err := subSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), Parameters: message.Parameters{message.ForwardParam(false)}, }) @@ -481,7 +477,7 @@ func TestSubscribe_UpstreamForwardOmittedWhenDownstreamForwards(t *testing.T) { defer teardown() pubNSStream, err := pubSess.PublishNamespace(t.Context(), &message.PublishNamespace{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), }) if err != nil { t.Fatalf("PublishNamespace: %v", err) @@ -492,7 +488,7 @@ func TestSubscribe_UpstreamForwardOmittedWhenDownstreamForwards(t *testing.T) { subSess := dialAnotherClient(t, pubSess) subStream, err := subSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), }) if err != nil { @@ -516,7 +512,7 @@ func TestSubscribe_UpstreamResumedWhenForwardingSubscriberJoins(t *testing.T) { defer teardown() pubNSStream, err := pubSess.PublishNamespace(t.Context(), &message.PublishNamespace{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), }) if err != nil { t.Fatalf("PublishNamespace: %v", err) @@ -576,7 +572,7 @@ func TestSubscribe_UpstreamResumedWhenForwardingSubscriberJoins(t *testing.T) { // Subscriber A (Forward=0) establishes the paused upstream. subA := dialAnotherClient(t, pubSess) subAStream, err := subA.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), Parameters: message.Parameters{message.ForwardParam(false)}, }) @@ -588,7 +584,7 @@ func TestSubscribe_UpstreamResumedWhenForwardingSubscriberJoins(t *testing.T) { // Subscriber B (Forward omitted → 1) reuses the upstream and must resume it. subB := dialAnotherClient(t, pubSess) subBStream, err := subB.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), }) if err != nil { @@ -648,7 +644,7 @@ func TestSubscribe_UpstreamSurvivesInitiatingSubscriber(t *testing.T) { defer teardown() pubNSStream, err := pubSess.PublishNamespace(t.Context(), &message.PublishNamespace{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), }) if err != nil { t.Fatalf("PublishNamespace: %v", err) @@ -660,7 +656,7 @@ func TestSubscribe_UpstreamSurvivesInitiatingSubscriber(t *testing.T) { subA := dialAnotherClient(t, pubSess) subAStream, err := subA.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), }) if err != nil { @@ -670,7 +666,7 @@ func TestSubscribe_UpstreamSurvivesInitiatingSubscriber(t *testing.T) { subB := dialAnotherClient(t, pubSess) subBStream, err := subB.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), }) if err != nil { @@ -740,7 +736,7 @@ func TestSubscribe_LastDownstreamTearsDownUpstream(t *testing.T) { defer teardown() pubNSStream, err := pubSess.PublishNamespace(t.Context(), &message.PublishNamespace{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), }) if err != nil { t.Fatalf("PublishNamespace: %v", err) @@ -751,7 +747,7 @@ func TestSubscribe_LastDownstreamTearsDownUpstream(t *testing.T) { subSess := dialAnotherClient(t, pubSess) subStream, err := subSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), }) if err != nil { @@ -779,7 +775,7 @@ func TestSubscribe_NoMatchingPublisher_RejectsDoesNotExist(t *testing.T) { defer teardown() pubNSStream, err := pubSess.PublishNamespace(t.Context(), &message.PublishNamespace{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), }) if err != nil { t.Fatalf("PublishNamespace: %v", err) @@ -788,7 +784,7 @@ func TestSubscribe_NoMatchingPublisher_RejectsDoesNotExist(t *testing.T) { subSess := dialAnotherClient(t, pubSess) _, err = subSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("audio")}, // no publisher for this namespace + Namespace: ns("audio"), // no publisher for this namespace Name: []byte("mic"), }) requireRejectedWithCode(t, err, moqt.RequestDoesNotExist) @@ -826,7 +822,7 @@ func TestSubscribe_UpstreamRejects_PropagatesRejection(t *testing.T) { defer teardown() pubNSStream, err := pubSess.PublishNamespace(t.Context(), &message.PublishNamespace{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), }) if err != nil { t.Fatalf("PublishNamespace: %v", err) @@ -845,7 +841,7 @@ func TestSubscribe_UpstreamRejects_PropagatesRejection(t *testing.T) { subSess := dialAnotherClient(t, pubSess) _, err = subSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), }) requireRejectedWithCode(t, err, tc.want) @@ -866,7 +862,7 @@ func TestSubscribe_AuthDenialUsesPolicyCode(t *testing.T) { defer teardown() _, err := clientSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), }) requireRejectedWithCode(t, err, moqt.RequestUnauthorized) @@ -888,7 +884,7 @@ func TestSubscribe_PublisherDisappears_EmitsPublishDone(t *testing.T) { defer teardown() pubReq, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), TrackAlias: 1, }) @@ -899,7 +895,7 @@ func TestSubscribe_PublisherDisappears_EmitsPublishDone(t *testing.T) { subSess := dialAnotherClient(t, pubSess) subReq, err := subSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), }) if err != nil { @@ -943,7 +939,7 @@ func TestSubscribe_PublisherDisappears_StreamClosesAfterPublishDone(t *testing.T defer teardown() pubReq, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), TrackAlias: 1, }) @@ -954,7 +950,7 @@ func TestSubscribe_PublisherDisappears_StreamClosesAfterPublishDone(t *testing.T subSess := dialAnotherClient(t, pubSess) subReq, err := subSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), }) if err != nil { @@ -999,7 +995,7 @@ func TestSubscribe_NoAliasCollisionWhenAlsoPublishing(t *testing.T) { // The client publishes its own track, taking inbound alias 0. pubStream, err := clientSess.Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("room"), []byte("self")}, + Namespace: ns("room", "self"), Name: []byte("video"), TrackAlias: 0, }) @@ -1011,7 +1007,7 @@ func TestSubscribe_NoAliasCollisionWhenAlsoPublishing(t *testing.T) { // A peer publishes a track the client will subscribe to. peerSess := dialAnotherClient(t, clientSess) peerStream, err := peerSess.Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("room"), []byte("peer")}, + Namespace: ns("room", "peer"), Name: []byte("video"), TrackAlias: 0, }) @@ -1024,7 +1020,7 @@ func TestSubscribe_NoAliasCollisionWhenAlsoPublishing(t *testing.T) { // published alias 0 must succeed — the relay's outbound alias (also // starting at 0) must not collide with the inbound alias 0. subStream, err := clientSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("room"), []byte("peer")}, + Namespace: ns("room", "peer"), Name: []byte("video"), }) if err != nil { @@ -1048,9 +1044,9 @@ func TestPublish_SavesLargestObjectFromPublish(t *testing.T) { pubSess, teardown := connectRelay(t, relay.Config{}) defer teardown() - ns := wire.TrackNamespace{[]byte("video")} + video := ns("video") pubStream, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: ns, + Namespace: video, Name: []byte("cam1"), TrackAlias: 42, Parameters: message.Parameters{message.LargestObjectParam(5, 9)}, @@ -1061,7 +1057,7 @@ func TestPublish_SavesLargestObjectFromPublish(t *testing.T) { defer pubStream.Close() subSess := dialAnotherClient(t, pubSess) - subReq, err := subSess.Subscribe(t.Context(), &message.Subscribe{Namespace: ns, Name: []byte("cam1")}) + subReq, err := subSess.Subscribe(t.Context(), &message.Subscribe{Namespace: video, Name: []byte("cam1")}) if err != nil { t.Fatalf("Subscribe: %v", err) } @@ -1095,11 +1091,11 @@ func TestPublish_ForwardedPublishCarriesEntryLargestObject(t *testing.T) { pubA, teardown := connectRelay(t, relay.Config{}) defer teardown() - ns := wire.TrackNamespace{[]byte("video"), []byte("cam7")} + tns := ns("video", "cam7") // First publisher sets the entry's watermark to {9,9}. pubStreamA, err := pubA.Publish(t.Context(), &message.Publish{ - Namespace: ns, + Namespace: tns, Name: []byte("rtp"), TrackAlias: 99, Parameters: message.Parameters{message.LargestObjectParam(9, 9)}, @@ -1112,7 +1108,7 @@ func TestPublish_ForwardedPublishCarriesEntryLargestObject(t *testing.T) { // Second publisher on the SAME track announces a lower one. pubB := dialAnotherClient(t, pubA) pubStreamB, err := pubB.Publish(t.Context(), &message.Publish{ - Namespace: ns, + Namespace: tns, Name: []byte("rtp"), TrackAlias: 100, Parameters: message.Parameters{message.LargestObjectParam(3, 4)}, @@ -1126,7 +1122,7 @@ func TestPublish_ForwardedPublishCarriesEntryLargestObject(t *testing.T) { // are forwarded when the SUBSCRIBE_TRACKS arrives). subSess := dialAnotherClient(t, pubA) subStream, err := subSess.SubscribeTracks(t.Context(), &message.SubscribeTracks{ - TrackNamespacePrefix: wire.TrackNamespace{[]byte("video")}, + TrackNamespacePrefix: ns("video"), }) if err != nil { t.Fatalf("SubscribeTracks: %v", err) diff --git a/pkg/relay/session_update_test.go b/pkg/relay/session_update_test.go index bcc8a53a..6a56e3c1 100644 --- a/pkg/relay/session_update_test.go +++ b/pkg/relay/session_update_test.go @@ -27,7 +27,7 @@ func TestRequestUpdate_PriorityChangeReturnsOK(t *testing.T) { defer teardown() pubStream, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), TrackAlias: 7, }) @@ -38,7 +38,7 @@ func TestRequestUpdate_PriorityChangeReturnsOK(t *testing.T) { subSess := dialAnotherClient(t, pubSess) subMsg := &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), } subStream, err := subSess.Subscribe(t.Context(), subMsg) @@ -71,7 +71,7 @@ func TestRequestUpdate_MalformedRejectedWithUpdateFailed(t *testing.T) { defer teardown() pubStream, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), TrackAlias: 7, }) @@ -82,7 +82,7 @@ func TestRequestUpdate_MalformedRejectedWithUpdateFailed(t *testing.T) { subSess := dialAnotherClient(t, pubSess) subMsg := &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), } subStream, err := subSess.Subscribe(t.Context(), subMsg) @@ -124,7 +124,7 @@ func TestRequestUpdate_InvalidGroupOrderClosesSession(t *testing.T) { defer teardown() pubStream, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), TrackAlias: 7, }) @@ -135,7 +135,7 @@ func TestRequestUpdate_InvalidGroupOrderClosesSession(t *testing.T) { subSess := dialAnotherClient(t, pubSess) subStream, err := subSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), }) if err != nil { @@ -148,11 +148,7 @@ func TestRequestUpdate_InvalidGroupOrderClosesSession(t *testing.T) { _, _ = subSess.UpdateRequest(t.Context(), subStream, message.Parameters{message.ByteParam(message.ParamGroupOrder, 0x05)}) - select { - case <-subSess.Done(): - case <-time.After(2 * time.Second): - t.Fatal("session not closed after out-of-range GROUP_ORDER REQUEST_UPDATE (§10.2.8)") - } + requireSessionClosed(t, subSess, "out-of-range GROUP_ORDER REQUEST_UPDATE (§10.2.8)") } // TestRequestUpdate_ForwardPauseAndResume is the §9.2 data-plane test: @@ -168,7 +164,7 @@ func TestRequestUpdate_ForwardPauseAndResume(t *testing.T) { const publisherAlias = uint64(7) pubStream, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), TrackAlias: publisherAlias, }) @@ -179,7 +175,7 @@ func TestRequestUpdate_ForwardPauseAndResume(t *testing.T) { subSess := dialAnotherClient(t, pubSess) subMsg := &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), } subStream, err := subSess.Subscribe(t.Context(), subMsg) @@ -309,7 +305,7 @@ func TestRequestUpdate_FetchValidUpdateReturnsOK(t *testing.T) { fetchSess := dialAnotherClient(t, pubSess) fetchMsg := &message.Fetch{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), Parameters: message.Parameters{ message.GroupOrderParam(message.GroupOrderAscending), @@ -362,7 +358,7 @@ func TestRequestUpdate_InvalidRequestIDClosesSession(t *testing.T) { defer teardown() pubStream, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), TrackAlias: 7, }) @@ -373,7 +369,7 @@ func TestRequestUpdate_InvalidRequestIDClosesSession(t *testing.T) { subSess := dialAnotherClient(t, pubSess) subStream, err := subSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), }) if err != nil { @@ -387,9 +383,135 @@ func TestRequestUpdate_InvalidRequestIDClosesSession(t *testing.T) { t.Fatalf("write REQUEST_UPDATE: %v", err) } - select { - case <-subSess.Done(): - case <-time.After(2 * time.Second): - t.Fatal("session not closed after wrong-parity REQUEST_UPDATE (§10.1)") + requireSessionClosed(t, subSess, "wrong-parity REQUEST_UPDATE (§10.1)") +} + +// TestRequestUpdateOK_CarriesLargestObject: REQUEST_UPDATE_OK carries +// LARGEST_OBJECT once Objects were published, and omits it before (§10.2.17). +func TestRequestUpdateOK_CarriesLargestObject(t *testing.T) { + t.Parallel() + pubSess, teardown := connectRelay(t, relay.Config{}) + defer teardown() + pub := publishVideoTrack(t, pubSess, "cam1", 1) + subSess := dialAnotherClient(t, pubSess) + subReq := subscribeCam1(t, subSess) + + ok, err := subSess.UpdateRequest(t.Context(), subReq, message.Parameters{message.SubscriberPriorityParam(7)}) + if err != nil { + t.Fatalf("UpdateRequest before any Object: %v", err) } + if p, has := ok.Parameters.Find(message.ParamLargestObject); has { + t.Fatalf("REQUEST_UPDATE_OK carried LARGEST_OBJECT {%d,%d} before any Object was published", p.Group, p.Object) + } + + publishSubgroupWith(t, pub, 3, 1, nil) + if !awaitSubgroupObject(t, subSess, 2*time.Second) { + t.Fatal("the Object never reached the subscriber") + } + ok, err = subSess.UpdateRequest(t.Context(), subReq, message.Parameters{message.SubscriberPriorityParam(9)}) + if err != nil { + t.Fatalf("UpdateRequest after an Object: %v", err) + } + p, has := ok.Parameters.Find(message.ParamLargestObject) + if !has { + t.Fatal("REQUEST_UPDATE_OK omitted LARGEST_OBJECT after Object {3,0} was published") + } + if p.Group != 3 || p.Object != 0 { + t.Fatalf("LARGEST_OBJECT = {%d,%d}, want {3,0}", p.Group, p.Object) + } +} + +// The ParamScope tests: a REQUEST_UPDATE parameter outside the update's scope +// (§10.2.1), or an unknown one (§10.2), closes the session. + +// sendUpdateRaw writes a REQUEST_UPDATE on stream from a goroutine, without +// awaiting a reply. +func sendUpdateRaw(t *testing.T, sess *session.Session, stream session.Stream, params message.Parameters) { + t.Helper() + go func() { + _ = message.Marshal(stream, &message.RequestUpdate{RequestID: sess.AllocRequestID(), Parameters: params}) + }() +} + +// TestRelay_ParamScopeSubscribeUpdate: on a SUBSCRIBE. +func TestRelay_ParamScopeSubscribeUpdate(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + params message.Parameters + }{ + {"TRACK_NAMESPACE_PREFIX", message.Parameters{message.TrackNamespacePrefixParam(ns("video"))}}, + {"unknown parameter", message.Parameters{message.VarintParam(0x3E, 1)}}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + pubSess, _ := newCam1Publisher(t, nil) + subSess := dialAnotherClient(t, pubSess) + sub := subscribeCam1(t, subSess) + sendUpdateRaw(t, subSess, sub.Stream, tc.params) + requireSessionClosed(t, subSess, "a REQUEST_UPDATE parameter outside its scope") + }) + } +} + +// TestRelay_ParamScopeNamespaceUpdate: FORWARD on a SUBSCRIBE_NAMESPACE +// (§10.2.18). +func TestRelay_ParamScopeNamespaceUpdate(t *testing.T) { + t.Parallel() + sess, teardown := connectRelay(t, relay.Config{}) + defer teardown() + nsSub, err := sess.SubscribeNamespace( + t.Context(), + &message.SubscribeNamespace{TrackNamespacePrefix: ns("video")}, + ) + if err != nil { + t.Fatalf("SubscribeNamespace: %v", err) + } + sendUpdateRaw(t, sess, nsSub.Stream, message.Parameters{message.ForwardParam(true)}) + requireSessionClosed(t, sess, "FORWARD in a SUBSCRIBE_NAMESPACE update") +} + +// TestRelay_ParamScopeFetchUpdate: LOCATION_FILTER on a FETCH (§10.2.9). +func TestRelay_ParamScopeFetchUpdate(t *testing.T) { + t.Parallel() + pubSess, alias := newCam1Publisher(t, nil) + publishObjects(t, pubSess, alias, 3, 1) + fetchSess := dialAnotherClient(t, pubSess) + go drainAll(t.Context(), fetchSess) // the relay reads updates once the data is written + // The Object reaches the relay's cache asynchronously; until it does the + // FETCH is refused, so retry. + var fr *session.FetchRequest + deadline := time.Now().Add(2 * time.Second) + for { + var err error + fr, err = fetchSess.Fetch(t.Context(), &message.Fetch{ + Namespace: ns("video"), Name: []byte("cam1"), + }) + if err == nil { + break + } + if time.Now().After(deadline) { + t.Fatalf("Fetch: %v", err) + } + time.Sleep(20 * time.Millisecond) + } + sendUpdateRaw(t, fetchSess, fr.Stream, message.Parameters{ + message.LocationFilterParam(&message.LocationFilter{Fields: 2}), + }) + requireSessionClosed(t, fetchSess, "LOCATION_FILTER in a FETCH update") +} + +// TestRelay_MalformedUpdateClosesSession: a REQUEST_UPDATE whose Length does +// not match its body closes the session (§10). +func TestRelay_MalformedUpdateClosesSession(t *testing.T) { + t.Parallel() + pubSess, _ := newCam1Publisher(t, nil) + subSess := dialAnotherClient(t, pubSess) + sub := subscribeCam1(t, subSess) + enc := wire.NewWriter(nil) + (&message.RequestUpdate{RequestID: subSess.AllocRequestID()}).Append(enc) + go func() { + _ = wire.WriteFrame(sub.Stream, uint64(message.TypeRequestUpdate), append(enc.Bytes(), 0x00)) + }() + requireSessionClosed(t, subSess, "a REQUEST_UPDATE whose Length exceeds its body") } diff --git a/pkg/relay/shared_alias_test.go b/pkg/relay/shared_alias_test.go index bcafbd7b..a31d9184 100644 --- a/pkg/relay/shared_alias_test.go +++ b/pkg/relay/shared_alias_test.go @@ -7,7 +7,6 @@ import ( "github.com/floatdrop/moq-go/pkg/moqt" "github.com/floatdrop/moq-go/pkg/moqt/message" "github.com/floatdrop/moq-go/pkg/moqt/session" - "github.com/floatdrop/moq-go/pkg/moqt/wire" "github.com/floatdrop/moq-go/pkg/relay" ) @@ -22,8 +21,8 @@ func TestRelay_SharedTrackAliasSurvivesFirstSubscriptionEnd(t *testing.T) { const alias = uint64(42) pubSess, teardown := connectRelay(t, relay.Config{}) defer teardown() - ns := wire.TrackNamespace{[]byte("video")} - if _, err := pubSess.PublishNamespace(t.Context(), &message.PublishNamespace{Namespace: ns}); err != nil { + video := ns("video") + if _, err := pubSess.PublishNamespace(t.Context(), &message.PublishNamespace{Namespace: video}); err != nil { t.Fatalf("PublishNamespace: %v", err) } @@ -63,7 +62,7 @@ func TestRelay_SharedTrackAliasSurvivesFirstSubscriptionEnd(t *testing.T) { done := make(chan error, 2) for _, s := range []*session.Session{subA, subB} { go func() { - _, err := s.Subscribe(t.Context(), &message.Subscribe{Namespace: ns, Name: []byte("cam1")}) + _, err := s.Subscribe(t.Context(), &message.Subscribe{Namespace: video, Name: []byte("cam1")}) done <- err }() } diff --git a/pkg/relay/subscribe_tracks_join_test.go b/pkg/relay/subscribe_tracks_join_test.go deleted file mode 100644 index 71c96c94..00000000 --- a/pkg/relay/subscribe_tracks_join_test.go +++ /dev/null @@ -1,86 +0,0 @@ -package relay_test - -import ( - "testing" - "time" - - "github.com/floatdrop/moq-go/pkg/moqt/message" - "github.com/floatdrop/moq-go/pkg/moqt/session" - "github.com/floatdrop/moq-go/pkg/moqt/wire" - "github.com/floatdrop/moq-go/pkg/relay" -) - -// §10.20.1: "Any Parameter that can be specified on a Subscription (ie: in -// SUBSCRIBE) is valid in SUBSCRIBE_TRACKS [...] To join Tracks initiated via -// the resulting PUBLISHes, the subscriber can specify a Location Filter and -// optionally include FILL_PARAMETERS, as described in Section 5.1.6." - -// TestSubscribeTracks_FillParametersOpenFillPerTrack: each forwarded PUBLISH's -// subscription gets its own fill fetch stream, carrying that PUBLISH's Request -// ID (§5.1.3: "the Request ID of the message that initiated it"), once the -// subscriber accepted the PUBLISH. -func TestSubscribeTracks_FillParametersOpenFillPerTrack(t *testing.T) { - t.Parallel() - pubSess, teardown := connectRelay(t, relay.Config{}) - defer teardown() - publishVideoTrack(t, pubSess, "cam", 7) - sendObject(pubSess, 7, 0) - sendObject(pubSess, 7, 1) - time.Sleep(50 * time.Millisecond) // the relay caches both Groups - - holder := dialAnotherClient(t, pubSess) - reqs := forwardedPublishes(t, holder) - openSubscribeTracks(t, holder, ns("video"), - message.NextObjectFilter(), - message.FillParametersParam(message.Parameters{message.UnfilteredFilter()})) - fwd := awaitForwarded(t, reqs) - acceptForwarded(t, fwd) - - ds, ok := tryAcceptDataStream(t, holder, 2*time.Second) - if !ok { - t.Fatal("no fill fetch stream for the forwarded PUBLISH's subscription") - } - fs, ok := ds.(*session.IncomingFetchStream) - if !ok { - t.Fatalf("got %T, want the fill fetch stream", ds) - } - if want := fwd.First.(*message.Publish).RequestID; fs.Header.RequestID != want { - t.Fatalf("fill FETCH_HEADER Request ID %d, want the PUBLISH's %d", fs.Header.RequestID, want) - } - if objs := decodeFetchStream(t, fs, message.GroupOrderAscending); len(objs) != 2 { - t.Fatalf("fill delivered %d Objects, want both cached Groups: %+v", len(objs), objs) - } -} - -// TestSubscribeTracks_NewGroupRequestPropagated: a NEW_GROUP_REQUEST on the -// SUBSCRIBE_TRACKS is handled for each forwarded subscription as for a -// SUBSCRIBE served from an existing upstream (§10.2.19): the relay sends it -// upstream when the track supports dynamic Groups. -func TestSubscribeTracks_NewGroupRequestPropagated(t *testing.T) { - t.Parallel() - pubSess, teardown := connectRelay(t, relay.Config{}) - defer teardown() - pub, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("video")}, Name: []byte("cam"), TrackAlias: 7, - TrackProperties: dynamicGroupsProperties(1), - }) - if err != nil { - t.Fatalf("Publish: %v", err) - } - defer pub.Close() - gotNewGroup := watchUpstreamNewGroup(t, pub.Stream) - - holder := dialAnotherClient(t, pubSess) - reqs := forwardedPublishes(t, holder) - openSubscribeTracks(t, holder, ns("video"), message.NewGroupRequestParam(5)) - acceptForwarded(t, awaitForwarded(t, reqs)) - - select { - case v := <-gotNewGroup: - if v != 5 { - t.Fatalf("upstream NEW_GROUP_REQUEST = %d, want 5", v) - } - case <-time.After(2 * time.Second): - t.Fatal("the relay did not send the SUBSCRIBE_TRACKS's NEW_GROUP_REQUEST upstream") - } -} diff --git a/pkg/relay/subscribe_tracks_test.go b/pkg/relay/subscribe_tracks_test.go new file mode 100644 index 00000000..93dc6f65 --- /dev/null +++ b/pkg/relay/subscribe_tracks_test.go @@ -0,0 +1,664 @@ +package relay_test + +import ( + "context" + "testing" + "time" + + "github.com/floatdrop/moq-go/pkg/moqt" + "github.com/floatdrop/moq-go/pkg/moqt/message" + "github.com/floatdrop/moq-go/pkg/moqt/session" + "github.com/floatdrop/moq-go/pkg/moqt/wire" + "github.com/floatdrop/moq-go/pkg/relay" +) + +// SUBSCRIBE_TRACKS (§6.1, §10.20): the relay forwards a PUBLISH per matching +// track and serves the resulting subscription like any other — Objects on the +// alias it chose (§10.11), REQUEST_UPDATE answered (§10.9), REQUEST_ERROR ends +// it, PUBLISH_DONE closes it (§10.12). + +func TestForwardedPublish_DeliversObjects(t *testing.T) { + t.Parallel() + subSess, teardown := connectRelay(t, relay.Config{}) + defer teardown() + subscribeTracks(t, subSess, ns("video")) + reqs := forwardedPublishes(t, subSess) + + pubSess := dialAnotherClient(t, subSess) + publishVideoTrack(t, pubSess, "cam", 7) + fwd := awaitForwarded(t, reqs) + in := acceptForwarded(t, fwd) + go sendObjects(pubSess, 7, 1, 1) + awaitObjectOn(t, subSess, in.TrackAlias()) +} + +// TestForwardedPublish_UninterestedStopsDelivery: REQUEST_ERROR UNINTERESTED on +// a forwarded PUBLISH (§10.11) stops delivery. +func TestForwardedPublish_UninterestedStopsDelivery(t *testing.T) { + t.Parallel() + subSess, teardown := connectRelay(t, relay.Config{}) + defer teardown() + subscribeTracks(t, subSess, ns("video")) + reqs := forwardedPublishes(t, subSess) + + pubSess := dialAnotherClient(t, subSess) + publishVideoTrack(t, pubSess, "cam", 7) + fwd := awaitForwarded(t, reqs) + alias := fwd.First.(*message.Publish).TrackAlias + // REQUEST_ERROR and a FIN only: no STOP_SENDING, which would end the + // subscription by itself (§3.3.3) whatever the relay made of the error. + refused := make(chan struct{}) + go func() { + _ = message.Marshal(fwd.Stream, &message.RequestError{ + ErrorCode: moqt.RequestUninterested, ErrorReason: "no thanks", + }) + _ = fwd.Stream.Close() + close(refused) + }() + select { + case <-refused: + case <-time.After(2 * time.Second): + t.Fatal("the relay never read the REQUEST_ERROR") + } + time.Sleep(100 * time.Millisecond) // let the relay act on it + + go sendObjects(pubSess, 7, 1, 1) + ctx, cancel := context.WithTimeout(t.Context(), 300*time.Millisecond) + defer cancel() + for { + ds, err := subSess.AcceptDataStream(ctx) + if err != nil { + return // nothing delivered: correct + } + if sg, ok := ds.(*session.IncomingSubgroupStream); ok && sg.Header.TrackAlias == alias { + t.Fatal("relay kept delivering after REQUEST_ERROR UNINTERESTED") + } + } +} + +// TestForwardedPublish_EndsWithPublishDone: when the track's publisher goes +// away the forwarded subscription ends with PUBLISH_DONE, not a bare FIN. +func TestForwardedPublish_EndsWithPublishDone(t *testing.T) { + t.Parallel() + subSess, teardown := connectRelay(t, relay.Config{}) + defer teardown() + subscribeTracks(t, subSess, ns("video")) + reqs := forwardedPublishes(t, subSess) + + pub := publishVideoTrack(t, dialAnotherClient(t, subSess), "cam", 7) + fwd := awaitForwarded(t, reqs) + in := acceptForwarded(t, fwd) + msgs := streamMessages(t, in.Stream) + _ = pub.Done(moqt.PublishDoneTrackEnded, "bye") + if m := nextMessage(t, msgs); m.Type() != message.TypePublishDone { + t.Fatalf("got %T, want PUBLISH_DONE", m) + } +} + +// TestForwardedPublish_AnswersRequestUpdate: the subscriber of a PUBLISH may +// send REQUEST_UPDATE (§10.9), and it gets its single mandated response. +func TestForwardedPublish_AnswersRequestUpdate(t *testing.T) { + t.Parallel() + subSess, teardown := connectRelay(t, relay.Config{}) + defer teardown() + subscribeTracks(t, subSess, ns("video")) + reqs := forwardedPublishes(t, subSess) + + publishVideoTrack(t, dialAnotherClient(t, subSess), "cam", 7) + fwd := awaitForwarded(t, reqs) + acceptForwarded(t, fwd) + // Off the test goroutine: the REQUEST_UPDATE write itself blocks on the + // unbuffered test pipe if the relay never reads it. + answered := make(chan error, 1) + go func() { + _, err := subSess.UpdateRequest(t.Context(), fwd.Stream, message.Parameters{message.ForwardParam(false)}) + answered <- err + }() + select { + case err := <-answered: + if err != nil { + t.Fatalf("REQUEST_UPDATE on a forwarded PUBLISH: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("REQUEST_UPDATE on a forwarded PUBLISH was never answered") + } +} + +// TestForwardedPublish_OnePerTrack: a second publisher of a track the +// subscriber already receives does not open a second subscription to it. +func TestForwardedPublish_OnePerTrack(t *testing.T) { + t.Parallel() + subSess, teardown := connectRelay(t, relay.Config{}) + defer teardown() + subscribeTracks(t, subSess, ns("video")) + reqs := forwardedPublishes(t, subSess) + + publishVideoTrack(t, dialAnotherClient(t, subSess), "cam", 7) + fwd := awaitForwarded(t, reqs) + acceptForwarded(t, fwd) + publishVideoTrack(t, dialAnotherClient(t, subSess), "cam", 9) + requireNoForward(t, reqs, "a second publisher of a forwarded track") +} + +// TestForwardedPublish_ExistingTrackAnnounced: a track published before the +// SUBSCRIBE_TRACKS is forwarded too (§10.20). +func TestForwardedPublish_ExistingTrackAnnounced(t *testing.T) { + t.Parallel() + pubSess, teardown := connectRelay(t, relay.Config{}) + defer teardown() + publishVideoTrack(t, pubSess, "cam", 7) + + subSess := dialAnotherClient(t, pubSess) + reqs := forwardedPublishes(t, subSess) + subscribeTracks(t, subSess, ns("video")) + fwd := awaitForwarded(t, reqs) + if name := string(fwd.First.(*message.Publish).Name); name != "cam" { + t.Fatalf("forwarded %q, want cam", name) + } +} + +// TestForwardedPublish_OwnTrackNotEchoed: the subscriber's own tracks are not +// forwarded to it (§6.1). +func TestForwardedPublish_OwnTrackNotEchoed(t *testing.T) { + t.Parallel() + sess, teardown := connectRelay(t, relay.Config{}) + defer teardown() + subscribeTracks(t, sess, ns("video")) + reqs := forwardedPublishes(t, sess) + publishVideoTrack(t, sess, "mine", 7) + requireNoForward(t, reqs, "the subscriber's own track") +} + +// TestForwardedPublish_DropsUpstreamParameters: the upstream PUBLISH's Message +// Parameters are not forwarded (§10.2.1). +func TestForwardedPublish_DropsUpstreamParameters(t *testing.T) { + t.Parallel() + subSess, teardown := connectRelay(t, relay.Config{}) + defer teardown() + subscribeTracks(t, subSess, ns("video")) + reqs := forwardedPublishes(t, subSess) + + tok := message.AuthorizationTokenParam(message.Token{ + AliasType: message.AliasTypeUseValue, TokenType: 1, TokenValue: []byte("secret"), + }) + publishVideoTrack(t, dialAnotherClient(t, subSess), "cam", 7, tok) + fwd := awaitForwarded(t, reqs) + if _, found := fwd.First.(*message.Publish).Parameters.Find(message.ParamAuthorizationToken); found { + t.Fatal("forwarded PUBLISH carries the upstream's AUTHORIZATION_TOKEN") + } +} + +// TestForwardedPublish_EchoesSubscribeTracksParameters: the SUBSCRIBE_TRACKS +// parameters are echoed in each PUBLISH (§10.20.1), except AUTHORIZATION_TOKEN +// (§10.2.2). +func TestForwardedPublish_EchoesSubscribeTracksParameters(t *testing.T) { + t.Parallel() + subSess, teardown := connectRelay(t, relay.Config{}) + defer teardown() + tok := message.AuthorizationTokenParam(message.Token{ + AliasType: message.AliasTypeUseValue, TokenType: 1, TokenValue: []byte("mine"), + }) + subscribeTracks(t, subSess, ns("video"), message.SubscriberPriorityParam(9), tok) + reqs := forwardedPublishes(t, subSess) + + publishVideoTrack(t, dialAnotherClient(t, subSess), "cam", 7) + params := awaitForwarded(t, reqs).First.(*message.Publish).Parameters + if p, found := params.Find(message.ParamSubscriberPriority); !found || p.Byte != 9 { + t.Errorf("forwarded PUBLISH SUBSCRIBER_PRIORITY = %+v (found %v), want 9", p, found) + } + if _, found := params.Find(message.ParamAuthorizationToken); found { + t.Error("forwarded PUBLISH carries the SUBSCRIBE_TRACKS's AUTHORIZATION_TOKEN") + } +} + +// TestForwardedPublish_SubscribedTrackNotForwarded: a track the subscriber +// already receives through its own SUBSCRIBE is not forwarded as well. +func TestForwardedPublish_SubscribedTrackNotForwarded(t *testing.T) { + t.Parallel() + pubSess, teardown := connectRelay(t, relay.Config{}) + defer teardown() + publishVideoTrack(t, pubSess, "cam", 7) + sess := dialAnotherClient(t, pubSess) + sub, err := sess.Subscribe(t.Context(), &message.Subscribe{Namespace: ns("video"), Name: []byte("cam")}) + if err != nil { + t.Fatalf("Subscribe: %v", err) + } + t.Cleanup(func() { _ = sub.Close() }) + reqs := forwardedPublishes(t, sess) + subscribeTracks(t, sess, ns("video")) + requireNoForward(t, reqs, "a track the subscriber already SUBSCRIBEd to") +} + +// TestForwardedPublish_RepublishedTrackForwardedAgain: after PUBLISH_DONE a new +// publication of the track is forwarded afresh, even before the subscriber +// closed its side (§10.12). +func TestForwardedPublish_RepublishedTrackForwardedAgain(t *testing.T) { + t.Parallel() + subSess, teardown := connectRelay(t, relay.Config{}) + defer teardown() + subscribeTracks(t, subSess, ns("video")) + reqs := forwardedPublishes(t, subSess) + + pub := publishVideoTrack(t, dialAnotherClient(t, subSess), "cam", 7) + in := acceptForwarded(t, awaitForwarded(t, reqs)) + msgs := streamMessages(t, in.Stream) + _ = pub.Done(moqt.PublishDoneTrackEnded, "bye") + if m := nextMessage(t, msgs); m.Type() != message.TypePublishDone { + t.Fatalf("got %T, want PUBLISH_DONE", m) + } + publishVideoTrack(t, dialAnotherClient(t, subSess), "cam", 9) + awaitForwarded(t, reqs) +} + +// TestForwardedPublish_SubscribeTracksParametersValidated: SUBSCRIBE_TRACKS +// parameters are validated as on SUBSCRIBE (§10.20.1): a malformed +// LOCATION_FILTER is MALFORMED_TRACK. +func TestForwardedPublish_SubscribeTracksParametersValidated(t *testing.T) { + t.Parallel() + subSess, teardown := connectRelay(t, relay.Config{}) + defer teardown() + _, err := subSess.SubscribeTracks(t.Context(), &message.SubscribeTracks{ + TrackNamespacePrefix: ns("video"), + Parameters: message.Parameters{message.BytesParam(message.ParamLocationFilter, []byte{0xFF})}, + }) + requireRejectedWithCode(t, err, moqt.RequestMalformedTrack) +} + +// A REQUEST_UPDATE on SUBSCRIBE_TRACKS applies to the PUBLISHes sent from then +// on, not to existing subscriptions (§10.2.18 FORWARD, §10.9.2 prefix); tracks +// that newly match are forwarded when it applies (§10.20). + +// updateSubscribeTracks sends a REQUEST_UPDATE with ps on a SUBSCRIBE_TRACKS +// stream, failing the test unless it is accepted. +func updateSubscribeTracks(t *testing.T, sess *session.Session, stream session.Stream, ps ...message.Parameter) { + t.Helper() + if _, err := sess.UpdateRequest(t.Context(), stream, ps); err != nil { + t.Fatalf("REQUEST_UPDATE on SUBSCRIBE_TRACKS: %v", err) + } +} + +// trackPropertyFilter is a TRACK_PROPERTY_FILTER admitting propType == v. +func trackPropertyFilter(propType uint64, v uint64) message.Parameter { + return message.RangeFilterParam(&message.RangeFilter{ + Type: message.ParamTrackPropertyFilter, PropertyType: propType, + Ranges: []message.Range{{Start: v, End: v}}, + }) +} + +// publishName is the track name of a forwarded PUBLISH. +func publishName(r *session.Request) string { return string(r.First.(*message.Publish).Name) } + +// TestSubscribeTracksUpdate_RangeFilters: an updated TRACK_PROPERTY_FILTER +// forwards an existing track it newly matches, and applies to later PUBLISHes. +func TestSubscribeTracksUpdate_RangeFilters(t *testing.T) { + t.Parallel() + subSess, teardown := connectRelay(t, relay.Config{}) + defer teardown() + reqs := forwardedPublishes(t, subSess) + stream := subscribeTracks(t, subSess, ns("video"), trackPropertyFilter(0x40, 5)) + + pubSess := dialAnotherClient(t, subSess) + publishVideoTrackProps(t, pubSess, "one", 7, trackProp(0x40, 1)) + requireNoForward(t, reqs, "filter 0x40=5, track 0x40=1") + + updateSubscribeTracks(t, subSess, stream, trackPropertyFilter(0x40, 1)) + if got := publishName(awaitForwarded(t, reqs)); got != "one" { + t.Fatalf("forwarded %q after the update, want the existing track \"one\"", got) + } + publishVideoTrackProps(t, pubSess, "two", 8, trackProp(0x40, 1)) + if got := publishName(awaitForwarded(t, reqs)); got != "two" { + t.Fatalf("forwarded %q, want the new track \"two\"", got) + } + publishVideoTrackProps(t, pubSess, "three", 9, trackProp(0x40, 5)) + requireNoForward(t, reqs, "updated filter 0x40=1, new track 0x40=5") +} + +// TestSubscribeTracksUpdate_ForwardAppliesToFuturePublishes: FORWARD=0 in the +// update is carried on PUBLISHes sent afterwards; the existing forwarded +// subscription keeps receiving Objects. +func TestSubscribeTracksUpdate_ForwardAppliesToFuturePublishes(t *testing.T) { + t.Parallel() + subSess, teardown := connectRelay(t, relay.Config{}) + defer teardown() + reqs := forwardedPublishes(t, subSess) + stream := subscribeTracks(t, subSess, ns("video")) + + pubSess := dialAnotherClient(t, subSess) + publishVideoTrack(t, pubSess, "one", 7) + first := awaitForwarded(t, reqs) + acceptForwarded(t, first) + + updateSubscribeTracks(t, subSess, stream, message.ForwardParam(false)) + publishVideoTrack(t, pubSess, "two", 8) + second := awaitForwarded(t, reqs) + if p, ok := second.First.(*message.Publish).Parameters.Find(message.ParamForward); !ok || p.Byte != 0 { + t.Fatalf("PUBLISH after the FORWARD=0 update carries FORWARD %v (present %v), want 0", p.Byte, ok) + } + + go sendObjects(pubSess, 7, 1, 1) + awaitObjectOn(t, subSess, first.First.(*message.Publish).TrackAlias) +} + +// TestSubscribeTracksUpdate_PrefixForwardsExistingTracks: after a +// TRACK_NAMESPACE_PREFIX update, the tracks that already exist under the new +// prefix are forwarded, not only later ones. +func TestSubscribeTracksUpdate_PrefixForwardsExistingTracks(t *testing.T) { + t.Parallel() + subSess, teardown := connectRelay(t, relay.Config{}) + defer teardown() + reqs := forwardedPublishes(t, subSess) + stream := subscribeTracks(t, subSess, ns("audio")) + + pubSess := dialAnotherClient(t, subSess) + publishVideoTrack(t, pubSess, "cam", 7) + requireNoForward(t, reqs, "prefix audio, track video/cam") + + updateSubscribeTracks(t, subSess, stream, message.TrackNamespacePrefixParam(ns("video"))) + if got := publishName(awaitForwarded(t, reqs)); got != "cam" { + t.Fatalf("forwarded %q after the prefix update, want the existing video/cam", got) + } +} + +// TestSubscribeTracksUpdate_ExistingSubscriptionsUnaffected: an update whose +// filters no longer match a forwarded track does not end its subscription, +// and an update does not re-forward a track the subscriber refused. +func TestSubscribeTracksUpdate_ExistingSubscriptionsUnaffected(t *testing.T) { + t.Parallel() + subSess, teardown := connectRelay(t, relay.Config{}) + defer teardown() + reqs := forwardedPublishes(t, subSess) + stream := subscribeTracks(t, subSess, ns("video")) + + pubSess := dialAnotherClient(t, subSess) + publishVideoTrackProps(t, pubSess, "kept", 7, trackProp(0x40, 1)) + kept := awaitForwarded(t, reqs) + acceptForwarded(t, kept) + publishVideoTrackProps(t, pubSess, "refused", 8, trackProp(0x40, 1)) + refused := awaitForwarded(t, reqs) + _ = message.Marshal(refused.Stream, &message.RequestError{ErrorCode: moqt.RequestUninterested}) + _ = refused.Stream.Close() + time.Sleep(100 * time.Millisecond) + + // Matching does not change, so nothing newly matches: the refused + // track is not offered again. + updateSubscribeTracks(t, subSess, stream, message.ForwardParam(true)) + requireNoForward(t, reqs, "an update that changes no match") + // The kept track stops matching; its subscription carries on. + updateSubscribeTracks(t, subSess, stream, trackPropertyFilter(0x40, 9)) + + go sendObjects(pubSess, 7, 1, 1) + awaitObjectOn(t, subSess, kept.First.(*message.Publish).TrackAlias) +} + +// TestSubscribeTracks_ForwardsTrackGainedBySubscribe: a track the relay gains +// by SUBSCRIBEing to a namespace publisher is forwarded (§10.20), except to the +// subscriber whose SUBSCRIBE caused it. +func TestSubscribeTracks_ForwardsTrackGainedBySubscribe(t *testing.T) { + t.Parallel() + holder, teardown := connectRelay(t, relay.Config{}) + defer teardown() + reqs := forwardedPublishes(t, holder) + subscribeTracks(t, holder, ns("video")) + + pubSess := dialAnotherClient(t, holder) + if _, err := pubSess.PublishNamespace(t.Context(), &message.PublishNamespace{Namespace: ns("video")}); err != nil { + t.Fatalf("PublishNamespace: %v", err) + } + go func() { + for { + r, err := pubSess.AcceptRequest(t.Context()) + if err != nil { + return + } + if _, ok := r.First.(*message.Subscribe); ok { + _ = r.Reply(&message.SubscribeOK{TrackAlias: 7}) + } + } + }() + requireNoForward(t, reqs, "a namespace with no track yet") + + subSess := dialAnotherClient(t, holder) + subReqs := forwardedPublishes(t, subSess) + subscribeTracks(t, subSess, ns("video")) + subscribeCam1(t, subSess) + + if got := publishName(awaitForwarded(t, reqs)); got != "cam1" { + t.Fatalf("forwarded %q, want video/cam1", got) + } + requireNoForward(t, subReqs, "the subscriber whose SUBSCRIBE created the track") +} + +// TestSubscribeTracksUpdate_RefusalEndsRequest: a refused REQUEST_UPDATE ends +// the SUBSCRIBE_TRACKS (§10.9.1, §3.3.2), freeing its prefix for a new one. +func TestSubscribeTracksUpdate_RefusalEndsRequest(t *testing.T) { + t.Parallel() + subSess, teardown := connectRelay(t, relay.Config{}) + defer teardown() + stream := subscribeTracks(t, subSess, ns("video")) + + // An odd Property Type in a TRACK_PROPERTY_FILTER is INVALID_FILTER. + _, err := subSess.UpdateRequest(t.Context(), stream, message.Parameters{ + message.RangeFilterParam(&message.RangeFilter{ + Type: message.ParamTrackPropertyFilter, PropertyType: 0x41, Ranges: []message.Range{{Start: 1, End: 1}}, + }), + }) + requireRejectedWithCode(t, err, moqt.RequestInvalidFilter) + + deadline := time.Now().Add(2 * time.Second) + for { + ts, err := subSess.SubscribeTracks(t.Context(), &message.SubscribeTracks{TrackNamespacePrefix: ns("video")}) + if err == nil { + _ = ts.Close() + return + } + if time.Now().After(deadline) { + t.Fatalf( + "SUBSCRIBE_TRACKS video after the refused update: %v; the ended request still holds its prefix", + err, + ) + } + time.Sleep(20 * time.Millisecond) + } +} + +// TestSubscribeTracks_FillParametersOpenFillPerTrack: FILL_PARAMETERS on +// SUBSCRIBE_TRACKS (§10.20.1) opens a fill fetch stream per forwarded PUBLISH, +// carrying that PUBLISH's Request ID (§5.1.3). +func TestSubscribeTracks_FillParametersOpenFillPerTrack(t *testing.T) { + t.Parallel() + pubSess, teardown := connectRelay(t, relay.Config{}) + defer teardown() + publishVideoTrack(t, pubSess, "cam", 7) + sendObjects(pubSess, 7, 0, 1) + sendObjects(pubSess, 7, 1, 1) + time.Sleep(50 * time.Millisecond) // the relay caches both Groups + + holder := dialAnotherClient(t, pubSess) + reqs := forwardedPublishes(t, holder) + subscribeTracks(t, holder, ns("video"), + message.NextObjectFilter(), + message.FillParametersParam(message.Parameters{message.UnfilteredFilter()})) + fwd := awaitForwarded(t, reqs) + acceptForwarded(t, fwd) + + ds, ok := tryAcceptDataStream(t, holder, 2*time.Second) + if !ok { + t.Fatal("no fill fetch stream for the forwarded PUBLISH's subscription") + } + fs, ok := ds.(*session.IncomingFetchStream) + if !ok { + t.Fatalf("got %T, want the fill fetch stream", ds) + } + if want := fwd.First.(*message.Publish).RequestID; fs.Header.RequestID != want { + t.Fatalf("fill FETCH_HEADER Request ID %d, want the PUBLISH's %d", fs.Header.RequestID, want) + } + if objs := decodeFetchStream(t, fs, message.GroupOrderAscending); len(objs) != 2 { + t.Fatalf("fill delivered %d Objects, want both cached Groups: %+v", len(objs), objs) + } +} + +// TestSubscribeTracks_NewGroupRequestPropagated: a NEW_GROUP_REQUEST on +// SUBSCRIBE_TRACKS reaches the upstream of a dynamic-Groups track (§10.2.19). +func TestSubscribeTracks_NewGroupRequestPropagated(t *testing.T) { + t.Parallel() + pubSess, teardown := connectRelay(t, relay.Config{}) + defer teardown() + pub := publishVideoTrackProps(t, pubSess, "cam", 7, trackProp(message.PropertyDynamicGroups, 1)) + gotNewGroup := watchUpstreamNewGroup(t, pub.Stream) + + holder := dialAnotherClient(t, pubSess) + reqs := forwardedPublishes(t, holder) + subscribeTracks(t, holder, ns("video"), message.NewGroupRequestParam(5)) + acceptForwarded(t, awaitForwarded(t, reqs)) + + select { + case v := <-gotNewGroup: + if v != 5 { + t.Fatalf("upstream NEW_GROUP_REQUEST = %d, want 5", v) + } + case <-time.After(2 * time.Second): + t.Fatal("the relay did not send the SUBSCRIBE_TRACKS's NEW_GROUP_REQUEST upstream") + } +} + +// INCLUDE_PROPERTIES=0 (§10.2.21) empties the Track Properties of the OK or +// forwarded PUBLISH. The subscriber then reads DEFAULT_PUBLISHER_PRIORITY as +// 128 (§12.4), so the relay writes the priority inline on what it forwards. + +const trackDefaultPriority = 7 + +// priorityTrackProps sets DEFAULT_PUBLISHER_PRIORITY to trackDefaultPriority. +func priorityTrackProps() []wire.KVPair { + return trackProp(message.PropertyDefaultPublisherPriority, trackDefaultPriority) +} + +// noProps is INCLUDE_PROPERTIES=0. +func noProps() message.Parameter { return message.IncludePropertiesParam(false) } + +func TestIncludeProperties_SubscribeOK(t *testing.T) { + t.Parallel() + pubSess, alias := newCam1Publisher(t, priorityTrackProps()) + subSess := dialAnotherClient(t, pubSess) + sub := subscribeCam1(t, subSess, noProps()) + if len(sub.OK.TrackProperties) != 0 { + t.Fatalf("SUBSCRIBE_OK Track Properties %x with INCLUDE_PROPERTIES=0, want empty", sub.OK.TrackProperties) + } + with := subscribeCam1(t, dialAnotherClient(t, pubSess)) + if len(with.OK.TrackProperties) == 0 { + t.Fatal("SUBSCRIBE_OK lost its Track Properties without INCLUDE_PROPERTIES") + } + + // The subgroup the publisher sends with the default priority reaches + // the subscriber with the priority spelled out. + go sendObjects(pubSess, alias, 1, 1) + ds, ok := tryAcceptDataStream(t, subSess, 2*time.Second) + if !ok { + t.Fatal("no subgroup forwarded") + } + sg := ds.(*session.IncomingSubgroupStream) + if !sg.Header.InlinePriority || sg.Header.PublisherPriority != trackDefaultPriority { + t.Fatalf("forwarded header inline=%v priority=%d, want inline priority %d", + sg.Header.InlinePriority, sg.Header.PublisherPriority, trackDefaultPriority) + } +} + +func TestIncludeProperties_Datagram(t *testing.T) { + t.Parallel() + pubSess, alias := newCam1Publisher(t, priorityTrackProps()) + subSess := dialAnotherClient(t, pubSess) + subscribeCam1(t, subSess, noProps()) + if err := pubSess.SendDatagram(&message.ObjectDatagram{ + Type: message.DatagramDefaultPriorityBit, TrackAlias: alias, GroupID: 1, ObjectPayload: []byte("x"), + }); err != nil { + t.Fatalf("SendDatagram: %v", err) + } + ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) + defer cancel() + d, err := subSess.ReceiveDatagram(ctx) + if err != nil { + t.Fatalf("ReceiveDatagram: %v", err) + } + if d.HasDefaultPriority() || d.PublisherPriority != trackDefaultPriority { + t.Fatalf("forwarded datagram default=%v priority=%d, want explicit priority %d", + d.HasDefaultPriority(), d.PublisherPriority, trackDefaultPriority) + } +} + +func TestIncludeProperties_FetchAndTrackStatus(t *testing.T) { + t.Parallel() + pubSess, alias := newCam1Publisher(t, priorityTrackProps()) + newCam1Subscriber(t, pubSess) // keeps the track's upstream alive + publishObjects(t, pubSess, alias, 1, 1) + time.Sleep(50 * time.Millisecond) + c := dialAnotherClient(t, pubSess) + + ts, err := c.TrackStatus(t.Context(), &message.TrackStatus{ + Namespace: ns("video"), Name: []byte("cam1"), + Parameters: message.Parameters{noProps()}, + }) + if err != nil { + t.Fatalf("TrackStatus: %v", err) + } + if len(ts.OK.TrackProperties) != 0 { + t.Fatalf("TRACK_STATUS_OK Track Properties %x with INCLUDE_PROPERTIES=0, want empty", ts.OK.TrackProperties) + } + + fr, err := c.Fetch(t.Context(), &message.Fetch{ + Namespace: ns("video"), Name: []byte("cam1"), + Parameters: message.Parameters{noProps(), + fetchRangeFilter(message.Location{Group: 1}, message.Location{Group: 1})}, + }) + if err != nil { + t.Fatalf("Fetch: %v", err) + } + defer fr.Close() + if len(fr.OK.TrackProperties) != 0 { + t.Fatalf("FETCH_OK Track Properties %x with INCLUDE_PROPERTIES=0, want empty", fr.OK.TrackProperties) + } + go drainAll(t.Context(), c) +} + +func TestIncludeProperties_SubscribeTracks(t *testing.T) { + t.Parallel() + holder, teardown := connectRelay(t, relay.Config{}) + defer teardown() + reqs := forwardedPublishes(t, holder) + subscribeTracks(t, holder, ns("video"), noProps()) + + pubSess := dialAnotherClient(t, holder) + publishVideoTrackProps(t, pubSess, "cam", 7, priorityTrackProps()) + fwd := awaitForwarded(t, reqs) + if tp := fwd.First.(*message.Publish).TrackProperties; len(tp) != 0 { + t.Fatalf("forwarded PUBLISH Track Properties %x with INCLUDE_PROPERTIES=0, want empty", tp) + } + acceptForwarded(t, fwd) + + go sendObjects(pubSess, 7, 1, 1) + ds, ok := tryAcceptDataStream(t, holder, 2*time.Second) + if !ok { + t.Fatal("no subgroup forwarded") + } + if h := ds.(*session.IncomingSubgroupStream).Header; !h.InlinePriority || + h.PublisherPriority != trackDefaultPriority { + t.Fatalf("forwarded header inline=%v priority=%d, want inline priority %d", + h.InlinePriority, h.PublisherPriority, trackDefaultPriority) + } +} + +// TestIncludeProperties_TrackStatusStillAnswers: INCLUDE_PROPERTIES=0 on a +// known track with no Objects yet still gets TRACK_STATUS_OK. +func TestIncludeProperties_TrackStatusStillAnswers(t *testing.T) { + t.Parallel() + pubSess, _ := newCam1Publisher(t, priorityTrackProps()) + c := dialAnotherClient(t, pubSess) + ts, err := c.TrackStatus(t.Context(), &message.TrackStatus{ + Namespace: ns("video"), Name: []byte("cam1"), + Parameters: message.Parameters{noProps()}, + }) + if err != nil { + t.Fatalf("TRACK_STATUS with INCLUDE_PROPERTIES=0 on a known track: %v", err) + } + if len(ts.OK.TrackProperties) != 0 { + t.Fatalf("TRACK_STATUS_OK Track Properties %x, want empty", ts.OK.TrackProperties) + } +} diff --git a/pkg/relay/subscribe_tracks_update_test.go b/pkg/relay/subscribe_tracks_update_test.go deleted file mode 100644 index e242f817..00000000 --- a/pkg/relay/subscribe_tracks_update_test.go +++ /dev/null @@ -1,251 +0,0 @@ -package relay_test - -import ( - "testing" - "time" - - "github.com/floatdrop/moq-go/pkg/moqt" - "github.com/floatdrop/moq-go/pkg/moqt/message" - "github.com/floatdrop/moq-go/pkg/moqt/session" - "github.com/floatdrop/moq-go/pkg/moqt/wire" - "github.com/floatdrop/moq-go/pkg/relay" -) - -// A REQUEST_UPDATE on a SUBSCRIBE_TRACKS changes the parameters used for the -// PUBLISHes the relay sends from then on — §10.2.18 says so of FORWARD: "In -// the case of a REQUEST_UPDATE for SUBSCRIBE_TRACKS, it specifies the -// Forwarding State on future subscriptions that match the prefix. Existing -// subscriptions are unaffected." — and §10.9.2 of the prefix: "Updating the -// prefix of a SUBSCRIBE_TRACKS has no effect on existing subscriptions". -// Tracks that exist and newly match (§10.20: "request PUBLISH messages for all -// tracks within matching namespaces") are forwarded when the update applies. - -// openSubscribeTracks sends SUBSCRIBE_TRACKS for prefix and returns its stream, -// for REQUEST_UPDATEs. -func openSubscribeTracks( - t *testing.T, - sess *session.Session, - prefix wire.TrackNamespace, - ps ...message.Parameter, -) session.Stream { - t.Helper() - ts, err := sess.SubscribeTracks(t.Context(), &message.SubscribeTracks{TrackNamespacePrefix: prefix, Parameters: ps}) - if err != nil { - t.Fatalf("SubscribeTracks: %v", err) - } - t.Cleanup(func() { _ = ts.Close() }) - return ts.Stream -} - -func updateSubscribeTracks(t *testing.T, sess *session.Session, stream session.Stream, ps ...message.Parameter) { - t.Helper() - if _, err := sess.UpdateRequest(t.Context(), stream, ps); err != nil { - t.Fatalf("REQUEST_UPDATE on SUBSCRIBE_TRACKS: %v", err) - } -} - -// publishWithProps PUBLISHes ns/name from sess with one Track Property. -func publishWithProps( - t *testing.T, - sess *session.Session, - namespace wire.TrackNamespace, - name string, - alias, propType, propVal uint64, -) { - t.Helper() - p, err := sess.Publish(t.Context(), &message.Publish{ - Namespace: namespace, Name: []byte(name), TrackAlias: alias, - TrackProperties: message.AppendTrackProperties([]wire.KVPair{{Type: propType, IntVal: propVal}}), - }) - if err != nil { - t.Fatalf("Publish %s: %v", name, err) - } - t.Cleanup(func() { _ = p.Close() }) -} - -func trackPropertyFilter(propType uint64, v uint64) message.Parameter { - return message.RangeFilterParam(&message.RangeFilter{ - Type: message.ParamTrackPropertyFilter, PropertyType: propType, - Ranges: []message.Range{{Start: v, End: v}}, - }) -} - -func publishName(r *session.Request) string { return string(r.First.(*message.Publish).Name) } - -// TestSubscribeTracksUpdate_RangeFilters: an updated TRACK_PROPERTY_FILTER -// forwards an existing track it newly matches, and applies to later PUBLISHes. -func TestSubscribeTracksUpdate_RangeFilters(t *testing.T) { - t.Parallel() - subSess, teardown := connectRelay(t, relay.Config{}) - defer teardown() - reqs := forwardedPublishes(t, subSess) - stream := openSubscribeTracks(t, subSess, ns("video"), trackPropertyFilter(0x40, 5)) - - pubSess := dialAnotherClient(t, subSess) - publishWithProps(t, pubSess, ns("video"), "one", 7, 0x40, 1) - requireNoForward(t, reqs, "filter 0x40=5, track 0x40=1") - - updateSubscribeTracks(t, subSess, stream, trackPropertyFilter(0x40, 1)) - if got := publishName(awaitForwarded(t, reqs)); got != "one" { - t.Fatalf("forwarded %q after the update, want the existing track \"one\"", got) - } - publishWithProps(t, pubSess, ns("video"), "two", 8, 0x40, 1) - if got := publishName(awaitForwarded(t, reqs)); got != "two" { - t.Fatalf("forwarded %q, want the new track \"two\"", got) - } - publishWithProps(t, pubSess, ns("video"), "three", 9, 0x40, 5) - requireNoForward(t, reqs, "updated filter 0x40=1, new track 0x40=5") -} - -// TestSubscribeTracksUpdate_ForwardAppliesToFuturePublishes: FORWARD=0 in the -// update is carried on PUBLISHes sent afterwards; the existing forwarded -// subscription keeps receiving Objects. -func TestSubscribeTracksUpdate_ForwardAppliesToFuturePublishes(t *testing.T) { - t.Parallel() - subSess, teardown := connectRelay(t, relay.Config{}) - defer teardown() - reqs := forwardedPublishes(t, subSess) - stream := openSubscribeTracks(t, subSess, ns("video")) - - pubSess := dialAnotherClient(t, subSess) - publishVideoTrack(t, pubSess, "one", 7) - first := awaitForwarded(t, reqs) - acceptForwarded(t, first) - - updateSubscribeTracks(t, subSess, stream, message.ForwardParam(false)) - publishVideoTrack(t, pubSess, "two", 8) - second := awaitForwarded(t, reqs) - if p, ok := second.First.(*message.Publish).Parameters.Find(message.ParamForward); !ok || p.Byte != 0 { - t.Fatalf("PUBLISH after the FORWARD=0 update carries FORWARD %v (present %v), want 0", p.Byte, ok) - } - - go sendObject(pubSess, 7, 1) - awaitObjectOn(t, subSess, first.First.(*message.Publish).TrackAlias) -} - -// TestSubscribeTracksUpdate_PrefixForwardsExistingTracks: after a -// TRACK_NAMESPACE_PREFIX update, the tracks that already exist under the new -// prefix are forwarded, not only later ones. -func TestSubscribeTracksUpdate_PrefixForwardsExistingTracks(t *testing.T) { - t.Parallel() - subSess, teardown := connectRelay(t, relay.Config{}) - defer teardown() - reqs := forwardedPublishes(t, subSess) - stream := openSubscribeTracks(t, subSess, ns("audio")) - - pubSess := dialAnotherClient(t, subSess) - publishVideoTrack(t, pubSess, "cam", 7) - requireNoForward(t, reqs, "prefix audio, track video/cam") - - updateSubscribeTracks(t, subSess, stream, message.TrackNamespacePrefixParam(ns("video"))) - if got := publishName(awaitForwarded(t, reqs)); got != "cam" { - t.Fatalf("forwarded %q after the prefix update, want the existing video/cam", got) - } -} - -// TestSubscribeTracksUpdate_ExistingSubscriptionsUnaffected: an update whose -// filters no longer match a forwarded track does not end its subscription, -// and an update does not re-forward a track the subscriber refused. -func TestSubscribeTracksUpdate_ExistingSubscriptionsUnaffected(t *testing.T) { - t.Parallel() - subSess, teardown := connectRelay(t, relay.Config{}) - defer teardown() - reqs := forwardedPublishes(t, subSess) - stream := openSubscribeTracks(t, subSess, ns("video")) - - pubSess := dialAnotherClient(t, subSess) - publishWithProps(t, pubSess, ns("video"), "kept", 7, 0x40, 1) - kept := awaitForwarded(t, reqs) - acceptForwarded(t, kept) - publishWithProps(t, pubSess, ns("video"), "refused", 8, 0x40, 1) - refused := awaitForwarded(t, reqs) - _ = message.Marshal(refused.Stream, &message.RequestError{ErrorCode: moqt.RequestUninterested}) - _ = refused.Stream.Close() - time.Sleep(100 * time.Millisecond) - - // Matching does not change, so nothing newly matches: the refused - // track is not offered again. - updateSubscribeTracks(t, subSess, stream, message.ForwardParam(true)) - requireNoForward(t, reqs, "an update that changes no match") - // The kept track stops matching; its subscription carries on. - updateSubscribeTracks(t, subSess, stream, trackPropertyFilter(0x40, 9)) - - go sendObject(pubSess, 7, 1) - awaitObjectOn(t, subSess, kept.First.(*message.Publish).TrackAlias) -} - -// TestSubscribeTracks_ForwardsTrackGainedBySubscribe: SUBSCRIBE_TRACKS asks for -// "all tracks within matching namespaces, as well as future track -// publications" (§10.20). A track that appears because the relay itself -// SUBSCRIBEd to a namespace publisher, not because one PUBLISHed it, is one -// of them. The subscriber whose SUBSCRIBE caused it gets no PUBLISH for it. -func TestSubscribeTracks_ForwardsTrackGainedBySubscribe(t *testing.T) { - t.Parallel() - holder, teardown := connectRelay(t, relay.Config{}) - defer teardown() - reqs := forwardedPublishes(t, holder) - openSubscribeTracks(t, holder, ns("video")) - - pubSess := dialAnotherClient(t, holder) - if _, err := pubSess.PublishNamespace(t.Context(), &message.PublishNamespace{Namespace: ns("video")}); err != nil { - t.Fatalf("PublishNamespace: %v", err) - } - go func() { - for { - r, err := pubSess.AcceptRequest(t.Context()) - if err != nil { - return - } - if _, ok := r.First.(*message.Subscribe); ok { - _ = r.Reply(&message.SubscribeOK{TrackAlias: 7}) - } - } - }() - requireNoForward(t, reqs, "a namespace with no track yet") - - subSess := dialAnotherClient(t, holder) - subReqs := forwardedPublishes(t, subSess) - openSubscribeTracks(t, subSess, ns("video")) - subscribeCam1Req(t, subSess) - - if got := publishName(awaitForwarded(t, reqs)); got != "cam1" { - t.Fatalf("forwarded %q, want video/cam1", got) - } - requireNoForward(t, subReqs, "the subscriber whose SUBSCRIBE created the track") -} - -// TestSubscribeTracksUpdate_RefusalEndsRequest: "When a REQUEST_UPDATE fails -// for a SUBSCRIBE_NAMESPACE, SUBSCRIBE_TRACKS or PUBLISH_NAMESPACE, the -// responder MUST close the bidi stream" (§10.9.1), which ends the request -// (§3.3.2). The relay stops serving it: its prefix is free again for a new -// SUBSCRIBE_TRACKS on the same session. -func TestSubscribeTracksUpdate_RefusalEndsRequest(t *testing.T) { - t.Parallel() - subSess, teardown := connectRelay(t, relay.Config{}) - defer teardown() - stream := openSubscribeTracks(t, subSess, ns("video")) - - // An odd Property Type in a TRACK_PROPERTY_FILTER is INVALID_FILTER. - _, err := subSess.UpdateRequest(t.Context(), stream, message.Parameters{ - message.RangeFilterParam(&message.RangeFilter{ - Type: message.ParamTrackPropertyFilter, PropertyType: 0x41, Ranges: []message.Range{{Start: 1, End: 1}}, - }), - }) - requireRejectedWithCode(t, err, moqt.RequestInvalidFilter) - - deadline := time.Now().Add(2 * time.Second) - for { - ts, err := subSess.SubscribeTracks(t.Context(), &message.SubscribeTracks{TrackNamespacePrefix: ns("video")}) - if err == nil { - _ = ts.Close() - return - } - if time.Now().After(deadline) { - t.Fatalf( - "SUBSCRIBE_TRACKS video after the refused update: %v; the ended request still holds its prefix", - err, - ) - } - time.Sleep(20 * time.Millisecond) - } -} diff --git a/pkg/relay/token_verify_test.go b/pkg/relay/token_verify_test.go index c8d7accf..4358c7b8 100644 --- a/pkg/relay/token_verify_test.go +++ b/pkg/relay/token_verify_test.go @@ -7,7 +7,6 @@ import ( "github.com/floatdrop/moq-go/pkg/moqt" "github.com/floatdrop/moq-go/pkg/moqt/message" "github.com/floatdrop/moq-go/pkg/moqt/session" - "github.com/floatdrop/moq-go/pkg/moqt/wire" "github.com/floatdrop/moq-go/pkg/relay" ) @@ -30,7 +29,7 @@ func TestSessionHandler_TokenDenialMapsToRequestError(t *testing.T) { defer teardown() _, err := clientSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), Parameters: message.Parameters{ message.AuthorizationTokenParam(message.Token{ @@ -58,7 +57,7 @@ func TestSessionHandler_TokenAllowReachesHandler(t *testing.T) { defer teardown() _, err := clientSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, + Namespace: ns("video"), Name: []byte("cam1"), Parameters: message.Parameters{ message.AuthorizationTokenParam(message.Token{ diff --git a/pkg/relay/track_status_largest_test.go b/pkg/relay/track_status_largest_test.go deleted file mode 100644 index af63bb03..00000000 --- a/pkg/relay/track_status_largest_test.go +++ /dev/null @@ -1,184 +0,0 @@ -package relay_test - -import ( - "testing" - "time" - - "github.com/floatdrop/moq-go/pkg/moqt/message" - "github.com/floatdrop/moq-go/pkg/moqt/wire" - "github.com/floatdrop/moq-go/pkg/relay" -) - -// TestTrackStatus_ReturnsLargestObject is the canonical assertion: after -// the publisher emits objects, a TRACK_STATUS for the same track carries -// the §10.2.17 LARGEST_OBJECT parameter in TRACK_STATUS_OK, sourced from -// the entry's watermark (which the fanout maintains on every forwarded -// object). -func TestTrackStatus_ReturnsLargestObject(t *testing.T) { - t.Parallel() - - pubSess, _, publisherAlias := publishAndCache(t) - publishObjects(t, pubSess, publisherAlias, 4 /*group*/, 3 /*count*/) - - // Watermark = {4, 2}. Give the relay a beat to drain the fanout - // into the cache + watermark before issuing the query. - time.Sleep(50 * time.Millisecond) - - querySess := dialAnotherClient(t, pubSess) - tsStream, err := querySess.TrackStatus(t.Context(), &message.TrackStatus{ - Namespace: wire.TrackNamespace{[]byte("video")}, - Name: []byte("cam1"), - }) - if err != nil { - t.Fatalf("TrackStatus: %v", err) - } - defer tsStream.Close() - - p, found := tsStream.OK.Parameters.Find(message.ParamLargestObject) - if !found { - t.Fatal("TRACK_STATUS_OK missing LARGEST_OBJECT parameter") - } - if p.Group != 4 || p.Object != 2 { - t.Fatalf("LARGEST_OBJECT = {%d, %d}, want {4, 2}", p.Group, p.Object) - } -} - -// TestTrackStatus_OmitsLargestObjectBeforeAnyObjects pins the boundary -// for tracks the relay knows about but where no object has been -// forwarded yet (e.g., publisher claimed the track via PUBLISH but -// hasn't opened a subgroup). The entry's watermark is the zero -// Location and the reply MUST NOT carry a LARGEST_OBJECT parameter — -// emitting one would claim the publisher delivered Location {0, 0}, -// which §10.2.17 explicitly reserves for "no objects observed": -// -// "If omitted from a message, the sending endpoint has not published -// or received any Objects in the Track." -func TestTrackStatus_OmitsLargestObjectBeforeAnyObjects(t *testing.T) { - t.Parallel() - - clientSess, teardown := connectRelay(t, relay.Config{}) - defer teardown() - - pubStream, err := clientSess.Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("video")}, - Name: []byte("cam1"), - TrackAlias: 1, - TrackProperties: []byte("rtp-h265"), - }) - if err != nil { - t.Fatalf("Publish: %v", err) - } - defer pubStream.Close() - - querySess := dialAnotherClient(t, clientSess) - tsStream, err := querySess.TrackStatus(t.Context(), &message.TrackStatus{ - Namespace: wire.TrackNamespace{[]byte("video")}, - Name: []byte("cam1"), - }) - if err != nil { - t.Fatalf("TrackStatus: %v", err) - } - defer tsStream.Close() - - if _, found := tsStream.OK.Parameters.Find(message.ParamLargestObject); found { - t.Fatal("TRACK_STATUS_OK unexpectedly carried LARGEST_OBJECT before any objects were forwarded") - } - if string(tsStream.OK.TrackProperties) != "rtp-h265" { - t.Fatalf("TrackProperties = %q, want %q", tsStream.OK.TrackProperties, "rtp-h265") - } -} - -// TestFetch_CacheEvictionUnderLoad is the integration test paired with -// the cache eviction story: under a publish flood that exceeds MaxCacheSize -// the relay's per-track cache must evict older entries, and a FETCH -// over the early range comes back with strictly fewer objects than -// the range implies. Per §10.13 the subscriber interprets gaps as -// "objects do not exist". -// -// The FIFO ring evicts strictly oldest-first, but the test asserts only -// the boundary (Len ≤ cap on the recent-tail FETCH, fewer-than-cap on -// the early-range FETCH) rather than specific IDs. -func TestFetch_CacheEvictionUnderLoad(t *testing.T) { - t.Parallel() - - const cacheCap = 16 - - pubSess, teardown := connectRelay(t, relay.Config{ - MaxCacheSize: cacheCap, - }) - defer teardown() - - const publisherAlias = uint64(7) - pubReq, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: wire.TrackNamespace{[]byte("video")}, - Name: []byte("cam1"), - TrackAlias: publisherAlias, - }) - if err != nil { - t.Fatalf("Publish: %v", err) - } - defer pubReq.Close() - - // A subscriber must exist for the fanout to commit objects to the - // per-track cache (the drainInbound path discards otherwise). - subSess := dialAnotherClient(t, pubSess) - subReq, err := subSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: wire.TrackNamespace{[]byte("video")}, - Name: []byte("cam1"), - }) - if err != nil { - t.Fatalf("Subscribe: %v", err) - } - defer subReq.Close() - go drainAllStreams(t.Context(), subSess) - - // Publish 4× cacheCap objects on a single subgroup so size-based - // eviction is forced. - const totalObjects = cacheCap * 4 - publishObjects(t, pubSess, publisherAlias, 0 /*group*/, totalObjects) - - // Give the relay a beat to drain the publish flood into the cache - // before the read side asserts. - time.Sleep(200 * time.Millisecond) - - // FETCH the recent tail — those objects should still be present. - fetchSess := dialAnotherClient(t, pubSess) - _, recent := fetchAndDrain(t, - fetchSess, - wire.TrackNamespace{[]byte("video")}, - []byte("cam1"), - message.Location{Group: 0, Object: uint64(totalObjects - cacheCap)}, - message.Location{Group: 0, Object: uint64(totalObjects - 1)}, - message.GroupOrderAscending, - ) - if len(recent) == 0 { - t.Fatal("recent-tail FETCH returned 0 objects; expected eviction to retain recently-published entries") - } - if len(recent) > cacheCap { - t.Fatalf("recent-tail FETCH returned %d objects, want <= cacheCap (%d)", len(recent), cacheCap) - } - for _, o := range recent { - if o.object < uint64(totalObjects-cacheCap) { - t.Fatalf("recent-tail FETCH returned object %d, below the tail boundary (%d)", - o.object, totalObjects-cacheCap) - } - } - - // FETCH the oldest range — these objects should mostly be evicted. - // The FIFO ring keeps exactly the newest cacheCap entries, but the - // test only asserts the boundary: fewer objects than the range - // itself. - fetchSess2 := dialAnotherClient(t, pubSess) - _, oldest := fetchAndDrain(t, - fetchSess2, - wire.TrackNamespace{[]byte("video")}, - []byte("cam1"), - message.Location{Group: 0, Object: 0}, - message.Location{Group: 0, Object: uint64(cacheCap - 1)}, - message.GroupOrderAscending, - ) - if len(oldest) >= cacheCap { - t.Fatalf("oldest-range FETCH returned %d objects; expected eviction to have dropped some (want < %d)", - len(oldest), cacheCap) - } -} diff --git a/pkg/relay/track_status_test.go b/pkg/relay/track_status_test.go new file mode 100644 index 00000000..09adb58d --- /dev/null +++ b/pkg/relay/track_status_test.go @@ -0,0 +1,103 @@ +package relay_test + +import ( + "testing" + "time" + + "github.com/floatdrop/moq-go/pkg/moqt/message" + "github.com/floatdrop/moq-go/pkg/relay" +) + +// TestTrackStatus_ReturnsLargestObject: TRACK_STATUS_OK carries the track's +// LARGEST_OBJECT (§10.2.17) once Objects were forwarded. +func TestTrackStatus_ReturnsLargestObject(t *testing.T) { + t.Parallel() + + pubSess, _, publisherAlias := publishAndCache(t) + publishObjects(t, pubSess, publisherAlias, 4 /*group*/, 3 /*count*/) + + time.Sleep(50 * time.Millisecond) // let the watermark reach {4, 2} + + querySess := dialAnotherClient(t, pubSess) + tsStream, err := querySess.TrackStatus(t.Context(), &message.TrackStatus{ + Namespace: ns("video"), + Name: []byte("cam1"), + }) + if err != nil { + t.Fatalf("TrackStatus: %v", err) + } + defer tsStream.Close() + + p, found := tsStream.OK.Parameters.Find(message.ParamLargestObject) + if !found { + t.Fatal("TRACK_STATUS_OK missing LARGEST_OBJECT parameter") + } + if p.Group != 4 || p.Object != 2 { + t.Fatalf("LARGEST_OBJECT = {%d, %d}, want {4, 2}", p.Group, p.Object) + } +} + +// TestTrackStatus_OmitsLargestObjectBeforeAnyObjects: a known track with no +// Objects yet gets no LARGEST_OBJECT (§10.2.17), not {0, 0}. +func TestTrackStatus_OmitsLargestObjectBeforeAnyObjects(t *testing.T) { + t.Parallel() + + clientSess, teardown := connectRelay(t, relay.Config{}) + defer teardown() + + pubStream, err := clientSess.Publish(t.Context(), &message.Publish{ + Namespace: ns("video"), + Name: []byte("cam1"), + TrackAlias: 1, + TrackProperties: []byte("rtp-h265"), + }) + if err != nil { + t.Fatalf("Publish: %v", err) + } + defer pubStream.Close() + + querySess := dialAnotherClient(t, clientSess) + tsStream, err := querySess.TrackStatus(t.Context(), &message.TrackStatus{ + Namespace: ns("video"), + Name: []byte("cam1"), + }) + if err != nil { + t.Fatalf("TrackStatus: %v", err) + } + defer tsStream.Close() + + if _, found := tsStream.OK.Parameters.Find(message.ParamLargestObject); found { + t.Fatal("TRACK_STATUS_OK unexpectedly carried LARGEST_OBJECT before any objects were forwarded") + } + if string(tsStream.OK.TrackProperties) != "rtp-h265" { + t.Fatalf("TrackProperties = %q, want %q", tsStream.OK.TrackProperties, "rtp-h265") + } +} + +// TestRelay_TrackStatusRequestUpdateClosesSession: a REQUEST_UPDATE on a +// TRACK_STATUS stream closes the session (§10.15, §10.9). +func TestRelay_TrackStatusRequestUpdateClosesSession(t *testing.T) { + t.Parallel() + l := newPipeListener() + pubSess, teardown := connectRelayOn(t, relay.Config{}, l) + defer teardown() + video := ns("video") + if _, err := pubSess.PublishNamespace(t.Context(), &message.PublishNamespace{Namespace: video}); err != nil { + t.Fatalf("PublishNamespace: %v", err) + } + + peer, conn := dialRaw(t, l) + stream, err := conn.OpenStream() + if err != nil { + t.Fatalf("OpenStream: %v", err) + } + go func() { + _ = message.Marshal(stream, &message.TrackStatus{RequestID: 0, Namespace: video, Name: []byte("cam1")}) + if _, err := message.Parse(stream); err != nil { // TRACK_STATUS_OK + return + } + _ = message.Marshal(stream, &message.RequestUpdate{RequestID: 2}) + }() + + requireSessionClosed(t, peer, "a REQUEST_UPDATE on a TRACK_STATUS stream") +} diff --git a/pkg/relay/track_status_update_test.go b/pkg/relay/track_status_update_test.go deleted file mode 100644 index 1e4e9fb4..00000000 --- a/pkg/relay/track_status_update_test.go +++ /dev/null @@ -1,44 +0,0 @@ -package relay_test - -import ( - "testing" - "time" - - "github.com/floatdrop/moq-go/pkg/moqt/message" - "github.com/floatdrop/moq-go/pkg/moqt/wire" - "github.com/floatdrop/moq-go/pkg/relay" -) - -// TestRelay_TrackStatusRequestUpdateClosesSession: TRACK_STATUS cannot be -// updated (§10.15), and "An endpoint that receives a REQUEST_UPDATE other than -// in the two cases above MUST close the session with a PROTOCOL_VIOLATION" -// (§10.9). The relay used to FIN its side and never read the requester's. -func TestRelay_TrackStatusRequestUpdateClosesSession(t *testing.T) { - t.Parallel() - l := newPipeListener() - pubSess, teardown := connectRelayOn(t, relay.Config{}, l) - defer teardown() - ns := wire.TrackNamespace{[]byte("video")} - if _, err := pubSess.PublishNamespace(t.Context(), &message.PublishNamespace{Namespace: ns}); err != nil { - t.Fatalf("PublishNamespace: %v", err) - } - - peer, conn := dialRaw(t, l) - stream, err := conn.OpenStream() - if err != nil { - t.Fatalf("OpenStream: %v", err) - } - go func() { - _ = message.Marshal(stream, &message.TrackStatus{RequestID: 0, Namespace: ns, Name: []byte("cam1")}) - if _, err := message.Parse(stream); err != nil { // TRACK_STATUS_OK - return - } - _ = message.Marshal(stream, &message.RequestUpdate{RequestID: 2}) - }() - - select { - case <-peer.Done(): - case <-time.After(2 * time.Second): - t.Fatal("relay left the session open after a REQUEST_UPDATE on a TRACK_STATUS stream") - } -} diff --git a/pkg/relay/update_ok_largest_test.go b/pkg/relay/update_ok_largest_test.go deleted file mode 100644 index ebd0fce3..00000000 --- a/pkg/relay/update_ok_largest_test.go +++ /dev/null @@ -1,49 +0,0 @@ -package relay_test - -import ( - "testing" - "time" - - "github.com/floatdrop/moq-go/pkg/moqt/message" - "github.com/floatdrop/moq-go/pkg/relay" -) - -// TestRequestUpdateOK_CarriesLargestObject pins §10.2.17 on the relay's -// REQUEST_UPDATE_OK to a downstream subscriber: LARGEST_OBJECT "MAY appear in -// [...] REQUEST_UPDATE_OK [...]. If Objects have been published on this Track -// the Publisher MUST include this parameter." §10.9.1 relies on it: after an -// update widens the range, the subscriber FETCHes the gap up to it. With no -// Object yet it is omitted ("the sending endpoint has not published or -// received any Objects"). -func TestRequestUpdateOK_CarriesLargestObject(t *testing.T) { - t.Parallel() - pubSess, teardown := connectRelay(t, relay.Config{}) - defer teardown() - pub := publishVideoTrack(t, pubSess, "cam1", 1) - subSess := dialAnotherClient(t, pubSess) - subReq := subscribeCam1Req(t, subSess) - - ok, err := subSess.UpdateRequest(t.Context(), subReq, message.Parameters{message.SubscriberPriorityParam(7)}) - if err != nil { - t.Fatalf("UpdateRequest before any Object: %v", err) - } - if p, has := ok.Parameters.Find(message.ParamLargestObject); has { - t.Fatalf("REQUEST_UPDATE_OK carried LARGEST_OBJECT {%d,%d} before any Object was published", p.Group, p.Object) - } - - publishSubgroupWith(t, pub, 3, 1, nil) - if !awaitSubgroupObject(t, subSess, 2*time.Second) { - t.Fatal("the Object never reached the subscriber") - } - ok, err = subSess.UpdateRequest(t.Context(), subReq, message.Parameters{message.SubscriberPriorityParam(9)}) - if err != nil { - t.Fatalf("UpdateRequest after an Object: %v", err) - } - p, has := ok.Parameters.Find(message.ParamLargestObject) - if !has { - t.Fatal("REQUEST_UPDATE_OK omitted LARGEST_OBJECT after Object {3,0} was published") - } - if p.Group != 3 || p.Object != 0 { - t.Fatalf("LARGEST_OBJECT = {%d,%d}, want {3,0}", p.Group, p.Object) - } -} From d99dc81f23ce6bbd1a4a865b6f1c073811580000 Mon Sep 17 00:00:00 2001 From: Vsevolod Strukchinsky Date: Sat, 26 Sep 2026 10:28:45 +0500 Subject: [PATCH 11/12] test(relay): trim test comments; table-drive auth, token and cache tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test doc comments shrink to a line or two of purpose plus the § citation; history narratives and long spec quotations go. Citations the review flagged are corrected: multi-publisher dedup is §9.3, UPDATE_FAILED is §10.9.1, and §9.5/§10.7 are no longer cited for claims they do not make. Near-copies become tables: - TestRelay_AuthDenialUsesPolicyCode replaces the six per-request *_AuthDenialUsesPolicyCode tests (SUBSCRIBE, FETCH, TRACK_STATUS, PUBLISH_NAMESPACE, SUBSCRIBE_NAMESPACE, SUBSCRIBE_TRACKS); - TestSessionHandler_TokenVerification replaces TokenDenialMapsToRequestError and TokenAllowReachesHandler (token_verify_test.go is folded into session_handler_test.go). Each keeps its assertions as a subtest. FETCH helpers move to helpers_fetch_test.go to keep helpers_test.go small; every helper has a doc comment; locsEqual, the discovery import sentinel and a hand-rolled clone/search give way to slices. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/relay/cache_test.go | 10 +- pkg/relay/cachettl_tracknamettl_test.go | 1 + pkg/relay/cross_relay_test.go | 158 ++------ pkg/relay/default_priority_test.go | 5 + pkg/relay/discovery_integration_test.go | 25 +- pkg/relay/handler_datagram_test.go | 31 +- pkg/relay/handler_fanout_firstobject_test.go | 36 +- pkg/relay/handler_fanout_join_test.go | 12 +- pkg/relay/handler_fanout_lag_test.go | 17 +- pkg/relay/handler_fanout_multipub_test.go | 41 +- pkg/relay/handler_fanout_terminal_test.go | 8 +- pkg/relay/handler_fanout_test.go | 92 ++--- pkg/relay/handler_fanout_timeout_test.go | 58 +-- pkg/relay/handler_fetch_elem_test.go | 22 +- pkg/relay/handler_fetch_merge_test.go | 50 +-- pkg/relay/handler_fetch_session_test.go | 45 +-- pkg/relay/handler_fetch_split_test.go | 10 +- pkg/relay/handler_fetch_stitch_test.go | 10 +- pkg/relay/handler_fetch_test.go | 165 +++----- pkg/relay/handler_fetch_unknown_test.go | 54 +-- pkg/relay/handler_fetch_upstream_fail_test.go | 89 +---- pkg/relay/handler_namespace_fault_test.go | 30 +- .../handler_publish_alias_window_test.go | 36 +- pkg/relay/handler_publish_fault_test.go | 23 +- .../handler_subscribe_alias_window_test.go | 37 +- pkg/relay/harness_test.go | 4 +- pkg/relay/helpers_fetch_test.go | 378 ++++++++++++++++++ pkg/relay/helpers_test.go | 369 +---------------- pkg/relay/inbound_goaway_test.go | 14 +- pkg/relay/integration_test.go | 66 +-- pkg/relay/late_publisher_test.go | 57 ++- pkg/relay/limit_integration_test.go | 14 +- pkg/relay/malformed_track_test.go | 12 +- pkg/relay/merge_tracks_update_test.go | 7 +- pkg/relay/metrics_test.go | 23 +- pkg/relay/namespace_state_test.go | 55 ++- pkg/relay/newgroup_test.go | 20 +- pkg/relay/priority_test.go | 25 +- pkg/relay/publish_done_test.go | 2 +- pkg/relay/publish_skipped_test.go | 21 +- pkg/relay/rangefilter_relay_test.go | 35 +- pkg/relay/relay_test.go | 53 +-- pkg/relay/relay_upstream_test.go | 31 +- pkg/relay/session_cleanup_test.go | 23 +- pkg/relay/session_handler_fault_test.go | 24 +- pkg/relay/session_handler_test.go | 150 +++++-- pkg/relay/session_namespace_test.go | 72 +--- pkg/relay/session_pubsub_test.go | 176 +++----- pkg/relay/session_update_test.go | 54 +-- pkg/relay/shared_alias_test.go | 9 +- pkg/relay/shutdown_straggler_test.go | 11 +- pkg/relay/subscribe_tracks_test.go | 10 + pkg/relay/token_verify_test.go | 71 ---- 53 files changed, 1031 insertions(+), 1820 deletions(-) create mode 100644 pkg/relay/helpers_fetch_test.go delete mode 100644 pkg/relay/token_verify_test.go diff --git a/pkg/relay/cache_test.go b/pkg/relay/cache_test.go index 7beb832c..03ddf918 100644 --- a/pkg/relay/cache_test.go +++ b/pkg/relay/cache_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "io" + "slices" "testing" "time" @@ -117,12 +118,11 @@ func fetchCam1(t *testing.T, sess *session.Session, lastGroup uint64) []fetchEle // findElem returns the element at {group, 0}. func findElem(elems []fetchElem, group uint64) (fetchElem, bool) { - for _, e := range elems { - if e.Group == group && e.Object == 0 { - return e, true - } + i := slices.IndexFunc(elems, func(e fetchElem) bool { return e.Group == group && e.Object == 0 }) + if i < 0 { + return fetchElem{}, false } - return fetchElem{}, false + return elems[i], true } // TestRelay_MaxCacheDurationNotServedFromCache: a FETCH is not served an Object diff --git a/pkg/relay/cachettl_tracknamettl_test.go b/pkg/relay/cachettl_tracknamettl_test.go index 22095e52..ade77cb8 100644 --- a/pkg/relay/cachettl_tracknamettl_test.go +++ b/pkg/relay/cachettl_tracknamettl_test.go @@ -7,6 +7,7 @@ import ( "github.com/floatdrop/moq-go/pkg/moqt/track" ) +// name is a FullTrackName with an empty namespace. func name(n string) track.FullTrackName { return track.FullTrackName{Name: []byte(n)} } diff --git a/pkg/relay/cross_relay_test.go b/pkg/relay/cross_relay_test.go index 84fd0e18..bfb3e13b 100644 --- a/pkg/relay/cross_relay_test.go +++ b/pkg/relay/cross_relay_test.go @@ -31,6 +31,8 @@ type testRelay struct { startErr chan error } +// startTestRelay starts a relay on its own pipeListener; its Stop is the +// caller's. func startTestRelay(ctx context.Context, cfg relay.Config) *testRelay { if cfg.GoawayTimeout == 0 { cfg.GoawayTimeout = 50 * time.Millisecond @@ -71,11 +73,8 @@ func dialClient(t *testing.T, tr *testRelay) *session.Session { return sess } -// TestCrossRelay_OnDemandSubscribe is the end-to-end happy path: a subscriber -// on relay A receives objects published to relay B, routed across the boundary -// purely through Discovery + the Dialer. B advertises the "video" namespace; -// A has no local publisher, follows FindNamespace to B, dials it, and -// subscribes upstream. Objects flow publisher → B → A → subscriber. +// TestCrossRelay_OnDemandSubscribe: a subscriber on relay A receives Objects +// published to relay B, which A finds through Discovery and dials. func TestCrossRelay_OnDemandSubscribe(t *testing.T) { t.Parallel() @@ -197,11 +196,9 @@ func TestCrossRelay_OnDemandSubscribe(t *testing.T) { relayB.stop(t) } -// TestCrossRelay_LocalPublisherFailureFallsBackToDiscovery pins that a local -// publisher whose upstream SUBSCRIBE fails does not abort the search: the relay -// still falls back to a remote relay via Discovery. Without that, a transiently -// failing local publisher would reject the downstream SUBSCRIBE even though a -// healthy remote serves the track. +// TestCrossRelay_LocalPublisherFailureFallsBackToDiscovery: when the local +// publisher's upstream SUBSCRIBE fails, the relay still falls back to a remote +// relay found through Discovery. func TestCrossRelay_LocalPublisherFailureFallsBackToDiscovery(t *testing.T) { t.Parallel() @@ -321,12 +318,9 @@ func TestCrossRelay_LocalPublisherFailureFallsBackToDiscovery(t *testing.T) { <-rejectDone } -// TestCrossRelay_MultiRemoteFanIn pins §9.5 cross-relay fault tolerance: when -// two remote relays both advertise a namespace, relay A subscribes to BOTH (not -// just the first) and fans them into one track. The Dialer must fire for each -// remote, and the subscriber must receive each object exactly once even though -// both remotes push the same {GroupID, ObjectID} stream (the §2.1 dedup gate -// drops the redundant copy). +// TestCrossRelay_MultiRemoteFanIn: with two remote relays advertising a +// namespace, relay A subscribes to both (§9.5) and delivers each Object once (§9.3, +// §2.1). func TestCrossRelay_MultiRemoteFanIn(t *testing.T) { t.Parallel() @@ -449,11 +443,8 @@ collect: relayC.stop(t) } -// TestCrossRelay_SelfExclusion pins the loop guard: a FindNamespace result that -// names this relay's own RelayAddr must never trigger a dial or a self-loop -// SUBSCRIBE. The store is seeded with a namespace owned by "relay-A" itself; -// A's subscriber must be rejected (no other relay serves it) and the Dialer -// must never fire. +// TestCrossRelay_SelfExclusion: a Discovery entry naming this relay's own +// RelayAddr is never dialled; the subscriber is refused. func TestCrossRelay_SelfExclusion(t *testing.T) { t.Parallel() @@ -625,18 +616,8 @@ func TestCrossRelay_WatchNamespacesForward(t *testing.T) { relayA.stop(t) } -// TestCrossRelay_WatchNamespacesForwardsUnpublish is the withdrawal half of -// TestCrossRelay_WatchNamespacesForward: when a remote relay retracts a -// namespace, the local SUBSCRIBE_NAMESPACE holder learns of it via -// NAMESPACE_DONE. -// -// Until this existed the OpUnpublish arm of forwardNamespaceEvent was taken by -// no test at all. OpUnpublish itself is asserted on all over the suite, but -// every one of those stops at the store boundary — memory_test.go and -// discovery_integration_test.go check the event comes *out* of -// WatchNamespaces, not that the relay reflects it onward. So the arm could -// be deleted with the suite still green, while a downstream subscriber -// silently never learned the namespace had gone away. +// TestCrossRelay_WatchNamespacesForwardsUnpublish: a remote relay's withdrawn +// namespace reaches the local SUBSCRIBE_NAMESPACE holder as NAMESPACE_DONE. func TestCrossRelay_WatchNamespacesForwardsUnpublish(t *testing.T) { t.Parallel() @@ -720,17 +701,9 @@ func TestCrossRelay_WatchNamespacesForwardsUnpublish(t *testing.T) { relayA.stop(t) } -// TestCrossRelay_WatchNamespacesSkipsTrackSubscribers pins the WantsTracks skip -// in forwardNamespaceEvent: a SUBSCRIBE_TRACKS holder must NOT be sent the -// NAMESPACE reflected from a remote relay's advertisement. Those holders -// receive forwarded PUBLISH messages (§6.1 / §10.20), and a relay cannot -// synthesize a remote PUBLISH from a namespace advertisement alone. -// -// The SUBSCRIBE_NAMESPACE holder here is load-bearing, not decoration. A bare -// "nothing arrived on the SUBSCRIBE_TRACKS stream" assertion would pass just as -// well if the watch never fired, if the prefix never matched, or if the relay -// never started — the control subscriber is what distinguishes "delivered, then -// deliberately skipped" from "never delivered to anyone". +// TestCrossRelay_WatchNamespacesSkipsTrackSubscribers: a remote relay's +// namespace is not sent to a SUBSCRIBE_TRACKS holder (§6.1, §10.20). The +// SUBSCRIBE_NAMESPACE holder is the control that shows it was delivered at all. func TestCrossRelay_WatchNamespacesSkipsTrackSubscribers(t *testing.T) { t.Parallel() @@ -789,13 +762,8 @@ func TestCrossRelay_WatchNamespacesSkipsTrackSubscribers(t *testing.T) { // The event was delivered and the ticker keeps re-delivering it, so anything // on the SUBSCRIBE_TRACKS stream now is the skip having been dropped. // - // A Parse *error* fails just as loudly as a message. A stream that died is - // not a stream that was correctly skipped, and treating the two alike is how - // this assertion would stay green if the relay started resetting the - // SUBSCRIBE_TRACKS stream on a namespace event, or if SubscribeTracks - // stopped establishing it at all — the regressions it exists to catch. The - // reader is left blocked in Parse on the way out; it unblocks when the test - // closes the stream, and the buffered channel keeps it from leaking. + // A Parse error fails as loudly as a message: a dead stream was not + // correctly skipped. The reader unblocks when the test closes the stream. type parsed struct { msg message.Message err error @@ -821,12 +789,8 @@ func TestCrossRelay_WatchNamespacesSkipsTrackSubscribers(t *testing.T) { relayA.stop(t) } -// TestCrossRelay_SubscribeNamespaceSeedsRemote pins the seed side of -// cross-relay namespace discovery: a SUBSCRIBE_NAMESPACE holder is told about a -// namespace a *remote* relay advertised BEFORE the subscriber (and before this -// relay) existed. The watch's initial snapshot records it in the namespace -// registry, which seeds the subscriber, so one pre-advertise suffices — no -// re-advertise ticker needed. +// TestCrossRelay_SubscribeNamespaceSeedsRemote: a SUBSCRIBE_NAMESPACE holder +// learns of a namespace a remote relay advertised before either existed. func TestCrossRelay_SubscribeNamespaceSeedsRemote(t *testing.T) { t.Parallel() @@ -867,12 +831,9 @@ func TestCrossRelay_SubscribeNamespaceSeedsRemote(t *testing.T) { relayA.stop(t) } -// TestCrossRelay_ConcurrentSubscriberWrites drives two independent writers at -// one SUBSCRIBE_NAMESPACE holder's stream: a local publisher's PUBLISH_NAMESPACE -// forwards (on a session-handler goroutine) and the relay-level WatchNamespaces -// consumer forwarding remote advertisements. The two write the same stream from -// different goroutines, so this must stay clean under -race (it is the race -// SubscriberEntry.WriteMessage's mutex closes). +// TestCrossRelay_ConcurrentSubscriberWrites: a local PUBLISH_NAMESPACE and a +// remote advertisement write one SUBSCRIBE_NAMESPACE stream from two +// goroutines; run under -race. func TestCrossRelay_ConcurrentSubscriberWrites(t *testing.T) { t.Parallel() @@ -941,10 +902,8 @@ func TestCrossRelay_ConcurrentSubscriberWrites(t *testing.T) { <-drained } -// TestCrossRelay_DialerWithoutRelayAddrWarns pins the misconfiguration -// diagnostic for #4: a Dialer set with an empty RelayAddr disables cross-relay -// routing silently (self/remote Discovery entries become indistinguishable), so -// New must emit a warning. A RelayAddr-set relay must NOT warn. +// TestCrossRelay_DialerWithoutRelayAddrWarns: New warns when a Dialer is set +// without a RelayAddr, and not when one is set. func TestCrossRelay_DialerWithoutRelayAddrWarns(t *testing.T) { t.Parallel() @@ -1013,11 +972,9 @@ func TestCrossRelay_NoDialerNoop(t *testing.T) { relayA.stop(t) } -// TestCrossRelay_UpstreamFanInCapConverges pins Phase-1 affinity routing: when -// three remote relays advertise a namespace but UpstreamFanIn is 1, a leaf relay -// subscribes to exactly one of them (the cap), and two independent leaf relays -// pick the *same* one (rendezvous convergence). That is what turns a full -// O(n²) relay-to-relay mesh into a tree rooted at one relay per namespace. +// TestCrossRelay_UpstreamFanInCapConverges: with UpstreamFanIn 1 and three +// remotes, a leaf relay subscribes to exactly one, and two leaves pick the same +// one. func TestCrossRelay_UpstreamFanInCapConverges(t *testing.T) { t.Parallel() @@ -1131,27 +1088,9 @@ func TestCrossRelay_UpstreamFanInCapConverges(t *testing.T) { relayD.stop(t) } -// TestCrossRelay_GoawayPrecedesUpstreamTeardown pins the §3.6 shutdown ordering: -// "When the server is a subscriber, it SHOULD send a GOAWAY message to -// downstream subscribers prior to unsubscribing from upstream publishers." -// -// The relay is a subscriber on every session its upstream pool dialled, so -// cancelling the pool is the "unsubscribe from upstream" step and must not run -// before the GOAWAY broadcast. It used to run first, at the same point as the -// listener close, which raced the upstream session out of Stop's snapshot — so -// the upstream peer could be dropped having never been told the relay was going -// away. -// -// The test owns the upstream peer's session directly (the Dialer hands the relay -// one end of a pipe and the test serves the other), so it can observe what that -// peer receives. -// -// Detection profile, measured against the old ordering: 5/5 failures at -// GOMAXPROCS=1, 0/5 on a multi-core run. Cancelling the pool does not itself -// close the session — it unwinds the handler, and only if that wins the race to -// deregister does the session miss Stop's snapshot and lose its GOAWAY. So this -// is a strict regression guard single-threaded and a correctness assertion -// everywhere else. +// TestCrossRelay_GoawayPrecedesUpstreamTeardown: on Stop the relay sends GOAWAY +// before it unsubscribes from upstream publishers (§3.6). Reliably fails a +// wrong ordering only at GOMAXPROCS=1. func TestCrossRelay_GoawayPrecedesUpstreamTeardown(t *testing.T) { t.Parallel() @@ -1240,23 +1179,9 @@ func TestCrossRelay_GoawayPrecedesUpstreamTeardown(t *testing.T) { } } -// TestCrossRelay_FetchBackfillsPublishOnceTrack pins §10.2.17, which §9.4 makes -// binding on relays: the LARGEST_OBJECT a relay reports includes any value its -// upstream sent in SUBSCRIBE_OK, because its own downstream subscribers need it -// to FETCH what was published before they arrived. -// -// The track here is published *once* and then goes quiet, which is what makes -// the omission fatal rather than merely late. A live subscription carries only -// future objects, so the sole route to content published before the subscriber -// arrived is a §10.13 FETCH — and that is refused with INVALID_RANGE -// when the relay knows no Largest Object to compute a range from. Before the -// fix, relay A learned no watermark from B's SUBSCRIBE_OK, omitted -// LARGEST_OBJECT from its own SUBSCRIBE_OK (violating §10.2.17), and rejected -// the backfill; the subscriber never saw the track's contents at all. -// -// An MSF catalog is exactly this shape — published on join, republished only -// when a participant's tracks change — so in a conference across two relays the -// participant behind that catalog stayed invisible for the whole call. +// TestCrossRelay_FetchBackfillsPublishOnceTrack: relay A's LARGEST_OBJECT +// includes the one its upstream sent in SUBSCRIBE_OK (§10.2.17, §9.4), so a +// track published once, like an MSF catalog, can be FETCHed from A later. func TestCrossRelay_FetchBackfillsPublishOnceTrack(t *testing.T) { t.Parallel() @@ -1344,9 +1269,8 @@ func TestCrossRelay_FetchBackfillsPublishOnceTrack(t *testing.T) { // it arrived. A's own cache is empty — its upstream uses the §9.4 Next Object // filter — so answering means stitching from B (§9.4). // - // StartGroup=1 is the relative one-field form (§5.1.2): start at the current - // group. On a Fetch an omitted end means Largest Object, so this is the range - // draft-19 expressed as a Relative Joining FETCH with JoiningStart=0. + // StartGroup=1 is the relative one-field form (§5.1.2): the current group + // up to Largest Object. fetchReq, err := subSess.Fetch(ctx, &message.Fetch{ Namespace: subMsg.Namespace, Name: subMsg.Name, @@ -1409,12 +1333,8 @@ func TestCrossRelay_FetchBackfillsPublishOnceTrack(t *testing.T) { relayB.stop(t) } -// TestCrossRelay_PublishDoneCodeCrossesRelays: §10.12 "The application SHOULD -// use a relevant status code in PUBLISH_DONE". A code about the track reaches -// a subscriber two relays away: the origin relay passes the publisher's code -// to its downstream, which here is relay A's own upstream SUBSCRIBE, and A -// passes it on in turn rather than reporting its upstream's end as -// TRACK_ENDED. +// TestCrossRelay_PublishDoneCodeCrossesRelays: a PUBLISH_DONE code about the +// track reaches a subscriber two relays away unchanged (§10.12). func TestCrossRelay_PublishDoneCodeCrossesRelays(t *testing.T) { t.Parallel() store := discovery.NewMemoryStore() diff --git a/pkg/relay/default_priority_test.go b/pkg/relay/default_priority_test.go index 32a1c644..6c1a3fbc 100644 --- a/pkg/relay/default_priority_test.go +++ b/pkg/relay/default_priority_test.go @@ -64,10 +64,13 @@ func tryFetchedPriority( return obj.PublisherPriority, true } +// defaultPriorityProp sets DEFAULT_PUBLISHER_PRIORITY to v. func defaultPriorityProp(v uint64) []wire.KVPair { return trackProp(message.PropertyDefaultPublisherPriority, v) } +// TestDefaultPriority_Subgroup: a cached DEFAULT_PRIORITY subgroup Object is +// served with the inherited priority; an inline one keeps its own. func TestDefaultPriority_Subgroup(t *testing.T) { t.Parallel() cases := []struct { @@ -103,6 +106,8 @@ func TestDefaultPriority_Subgroup(t *testing.T) { } } +// TestDefaultPriority_Datagram: a cached DEFAULT_PRIORITY datagram is served +// with the track's DEFAULT_PUBLISHER_PRIORITY (§11.3.1). func TestDefaultPriority_Datagram(t *testing.T) { t.Parallel() pubSess, alias := newCam1Publisher(t, defaultPriorityProp(200)) diff --git a/pkg/relay/discovery_integration_test.go b/pkg/relay/discovery_integration_test.go index 0f6a8994..270e5908 100644 --- a/pkg/relay/discovery_integration_test.go +++ b/pkg/relay/discovery_integration_test.go @@ -2,8 +2,6 @@ package relay_test import ( "context" - "net" - "sync" "testing" "time" @@ -61,10 +59,8 @@ func TestDiscovery_PublishOnFirstUpstream(t *testing.T) { } } -// TestDiscovery_UnpublishOnLastUpstream pins the complementary half: -// when the only upstream goes away, Discovery sees an Unpublish event. -// We exercise the path via the publisher closing its session, which -// triggers per-session bulk cleanup in the TrackRegistry. +// TestDiscovery_UnpublishOnLastUpstream: when the only upstream's session +// closes, Discovery sees Unpublish. func TestDiscovery_UnpublishOnLastUpstream(t *testing.T) { t.Parallel() @@ -111,11 +107,8 @@ func TestDiscovery_UnpublishOnLastUpstream(t *testing.T) { } } -// TestDiscovery_PublishNamespaceOnFirstAdvertise pins the parallel -// namespace path: PUBLISH_NAMESPACE triggers -// Discovery.PublishNamespace; a second PUBLISH_NAMESPACE for the same -// namespace from the same relay does NOT generate a second event -// (ref-counting collapses duplicate advertisements). +// TestDiscovery_PublishNamespaceOnFirstAdvertise: the first PUBLISH_NAMESPACE +// reaches Discovery; a second for the same namespace does not. func TestDiscovery_PublishNamespaceOnFirstAdvertise(t *testing.T) { t.Parallel() @@ -250,11 +243,8 @@ func TestDiscovery_NilDiscoveryIsNoop(t *testing.T) { } } -// Sentinel: ensure the package-level helpers we use are still -// referenced and don't dead-code-eliminate. -var _ = net.IPv4 -var _ = sync.WaitGroup{} - +// receiveTrackEvent returns the next event from ch, or false after d or once ch +// closes. func receiveTrackEvent(ch <-chan discovery.TrackEvent, d time.Duration) (discovery.TrackEvent, bool) { select { case ev, ok := <-ch: @@ -264,6 +254,7 @@ func receiveTrackEvent(ch <-chan discovery.TrackEvent, d time.Duration) (discove } } +// receiveNamespaceEvent is [receiveTrackEvent] for namespace events. func receiveNamespaceEvent(ch <-chan discovery.NamespaceEvent, d time.Duration) (discovery.NamespaceEvent, bool) { select { case ev, ok := <-ch: @@ -287,6 +278,8 @@ func skipTrackSnapshot(t *testing.T, ch <-chan discovery.TrackEvent) { } } +// skipNamespaceSnapshot reads a namespace watch's snapshot up to its +// OpSnapshotDone. func skipNamespaceSnapshot(t *testing.T, ch <-chan discovery.NamespaceEvent) { t.Helper() for { diff --git a/pkg/relay/handler_datagram_test.go b/pkg/relay/handler_datagram_test.go index a361416a..b7977e19 100644 --- a/pkg/relay/handler_datagram_test.go +++ b/pkg/relay/handler_datagram_test.go @@ -11,10 +11,8 @@ import ( "github.com/floatdrop/moq-go/pkg/relay" ) -// TestDatagram_PublisherToSubscriberSingleDatagram is the canonical -// E2E test: publisher sends one OBJECT_DATAGRAM, relay forwards it to a -// subscriber on a separate session with the Track Alias remapped to the -// subscriber's per-session outbound alias. +// TestDatagram_PublisherToSubscriberSingleDatagram: one datagram reaches a +// subscriber under its per-session outbound Track Alias. func TestDatagram_PublisherToSubscriberSingleDatagram(t *testing.T) { t.Parallel() @@ -83,11 +81,8 @@ func TestDatagram_PublisherToSubscriberSingleDatagram(t *testing.T) { } } -// TestDatagram_FilterDropsBelowStart pins the §5.1.2 filter behaviour on -// the datagram path: a subscriber with AbsoluteStart {Group: 0, Object: 2} -// only sees datagrams whose Location is >= {0, 2}. The relay does not -// re-encode anything on a datagram (each is self-contained), so this is a -// straight gate check. +// TestDatagram_FilterDropsBelowStart: an AbsoluteStart {0, 2} filter drops +// earlier datagrams (§5.1.2). func TestDatagram_FilterDropsBelowStart(t *testing.T) { t.Parallel() @@ -164,10 +159,8 @@ func TestDatagram_FilterDropsBelowStart(t *testing.T) { } } -// TestDatagram_UnknownAliasDroppedSilently pins the §11.3 rule: an inbound -// datagram with a Track Alias the relay doesn't recognise is dropped -// silently — the session must NOT be closed, and the relay must keep -// processing further datagrams normally. +// TestDatagram_UnknownAliasDroppedSilently: a datagram with an unknown Track +// Alias is dropped without closing the session (§11.3). func TestDatagram_UnknownAliasDroppedSilently(t *testing.T) { t.Parallel() @@ -240,10 +233,8 @@ func TestDatagram_UnknownAliasDroppedSilently(t *testing.T) { } } -// TestDatagram_PausedSubscriptionReceivesNothing pins the §9.2 Forward-State -// gate on the datagram path: a subscription paused via REQUEST_UPDATE -// (Forward=0) receives no datagrams, and resuming (Forward=1) restores -// delivery — mirroring the subgroup fanout's ForwardDecision gate. +// TestDatagram_PausedSubscriptionReceivesNothing: FORWARD=0 stops datagrams +// and FORWARD=1 restores them (§9.2). func TestDatagram_PausedSubscriptionReceivesNothing(t *testing.T) { t.Parallel() @@ -324,10 +315,8 @@ func TestDatagram_PausedSubscriptionReceivesNothing(t *testing.T) { } } -// TestDatagram_RedundantPublishersDeduped pins §2.1 on the datagram path: -// with two redundant publishers feeding the same track, each {Group, -// Object} is forwarded to a subscriber exactly once (the subgroup path -// already dedups via the same entry ledger). +// TestDatagram_RedundantPublishersDeduped: datagrams from two redundant +// publishers reach the subscriber once each (§2.1). func TestDatagram_RedundantPublishersDeduped(t *testing.T) { t.Parallel() diff --git a/pkg/relay/handler_fanout_firstobject_test.go b/pkg/relay/handler_fanout_firstobject_test.go index d3ca5cd2..4836e753 100644 --- a/pkg/relay/handler_fanout_firstobject_test.go +++ b/pkg/relay/handler_fanout_firstobject_test.go @@ -143,11 +143,8 @@ func TestFanout_FirstObjectBitOnPlainForward(t *testing.T) { } } -// TestFanout_FirstObjectBitClearedForFilteredHead pins the filtered-head -// case: a subscriber whose filter starts mid-subgroup gets a stream whose -// first object is NOT the subgroup's first — the FIRST_OBJECT bit must be -// clear (ReplayingSubgroup true), where it previously advertised the stream -// as starting at the subgroup's origin. +// TestFanout_FirstObjectBitClearedForFilteredHead: a stream starting +// mid-subgroup because of the filter does not claim FIRST_OBJECT. func TestFanout_FirstObjectBitClearedForFilteredHead(t *testing.T) { t.Parallel() filter := &message.LocationFilter{Fields: 2, StartGroup: 0, StartObject: 2} @@ -168,10 +165,8 @@ func TestFanout_FirstObjectBitClearedForFilteredHead(t *testing.T) { } } -// TestFanout_FirstObjectBitAcrossGapReopen pins the §11.4.3 gap-reopen case: -// when the inbound subgroup skips object IDs, the relay resets the outbound -// stream and opens a fresh one — whose first object is mid-subgroup, so only -// the ORIGINAL stream may carry FIRST_OBJECT. +// TestFanout_FirstObjectBitAcrossGapReopen: after a gap reopen (§11.4.3) only +// the original stream carries FIRST_OBJECT. func TestFanout_FirstObjectBitAcrossGapReopen(t *testing.T) { t.Parallel() pub, sub := firstObjectTopology(t, nil) @@ -204,10 +199,8 @@ func TestFanout_FirstObjectBitAcrossGapReopen(t *testing.T) { } } -// TestFanout_FirstObjectBitNotInvented pins the propagation rule: when the -// INBOUND header already declared the stream a replay (FIRST_OBJECT clear), -// the relay must not invent the bit on the outbound stream even though it -// forwards from the inbound stream's first object. +// TestFanout_FirstObjectBitNotInvented: an inbound replay stream (FIRST_OBJECT +// clear) is not forwarded with the bit set. func TestFanout_FirstObjectBitNotInvented(t *testing.T) { t.Parallel() pub, sub := firstObjectTopology(t, nil) @@ -227,12 +220,9 @@ func TestFanout_FirstObjectBitNotInvented(t *testing.T) { } } -// TestFanout_ResolvesImplicitFirstObjectSubgroupID pins the §11.4.2 -// mode-0b01 resolution at ingest: a SUBGROUP_HEADER whose Subgroup ID is -// implied by its first object (here ID 5) must be attributed to subgroup 5 -// everywhere — the forwarded header (rewritten to the explicit form, since -// the fanout re-encodes object deltas) and the cached objects a later FETCH -// serves. Previously the whole pipeline filed such subgroups under ID 0. +// TestFanout_ResolvesImplicitFirstObjectSubgroupID: a mode-0b01 header's +// Subgroup ID comes from its first Object (§11.4.2), in the forwarded header +// and in the cache a FETCH serves. func TestFanout_ResolvesImplicitFirstObjectSubgroupID(t *testing.T) { t.Parallel() pub, sub := firstObjectTopology(t, nil) @@ -294,11 +284,9 @@ func TestFanout_ResolvesImplicitFirstObjectSubgroupID(t *testing.T) { } } -// TestFanout_ImplicitFirstObjectEdgeStreams pins the mode-0b01 pre-read's -// edge behaviour: a terminal-status first object still drives the §11.4.3 -// post-terminal enforcement through the pending handoff, and an empty 0b01 -// stream (whose Subgroup ID never resolves) leaves no state behind — a -// following normal subgroup fans out untouched. +// TestFanout_ImplicitFirstObjectEdgeStreams: a terminal-status first Object on +// a mode-0b01 stream still enforces §11.4.3, and an empty 0b01 stream leaves no +// state behind. func TestFanout_ImplicitFirstObjectEdgeStreams(t *testing.T) { t.Parallel() pub, sub := firstObjectTopology(t, nil) diff --git a/pkg/relay/handler_fanout_join_test.go b/pkg/relay/handler_fanout_join_test.go index 2d97f4c8..1edd1c4c 100644 --- a/pkg/relay/handler_fanout_join_test.go +++ b/pkg/relay/handler_fanout_join_test.go @@ -51,15 +51,9 @@ func joinOrFatal(t *testing.T, w *subgroupWriter) { } } -// TestSubgroupWriter_JoinUnwedgesBlockedWrite pins the bounded teardown join: -// a writer wedged inside a blocking stream write — the subscriber's session -// is alive but the data stream is not being read, so the synchronous -// in-process pipe never drains — used to hold the last contributor's -// <-w.done join hostage until the session died. joinWriters escalates after -// the deadline by cancelling the writer's stream I/O. Both wedge points are -// covered: the SUBGROUP_HEADER write inside the open (peer never accepts the -// stream) and an object write on an established stream (peer accepted — -// header consumed — but reads no objects). +// TestSubgroupWriter_JoinUnwedgesBlockedWrite: joinWriters cancels a writer +// wedged in a stream write after its deadline, whether blocked on the +// SUBGROUP_HEADER (stream never accepted) or on an Object (never read). func TestSubgroupWriter_JoinUnwedgesBlockedWrite(t *testing.T) { t.Parallel() diff --git a/pkg/relay/handler_fanout_lag_test.go b/pkg/relay/handler_fanout_lag_test.go index 94e00ab6..19dbc41a 100644 --- a/pkg/relay/handler_fanout_lag_test.go +++ b/pkg/relay/handler_fanout_lag_test.go @@ -9,19 +9,10 @@ import ( "github.com/floatdrop/moq-go/pkg/relay" ) -// TestFanout_LagWindowResetsSlowSubscriber pins §8 latency-window -// backpressure. A subscriber that accepts its stream but then stalls lets the -// relay's send queue back up; once a queued object has waited longer than -// MaxFanoutLag the relay resets the outbound stream and terminates the -// subscription. This is a latency measure, not a cumulative drop count: a -// subscriber that keeps up (the fast path in -// TestFanout_SlowSubscriberGetsResetWithoutBlockingFastOne) is left alone. -// -// The in-process transport is synchronous (a write blocks until the peer -// reads), so while the subscriber stalls the relay's first WriteObject blocks -// and the remaining objects age in the queue. When the subscriber finally -// reads the first object the writer dequeues the next — now aged past the -// window — and escalates. +// TestFanout_LagWindowResetsSlowSubscriber: once a queued Object has waited +// longer than MaxFanoutLag the relay resets the stream and ends the +// subscription (§8). The synchronous pipe lets Objects age while the +// subscriber stalls. func TestFanout_LagWindowResetsSlowSubscriber(t *testing.T) { const lag = 100 * time.Millisecond pubSess, teardown := connectRelay(t, relay.Config{MaxFanoutLag: lag}) diff --git a/pkg/relay/handler_fanout_multipub_test.go b/pkg/relay/handler_fanout_multipub_test.go index dbc2be85..326a6586 100644 --- a/pkg/relay/handler_fanout_multipub_test.go +++ b/pkg/relay/handler_fanout_multipub_test.go @@ -23,11 +23,9 @@ type objEvent struct { err error // non-nil marks a stream end (io.EOF = FIN, else reset) or accept error } -// readSubgroups loops AcceptDataStream on sub, decoding every object on every -// outbound subgroup stream into absolute Object IDs and emitting each on the -// returned channel. A per-stream read error is emitted as an objEvent with err -// set (and stream index) so callers can distinguish a clean FIN (io.EOF) from a -// reset. The goroutine exits when AcceptDataStream fails (session torn down). +// readSubgroups emits every Object of every subgroup stream sub accepts, with +// its absolute Object ID, and each stream's end as an event with err set +// (io.EOF for a FIN). It returns when AcceptDataStream fails. func readSubgroups(ctx context.Context, sub *session.Session, out chan<- objEvent) { streamIdx := 0 for { @@ -65,13 +63,9 @@ func readSubgroups(ctx context.Context, sub *session.Session, out chan<- objEven } } -// TestFanout_MultiPublisher_DeduplicatesObjects pins §9.5 / §2.1: two publishers -// claim the same Full Track Name and push the SAME {GroupID, ObjectID} objects. -// The relay must merge them into ONE outbound subgroup stream per subscriber -// (§2.2) and deliver each object exactly once — the second publisher's copies -// are dropped by the dedup gate. The writes are serialised (publisher B writes -// only after the subscriber has drained A's objects) so every one of B's objects -// is a pure duplicate, making the assertion deterministic. +// TestFanout_MultiPublisher_DeduplicatesObjects: two publishers sending the +// same Objects of one track reach the subscriber as one stream with each Object +// once (§9.3, §2.1, §2.2). func TestFanout_MultiPublisher_DeduplicatesObjects(t *testing.T) { t.Parallel() @@ -153,12 +147,8 @@ func TestFanout_MultiPublisher_DeduplicatesObjects(t *testing.T) { } } -// TestFanout_MultiPublisher_DedupSurvivesCacheEviction is the regression test -// for the dedup ledger being independent of the size-bounded Object Cache: a -// redundant publisher lagging by MORE than the cache capacity must still have -// its already-delivered objects dropped, not re-forwarded out of order. With a -// 4-object cache, publisher A streams 0..9 (so 0..5 are evicted); publisher B -// then replays 0..9. The subscriber must see exactly 0..9 once each, in order. +// TestFanout_MultiPublisher_DedupSurvivesCacheEviction: dedup holds for a +// redundant publisher lagging by more than the cache capacity. func TestFanout_MultiPublisher_DedupSurvivesCacheEviction(t *testing.T) { t.Parallel() @@ -241,11 +231,9 @@ func TestFanout_MultiPublisher_DedupSurvivesCacheEviction(t *testing.T) { } } -// TestFanout_MultiPublisher_FailoverContinuesFromSurvivor pins §9.5 fault -// tolerance: with two publishers feeding one track, resetting one mid-stream -// must NOT tear down the subscriber's stream — the surviving publisher keeps -// delivering on the same outbound stream, which FINs cleanly at the end. This -// is the §2.2 reset/"upstream conditions" carve-out put to work. +// TestFanout_MultiPublisher_FailoverContinuesFromSurvivor: resetting one of two +// publishers mid-stream leaves the subscriber's stream to the survivor, which +// FINs it cleanly (§9.3, §2.2). func TestFanout_MultiPublisher_FailoverContinuesFromSurvivor(t *testing.T) { t.Parallel() @@ -323,11 +311,8 @@ func TestFanout_MultiPublisher_FailoverContinuesFromSurvivor(t *testing.T) { } } -// TestFanout_MultiPublisher_MergesDisjointObjects pins the fan-in union: two -// publishers contribute DIFFERENT objects of the same track (A: evens, B: odds). -// Every object must be delivered exactly once. Interleaving across upstreams may -// trigger §11.4.3 stream reopens, so the assertion is on the delivered set (full -// coverage, no duplicates) rather than the stream count. +// TestFanout_MultiPublisher_MergesDisjointObjects: two publishers contributing +// different Objects of one track deliver each exactly once. func TestFanout_MultiPublisher_MergesDisjointObjects(t *testing.T) { t.Parallel() diff --git a/pkg/relay/handler_fanout_terminal_test.go b/pkg/relay/handler_fanout_terminal_test.go index 45250fa5..e16fcb95 100644 --- a/pkg/relay/handler_fanout_terminal_test.go +++ b/pkg/relay/handler_fanout_terminal_test.go @@ -9,11 +9,9 @@ import ( "github.com/floatdrop/moq-go/pkg/relay" ) -// TestFanout_ObjectAfterEndOfGroupResetsStream pins the §11.4.3 / §2.4.2 rule -// that no object may follow a terminal-status object (EndOfGroup / EndOfTrack) -// on the same Subgroup stream: the relay forwards the normal object and the -// EndOfGroup object, then — when a further object arrives on the same inbound -// subgroup — resets the downstream stream instead of forwarding it. +// TestFanout_ObjectAfterEndOfGroupResetsStream: an Object after END_OF_GROUP on +// the same subgroup resets the downstream stream instead of being forwarded +// (§11.4.3, §2.4.2). func TestFanout_ObjectAfterEndOfGroupResetsStream(t *testing.T) { t.Parallel() diff --git a/pkg/relay/handler_fanout_test.go b/pkg/relay/handler_fanout_test.go index af2063e1..d6f54c51 100644 --- a/pkg/relay/handler_fanout_test.go +++ b/pkg/relay/handler_fanout_test.go @@ -13,11 +13,8 @@ import ( "github.com/floatdrop/moq-go/pkg/relay" ) -// TestFanout_PublisherToSubscriberSingleObject is the minimum-viable -// fanout test: -// publisher opens a subgroup stream, writes one object; the relay forwards -// it to a subscriber on a separate session with the relay-allocated outbound -// TrackAlias. +// TestFanout_PublisherToSubscriberSingleObject: one Object reaches a subscriber +// on another session under the relay-allocated Track Alias. func TestFanout_PublisherToSubscriberSingleObject(t *testing.T) { t.Parallel() @@ -106,20 +103,10 @@ func TestFanout_PublisherToSubscriberSingleObject(t *testing.T) { } } -// TestFanout_StalledSubscriberDoesNotBlockFastOne pins the per-subscriber -// isolation guarantee: a subscriber that stops reading overflows its own -// bounded send queue (the relay drops objects for it) but does NOT stall a -// concurrent fast subscriber, which still receives every object. -// -// Method: connect two subscribers; one (the "stalled" one) never reads from -// its outbound stream after the initial Accept, so the relay's per-subscriber -// writer inbox fills and the relay starts dropping objects for it. The other -// reads everything. The publisher paces itself to the fast subscriber's reads -// (see fastRead) so the fast inbox can never overflow — without this the -// publisher's non-blocking flood would, at GOMAXPROCS=1, run to completion -// before the fast writer goroutine is ever scheduled and the fast subscriber -// would itself drop objects. We then assert the fast subscriber received every -// object and that the stalled subscriber genuinely overflowed (dropped > 0). +// TestFanout_StalledSubscriberDoesNotBlockFastOne: a subscriber that stops +// reading overflows its own queue without stalling a fast one, which gets every +// Object. The publisher paces itself to the fast reader so, at GOMAXPROCS=1, +// only the stalled queue can overflow. func TestFanout_StalledSubscriberDoesNotBlockFastOne(t *testing.T) { // Small queue so the stalled subscriber overflows; MaxDropsBeforeReset // left disabled — a fully-stalled subscriber blocks inside WriteObject on @@ -276,13 +263,9 @@ func TestFanout_StalledSubscriberDoesNotBlockFastOne(t *testing.T) { } } -// TestFanout_UnresponsiveSubscriberDoesNotStallSubgroup is the regression -// test for header writes under the subgroup lock: subscriber A never even -// accepts its data stream, so the relay's SUBGROUP_HEADER write to A blocks -// forever. That write used to run inside openWriterForSub under sg.Mu — the -// lock every contributor takes per forwarded object — stalling the whole -// subgroup (subscriber B starved and the inbound read loop wedged). With the -// lazy per-writer open, only A's own writer goroutine blocks. +// TestFanout_UnresponsiveSubscriberDoesNotStallSubgroup: a subscriber that never +// accepts its data stream blocks only its own writer, not the subgroup's other +// subscribers or the inbound read loop. func TestFanout_UnresponsiveSubscriberDoesNotStallSubgroup(t *testing.T) { t.Parallel() pubSess, teardown := connectRelay(t, relay.Config{}) @@ -362,13 +345,9 @@ func TestFanout_UnresponsiveSubscriberDoesNotStallSubgroup(t *testing.T) { } } -// TestFanout_AbsoluteStartFilter_DropsObjectsBeforeStart is the -// §5.1.2 filter -// canonical filter test: a subscriber with LocationFilter type -// AbsoluteStart {Group: 0, Object: 2} must only see objects whose absolute -// Location is >= {0, 2}. Earlier objects are dropped pre-enqueue and the -// outbound stream's ObjectIDDelta is re-encoded so the subscriber decodes -// the same absolute Object IDs the publisher emitted. +// TestFanout_AbsoluteStartFilter_DropsObjectsBeforeStart: an AbsoluteStart +// {0, 2} filter drops earlier Objects, and the forwarded deltas decode to the +// publisher's Object IDs (§5.1.2). func TestFanout_AbsoluteStartFilter_DropsObjectsBeforeStart(t *testing.T) { t.Parallel() @@ -481,11 +460,8 @@ func TestFanout_AbsoluteStartFilter_DropsObjectsBeforeStart(t *testing.T) { } } -// TestFanout_AbsoluteRangeFilter_DropsObjectsOutsideRange checks the -// AbsoluteRange filter on a single subgroup: Start = {0, 1}, EndGroupDelta -// = 0 admits objects in Group 0 with Object ID >= 1 only. The subscriber -// must therefore see IDs 1, 2, 3 (not 0) with deltas re-encoded against -// the previous forwarded ID. +// TestFanout_AbsoluteRangeFilter_DropsObjectsOutsideRange: an AbsoluteRange +// {0, 1}..group 0 filter forwards Objects 1, 2, 3 with re-encoded deltas. func TestFanout_AbsoluteRangeFilter_DropsObjectsOutsideRange(t *testing.T) { t.Parallel() @@ -595,12 +571,8 @@ func TestFanout_AbsoluteRangeFilter_DropsObjectsOutsideRange(t *testing.T) { } } -// TestSubscribe_InstallsPriorityAndGroupOrder verifies that -// SUBSCRIBER_PRIORITY and GROUP_ORDER parameters on a SUBSCRIBE are parsed -// and recorded on the [relay.DownstreamSub]. The relay doesn't yet act on -// these values at the QUIC layer (subgroup streams are §11.4.3 in-order, -// and per-stream priority isn't exposed), but the values are plumbed -// through so future scheduling and FETCH-response work can consult them. +// TestSubscribe_InstallsPriorityAndGroupOrder: SUBSCRIBER_PRIORITY and +// GROUP_ORDER on a SUBSCRIBE are recorded on the downstream subscription. func TestSubscribe_InstallsPriorityAndGroupOrder(t *testing.T) { t.Parallel() @@ -636,17 +608,9 @@ func TestSubscribe_InstallsPriorityAndGroupOrder(t *testing.T) { // covered by the unit test on DownstreamSub setters. } -// TestFanout_GapInForwardedObjectIDsOpensNewStream is the canonical -// §11.4.3 test: when the relay observes a gap in the forwarded Object IDs -// on a single inbound subgroup, it MUST reset the current outbound subgroup -// stream and open a fresh one for the next object. The subscriber should -// therefore see two outbound streams — the first containing only the -// pre-gap object, the second containing only the post-gap object. -// -// The publisher synthesises the gap by emitting an ObjectIDDelta that -// jumps from absolute Object ID 0 to absolute Object ID 2 (skipping 1). -// §11.4.3 technically forbids the publisher from doing this; we exercise -// the relay's defensive path that handles it anyway. +// TestFanout_GapInForwardedObjectIDsOpensNewStream: a gap in the Object IDs of +// one inbound subgroup resets the outbound stream and opens a new one for the +// next Object (§11.4.3). func TestFanout_GapInForwardedObjectIDsOpensNewStream(t *testing.T) { t.Parallel() @@ -780,12 +744,8 @@ func TestFanout_GapInForwardedObjectIDsOpensNewStream(t *testing.T) { } } -// TestFanout_InboundResetCancelsDownstream is the reset-propagation -// test: when the publisher's inbound subgroup stream is cancelled (not -// FIN'd) the relay MUST reset the corresponding downstream subgroup stream -// rather than FIN it. §11.4.3: "Processing a reset means that there might -// be other objects in the Subgroup beyond the last one received. A relay -// might immediately reset the corresponding downstream stream...". +// TestFanout_InboundResetCancelsDownstream: a reset inbound subgroup stream +// resets the downstream one rather than FINning it (§11.4.3). func TestFanout_InboundResetCancelsDownstream(t *testing.T) { t.Parallel() @@ -873,13 +833,9 @@ func TestFanout_InboundResetCancelsDownstream(t *testing.T) { } } -// TestFanout_UpdatesTrackEntryLargestObject pins §10.2.17: every forwarded -// object advances the entry's LargestObject watermark. The watermark isn't -// directly observable on the wire (TRACK_STATUS_OK doesn't yet carry -// LARGEST_OBJECT in this stage), so the test exercises the indirect signal: -// a later SUBSCRIBE with FilterLargestObject snapshots the entry's current -// watermark, and a follow-up object at a Location < snapshot is filtered -// out while one at a Location > snapshot passes. +// TestFanout_UpdatesTrackEntryLargestObject: each forwarded Object advances the +// track's LargestObject watermark (§10.2.17), observed through a later +// LargestObject-filtered SUBSCRIBE. func TestFanout_UpdatesTrackEntryLargestObject(t *testing.T) { t.Parallel() diff --git a/pkg/relay/handler_fanout_timeout_test.go b/pkg/relay/handler_fanout_timeout_test.go index 190008fa..7fffcad7 100644 --- a/pkg/relay/handler_fanout_timeout_test.go +++ b/pkg/relay/handler_fanout_timeout_test.go @@ -10,25 +10,14 @@ import ( "github.com/floatdrop/moq-go/pkg/relay" ) -// The §8 delivery timeouts and the §8 lag window are both "this subscriber is -// too slow", and the relay must not confuse them. §3.3.4 draws the line by the -// reset code it attaches: TOO_FAR_BEHIND is defined as "the corresponding -// subscription has exceeded the publisher's resource limits and is being -// terminated", whereas DELIVERY_TIMEOUT says only "A delivery timeout -// (Section 8) was exceeded for this stream". The tests below pin the difference from the -// subscriber's side, which is the only side that can observe it: after a -// delivery timeout the track keeps flowing, after a lag breach it does not -// (see TestFanout_LagWindowResetsSlowSubscriber). -// -// MaxFanoutLag is deliberately left at its zero value throughout, so the only -// escalation that can fire is the one under test. - -// countUntilEnd reads objects until the stream ends and returns how many -// arrived. The in-process pipe transport does not carry §3.3.4 reset codes, so -// a reset and a clean FIN look alike to the reader — the count is what -// separates them: a stream cut short by a timeout delivers fewer objects than -// the publisher wrote, and one that ran to completion delivers all of them. -// That is also the difference a real subscriber cares about. +// A §8 delivery timeout resets one stream and the track keeps flowing, unlike a +// lag-window breach, which ends the subscription (§3.3.4; see +// TestFanout_LagWindowResetsSlowSubscriber). MaxFanoutLag stays zero here, so +// only the escalation under test can fire. + +// countUntilEnd reads Objects until the stream ends or within elapses and +// returns how many arrived. The pipe transport carries no reset codes, so a +// short count is what shows a reset. func countUntilEnd(sg *session.IncomingSubgroupStream, within time.Duration) int { deadline := time.Now().Add(within) got := 0 @@ -41,16 +30,9 @@ func countUntilEnd(sg *session.IncomingSubgroupStream, within time.Duration) int return got } -// TestFanout_DeliveryTimeoutKeepsSubscriptionAlive pins the §8 / -// §3.3.4 distinction that makes a per-subgroup timeout usable: a subscriber -// that stalls past OBJECT_DELIVERY_TIMEOUT loses the subgroup it stalled on, -// and nothing else. The relay resets that one stream and keeps forwarding, so -// the next group arrives without the subscriber having to re-SUBSCRIBE. -// -// Before delivery timeouts were sourced in the fanout, the only escalation the -// relay had was the lag window, which terminates the subscription outright — -// so a publisher had no way to mark one subgroup as sheddable without risking -// the whole track. +// TestFanout_DeliveryTimeoutKeepsSubscriptionAlive: a subscriber stalled past +// OBJECT_DELIVERY_TIMEOUT loses only that subgroup; the next group still +// arrives (§8, §3.3.4). func TestFanout_DeliveryTimeoutKeepsSubscriptionAlive(t *testing.T) { const timeout = 100 * time.Millisecond pubSess, teardown := connectRelay(t, relay.Config{}) @@ -122,13 +104,8 @@ func TestFanout_DeliveryTimeoutKeepsSubscriptionAlive(t *testing.T) { } } -// TestFanout_PublisherTrackDeliveryTimeoutApplies pins the other half of the -// §8 resolution: the value can come from the publisher's Track Properties -// (§12.2) rather than the subscriber's parameters, and the relay must apply it -// to the streams it opens downstream. The subscriber here asks for nothing. -// -// This is the direction a publisher uses to mark its own data sheddable, so -// the relay sourcing it is what the whole mechanism rests on. +// TestFanout_PublisherTrackDeliveryTimeoutApplies: a delivery timeout from the +// publisher's Track Properties (§12.2) applies downstream too. func TestFanout_PublisherTrackDeliveryTimeoutApplies(t *testing.T) { const timeout = 100 * time.Millisecond prop := wire.KVPair{Type: message.PropertyObjectDeliveryTimeout, IntVal: uint64(timeout / time.Millisecond)} @@ -145,6 +122,8 @@ func TestFanout_PublisherTrackDeliveryTimeoutApplies(t *testing.T) { } } +// testPublisherTrackDeliveryTimeout requires a stalled subscriber's stream to +// be cut short by the delivery timeout the publisher's trackProps set. func testPublisherTrackDeliveryTimeout(t *testing.T, timeout time.Duration, trackProps []wire.KVPair) { pubSess, teardown := connectRelay(t, relay.Config{}) defer teardown() @@ -188,11 +167,8 @@ func testPublisherTrackDeliveryTimeout(t *testing.T, timeout time.Duration, trac } } -// TestFanout_NoDeliveryTimeoutLeavesStalledSubscriberAlone is the control for -// both tests above: with neither side declaring a timeout and no MaxFanoutLag, -// the same stall must cost the subscriber nothing. Without this, a bug that -// reset every slow stream unconditionally would still pass the two tests that -// assert a reset happens. +// TestFanout_NoDeliveryTimeoutLeavesStalledSubscriberAlone: the control — with +// no timeout and no MaxFanoutLag, the same stall resets nothing. func TestFanout_NoDeliveryTimeoutLeavesStalledSubscriberAlone(t *testing.T) { pubSess, teardown := connectRelay(t, relay.Config{}) defer teardown() diff --git a/pkg/relay/handler_fetch_elem_test.go b/pkg/relay/handler_fetch_elem_test.go index ac45ea8c..8c4b8f0b 100644 --- a/pkg/relay/handler_fetch_elem_test.go +++ b/pkg/relay/handler_fetch_elem_test.go @@ -6,24 +6,10 @@ import ( "github.com/floatdrop/moq-go/pkg/moqt/message" ) -// TestUpstreamFetchElemOK covers the guard that decides whether one element of -// an upstream relay's FETCH response may be re-serialized downstream. -// -// This is the sharpest edge in the stitching path. Every element it accepts is -// re-encoded into a §11.4.4 delta stream, where Group and Object IDs are -// expressed relative to the previous element — so accepting an element that -// moves the wrong way does not produce a visibly broken response, it produces a -// well-formed one carrying the WRONG absolute IDs, decoded without complaint by -// a conforming peer. The failure is invisible on both sides of the round trip, -// which is exactly the shape this repo's unit suite cannot otherwise catch. -// -// The rules, from the function's own contract and §11.4.4: -// -// - every element must lie within the requested [start, endIncl]; -// - within a group, Object IDs strictly ascend; -// - across groups, the Group ID moves in the response's order direction; -// - unknown-range markers carry absolute IDs and only re-anchor the -// encoding, so only the range check applies to them. +// TestUpstreamFetchElemOK: an upstream FETCH element may be re-serialized only +// if it lies in [start, endIncl], Object IDs ascend within a group, and Group +// IDs move in the response's order; markers get the range check only +// (§11.4.4). A wrong one would re-encode to well-formed but wrong IDs. func TestUpstreamFetchElemOK(t *testing.T) { t.Parallel() diff --git a/pkg/relay/handler_fetch_merge_test.go b/pkg/relay/handler_fetch_merge_test.go index e0c1f8d8..e8197b0f 100644 --- a/pkg/relay/handler_fetch_merge_test.go +++ b/pkg/relay/handler_fetch_merge_test.go @@ -3,6 +3,7 @@ package relay import ( "errors" "io" + "slices" "testing" "github.com/floatdrop/moq-go/pkg/moqt" @@ -13,16 +14,20 @@ import ( "github.com/floatdrop/moq-go/pkg/relay/cache" ) +// obj is a cached Object at {g, o}. func obj(g, o uint64) *cache.CachedObject { return &cache.CachedObject{GroupID: g, ObjectID: o, Payload: []byte{byte(o)}} } +// marker is a cached End of Unknown Range marker at {g, o}. func marker(g, o uint64) *cache.CachedObject { return &cache.CachedObject{GroupID: g, ObjectID: o, EndOfUnknownRange: true} } +// loc is a {Group, Object} Location. type loc struct{ G, O uint64 } +// locsOf returns the Locations of objs. func locsOf(objs []*cache.CachedObject) []loc { out := make([]loc, 0, len(objs)) for _, o := range objs { @@ -31,25 +36,9 @@ func locsOf(objs []*cache.CachedObject) []loc { return out } -func locsEqual(got, want []loc) bool { - if len(got) != len(want) { - return false - } - for i := range got { - if got[i] != want[i] { - return false - } - } - return true -} - -// TestMergeFetchObjects_DescendingSeamSplice pins the mid-group-floor merge: -// when the eviction floor splits a group between the cache and the upstream -// stitch, the descending merge must splice the seam group into one -// contiguous ascending run (upstream's lower Object IDs first) — plain -// concatenation puts the cache's high-object run first, a same-group -// transition to a lower Object ID that §11.4.4's delta encoding cannot -// express. +// TestMergeFetchObjects_DescendingSeamSplice: when the eviction floor splits a +// group, the descending merge splices it into one ascending run, upstream's +// lower Object IDs first, which §11.4.4's delta encoding can express. func TestMergeFetchObjects_DescendingSeamSplice(t *testing.T) { t.Parallel() desc := message.GroupOrderDescending @@ -87,7 +76,7 @@ func TestMergeFetchObjects_DescendingSeamSplice(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { got := locsOf(mergeFetchObjects(desc, tc.lower, tc.upper)) - if !locsEqual(got, tc.want) { + if !slices.Equal(got, tc.want) { t.Errorf("merged %v, want %v", got, tc.want) } }) @@ -97,16 +86,13 @@ func TestMergeFetchObjects_DescendingSeamSplice(t *testing.T) { asc := locsOf(mergeFetchObjects(message.GroupOrderAscending, []*cache.CachedObject{obj(6, 0), obj(6, 1)}, []*cache.CachedObject{obj(6, 2), obj(7, 0)})) - if !locsEqual(asc, []loc{{6, 0}, {6, 1}, {6, 2}, {7, 0}}) { + if !slices.Equal(asc, []loc{{6, 0}, {6, 1}, {6, 2}, {7, 0}}) { t.Errorf("ascending merge = %v", asc) } } -// TestStreamFetchObjects_DescendingSeamRoundTrip pins the wire outcome: a -// descending stitched response whose floor split a group must decode back -// to the exact merged Locations. Before the seam splice, the concatenated -// order made streamFetchObjects emit a wrapped same-group delta the -// subscriber decoded into garbage Object IDs. +// TestStreamFetchObjects_DescendingSeamRoundTrip: a descending stitched +// response with a split group decodes back to the merged Locations. func TestStreamFetchObjects_DescendingSeamRoundTrip(t *testing.T) { t.Parallel() cli, srv := sessiontest.NewSessionPair(t) @@ -159,17 +145,13 @@ func TestStreamFetchObjects_DescendingSeamRoundTrip(t *testing.T) { } want := []loc{{7, 0}, {6, 0}, {6, 1}, {6, 2}, {6, 3}, {5, 0}} - if !locsEqual(got, want) { + if !slices.Equal(got, want) { t.Fatalf("decoded %v, want %v", got, want) } } -// TestMergeFetchObjects_SeamMarkersSpliced pins the marker-tolerant splice: -// an upstream 0x10C marker interleaved with (or trailing) the seam group's -// objects moves with them — stopping the splice at a marker would re-emit -// the cache's high-object run before the remaining low-object seam objects, -// the very order the splice exists to prevent. A prefix with no objects -// (the whole-sub-range unknown marker) still stays after the cache. +// TestMergeFetchObjects_SeamMarkersSpliced: an upstream marker among the seam +// group's Objects moves with them; a marker-only prefix stays after the cache. func TestMergeFetchObjects_SeamMarkersSpliced(t *testing.T) { t.Parallel() desc := message.GroupOrderDescending @@ -199,7 +181,7 @@ func TestMergeFetchObjects_SeamMarkersSpliced(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { got := locsOf(mergeFetchObjects(desc, tc.lower, upper)) - if !locsEqual(got, tc.want) { + if !slices.Equal(got, tc.want) { t.Errorf("merged %v, want %v", got, tc.want) } }) diff --git a/pkg/relay/handler_fetch_session_test.go b/pkg/relay/handler_fetch_session_test.go index 846e5fa0..4b53550e 100644 --- a/pkg/relay/handler_fetch_session_test.go +++ b/pkg/relay/handler_fetch_session_test.go @@ -1,7 +1,6 @@ package relay_test import ( - "errors" "math" "testing" @@ -27,24 +26,6 @@ func TestFetch_RejectsUnknownTrack(t *testing.T) { requireRejectedWithCode(t, err, moqt.RequestDoesNotExist) } -// TestFetch_AuthDenialUsesPolicyCode verifies the authorizer wires through -// for FETCH and runs before the NotSupported stub. -func TestFetch_AuthDenialUsesPolicyCode(t *testing.T) { - t.Parallel() - auth := &denyAuthorizer{err: errors.New("no fetch for you")} - clientSess, teardown := connectRelay(t, relay.Config{Authorizer: auth}) - defer teardown() - - _, err := clientSess.Fetch(t.Context(), &message.Fetch{ - Namespace: ns("video"), - Name: []byte("cam1"), - }) - requireRejectedWithCode(t, err, moqt.RequestUnauthorized) - if got := auth.fetchCalls.Load(); got != 1 { - t.Errorf("fetchCalls = %d, want 1", got) - } -} - // TestTrackStatus_ReplyForKnownTrack: a publisher claims a track via PUBLISH, // which populates the TrackRegistry entry's Properties. A separate session's // TRACK_STATUS for the same name must echo those Properties in TRACK_STATUS_OK. @@ -79,11 +60,9 @@ func TestTrackStatus_ReplyForKnownTrack(t *testing.T) { } } -// TestTrackStatus_ReplyEmptyPropertiesForKnownNamespace verifies the -// fallback when a publisher has advertised the namespace via -// PUBLISH_NAMESPACE but no upstream subscription is yet active. The relay -// answers TRACK_STATUS_OK with empty Properties — telling the caller "this -// track may exist, but I have no metadata.". +// TestTrackStatus_ReplyEmptyPropertiesForKnownNamespace: a TRACK_STATUS for a +// track under an advertised namespace with no upstream yet gets +// TRACK_STATUS_OK with empty Properties. func TestTrackStatus_ReplyEmptyPropertiesForKnownNamespace(t *testing.T) { t.Parallel() pubSess, teardown := connectRelay(t, relay.Config{}) @@ -126,21 +105,3 @@ func TestTrackStatus_RejectsUnknownTrack(t *testing.T) { }) requireRejectedWithCode(t, err, moqt.RequestDoesNotExist) } - -// TestTrackStatus_AuthDenialUsesPolicyCode pins auth precedence on the -// TRACK_STATUS arm. -func TestTrackStatus_AuthDenialUsesPolicyCode(t *testing.T) { - t.Parallel() - auth := &denyAuthorizer{err: errors.New("no status")} - clientSess, teardown := connectRelay(t, relay.Config{Authorizer: auth}) - defer teardown() - - _, err := clientSess.TrackStatus(t.Context(), &message.TrackStatus{ - Namespace: ns("video"), - Name: []byte("cam1"), - }) - requireRejectedWithCode(t, err, moqt.RequestUnauthorized) - if got := auth.trackStatusCalls.Load(); got != 1 { - t.Errorf("trackStatusCalls = %d, want 1", got) - } -} diff --git a/pkg/relay/handler_fetch_split_test.go b/pkg/relay/handler_fetch_split_test.go index 74aa04f6..f7f4727e 100644 --- a/pkg/relay/handler_fetch_split_test.go +++ b/pkg/relay/handler_fetch_split_test.go @@ -29,12 +29,9 @@ func TestFetchPredecessor(t *testing.T) { } } -// draft-20 made both the FETCH range and FETCH_OK's End Location inclusive, so -// the exclusive/inclusive conversion the relay used to do is gone. What is left -// is §10.14's cap: the response ends at the requested end, or at Largest Object -// if the request reaches past it, or at Largest Object when the filter is -// open-ended ("When they are omitted from a Fetch, the EndGroup and EndObject -// are Largest Object", §5.1.2). +// TestCapFetchEndLocation: the response ends at the requested end, or at +// Largest Object when the request reaches past it or is open-ended (§10.14, +// §5.1.2). func TestCapFetchEndLocation(t *testing.T) { largest := message.Location{Group: 10, Object: 4} @@ -110,6 +107,7 @@ func TestMergeFetchObjects(t *testing.T) { } } +// groupIDs returns the Group IDs of objs. func groupIDs(objs []*cache.CachedObject) []uint64 { out := make([]uint64, len(objs)) for i, o := range objs { diff --git a/pkg/relay/handler_fetch_stitch_test.go b/pkg/relay/handler_fetch_stitch_test.go index 1c90fba2..8980f035 100644 --- a/pkg/relay/handler_fetch_stitch_test.go +++ b/pkg/relay/handler_fetch_stitch_test.go @@ -8,20 +8,14 @@ import ( "github.com/floatdrop/moq-go/pkg/relay" ) -// TestFetch_StitchesEvictedRangeFromUpstream pins the §9.4 upstream-stitching -// path end-to-end. The relay caches a live tail (groups 5..9, so its eviction -// floor is group 5) but a downstream FETCH asks for groups 0..9. The -// below-floor part (0..4) is not in cache, so the relay stitches it from an -// upstream FETCH and concatenates it with the cached tail. +// TestFetch_StitchesEvictedRangeFromUpstream: a FETCH of groups 0..9 when the +// cache holds 5..9 is stitched from an upstream FETCH of 0..4 (§9.4). // // upstream U ── PUBLISH_NAMESPACE ──▶ relay // ◀─ SUBSCRIBE (on-demand) ─ relay (U replies OK, pushes 5..9) // ◀─ FETCH [0..4] ────────── relay (U streams the evicted part) // live sub S ─ SUBSCRIBE ───────────▶ relay (triggers the upstream sub) // fetch F ─ FETCH [0..9] ────────▶ relay ─▶ F (stitched: U 0..4 + cache 5..9) -// -// It exercises the fetch router (cross-handler response routing), -// fetchUpstreamRange, the eviction-floor split, and the ordered merge. func TestFetch_StitchesEvictedRangeFromUpstream(t *testing.T) { upSess, teardown := connectRelay(t, relay.Config{}) defer teardown() diff --git a/pkg/relay/handler_fetch_test.go b/pkg/relay/handler_fetch_test.go index cd2ffb58..2c3c8064 100644 --- a/pkg/relay/handler_fetch_test.go +++ b/pkg/relay/handler_fetch_test.go @@ -15,11 +15,9 @@ import ( "github.com/floatdrop/moq-go/pkg/moqt/session" ) -// TestFetch_DatagramObjectRoundTrips is the regression test for the §11.4.4.1 -// Datagram bit (0x40): an object published as an OBJECT_DATAGRAM and served -// from the relay cache via FETCH must arrive with its payload intact and the -// Datagram bit set — 0x40 marks the wire shape, it does NOT mean "Object -// Status instead of payload" (FETCH objects carry no status field, §11.2.1.1). +// TestFetch_DatagramObjectRoundTrips: an Object published as a datagram is +// served by FETCH with its payload and the Datagram bit set (§11.4.4.1); FETCH +// Objects carry no status (§11.2.1.1). func TestFetch_DatagramObjectRoundTrips(t *testing.T) { t.Parallel() pubSess, subSess, publisherAlias := publishAndCache(t) @@ -92,10 +90,8 @@ func TestFetch_DatagramObjectRoundTrips(t *testing.T) { } } -// TestFetch_StatusMarkersNotServed pins §11.2.1.1 for the relay's FETCH -// serializer: cached End-of-Group status markers are never serialized into a -// FETCH response (the status field does not exist in FETCH objects), while a -// zero-length Normal object (Status 0) is a real object and IS served. +// TestFetch_StatusMarkersNotServed: cached End-of-Group status markers are not +// served by FETCH, while a zero-length Normal Object is (§11.2.1.1). func TestFetch_StatusMarkersNotServed(t *testing.T) { t.Parallel() pubSess, _, publisherAlias := publishAndCache(t) @@ -153,12 +149,9 @@ func TestFetch_StatusMarkersNotServed(t *testing.T) { } } -// TestFetch_WholeGroupEndForm pins the §5.1.2 wire form "When EndObject is -// omitted, the filter includes all objects in the End Group" end to end: a mid-group start with End={G,0} is a -// valid range (validation used to reject it as end < start), the FETCH_OK -// EndLocation is capped to the watermark+1 (capping used to echo {G,0} -// uncapped), and the delivered objects run from the start to the group's -// end. +// TestFetch_WholeGroupEndForm: a LOCATION_FILTER without EndObject covers the +// whole End Group (§5.1.2): a mid-group start is valid, FETCH_OK's EndLocation +// is capped to the watermark, and Objects run to the group's end. func TestFetch_WholeGroupEndForm(t *testing.T) { t.Parallel() pubSess, _, publisherAlias := publishAndCache(t) @@ -179,9 +172,8 @@ func TestFetch_WholeGroupEndForm(t *testing.T) { if ok == nil { t.Fatal("FetchOK is nil") } - // Largest is {0,2}; the whole-group request extends past it, so the response - // end is capped to Largest Object itself. draft-20 made FETCH_OK's End - // Location inclusive, so this is {0,2} — draft-19 encoded it as watermark+1. + // Largest is {0,2}; the whole-group request extends past it, so the + // inclusive response end is capped to Largest Object itself. if ok.EndLocation.Group != 0 || ok.EndLocation.Object != 2 { t.Fatalf("FETCH_OK EndLocation = {%d,%d}, want {0,2} (capped to Largest Object)", ok.EndLocation.Group, ok.EndLocation.Object) @@ -195,10 +187,8 @@ func TestFetch_WholeGroupEndForm(t *testing.T) { } } -// TestFetch_FromCacheAscending pins the 7d happy path: publisher emits -// objects across two groups; subscriber FETCHes the full range; relay -// returns each object in ascending (group asc, object asc) order with -// payloads intact. +// TestFetch_FromCacheAscending: a FETCH over two cached groups returns every +// Object in ascending order with its payload. func TestFetch_FromCacheAscending(t *testing.T) { t.Parallel() pubSess, _, publisherAlias := publishAndCache(t) @@ -234,10 +224,8 @@ func TestFetch_FromCacheAscending(t *testing.T) { } } -// TestFetch_FromCacheDescending exercises the §10.2.8 / §11.4.4 -// Descending group-order path: groups arrive in reverse order; objects -// within each group remain ascending (the spec keeps subgroup-internal -// order ascending regardless of GroupOrder). +// TestFetch_FromCacheDescending: with GroupOrder descending, groups arrive in +// reverse while Objects within a group stay ascending (§10.2.8, §11.4.4). func TestFetch_FromCacheDescending(t *testing.T) { t.Parallel() pubSess, _, publisherAlias := publishAndCache(t) @@ -294,10 +282,8 @@ func TestFetch_RejectsStartBeyondLargest(t *testing.T) { requireRejectedWithCode(t, err, moqt.RequestInvalidRange) } -// TestFetch_RejectsEmptyTrack pins the "no objects published yet" case -// (§10.13): the relay knows the track but the watermark is -// {0, 0}, so any FETCH (other than a request that ends at {0, 0}) -// has nothing to serve. REQUEST_ERROR / InvalidRange. +// TestFetch_RejectsEmptyTrack: a FETCH of a known track with no Objects yet is +// refused INVALID_RANGE (§10.13). func TestFetch_RejectsEmptyTrack(t *testing.T) { t.Parallel() pubSess, _, _ := publishAndCache(t) @@ -313,12 +299,9 @@ func TestFetch_RejectsEmptyTrack(t *testing.T) { requireRejectedWithCode(t, err, moqt.RequestInvalidRange) } -// TestSubscribe_FillCurrentGroup pins the §5.1.6 "join a Track at the current -// Group" happy path, which draft-20 rebuilt on fill fetch streams: SUBSCRIBE -// with a Next Object Location Filter plus FILL_PARAMETERS whose filter is -// StartGroup=1. The relay must open a fill fetch stream carrying the current -// group's cached objects — and key it to the SUBSCRIBE's own Request ID -// (§5.1.3), since there is no FETCH to name it. +// TestSubscribe_FillCurrentGroup: a Next Object SUBSCRIBE with FILL_PARAMETERS +// StartGroup=1 gets a fill fetch stream with the current group, keyed to the +// SUBSCRIBE's Request ID (§5.1.3, §5.1.6). func TestSubscribe_FillCurrentGroup(t *testing.T) { t.Parallel() pubSess, _, publisherAlias := publishAndCache(t) @@ -364,9 +347,7 @@ func TestSubscribe_FillCurrentGroup(t *testing.T) { if !isFetch { t.Fatalf("got %T, want *IncomingFetchStream — the fill must arrive on a fetch stream", ds) } - // §5.1.3: "The FETCH_HEADER on the fill fetch stream carries the Request ID - // of the message that initiated it: the SUBSCRIBE Request ID for the initial - // fill." Getting this wrong strands the subscriber's demux handler. + // §5.1.3: the initial fill carries the SUBSCRIBE's Request ID. if fs.Header.RequestID != subMsg.RequestID { t.Errorf("fill FETCH_HEADER Request ID = %d, want the SUBSCRIBE's %d", fs.Header.RequestID, subMsg.RequestID) @@ -383,18 +364,9 @@ func TestSubscribe_FillCurrentGroup(t *testing.T) { } } -// TestSubscribe_FillWholeTrack pins the other §5.1.6 shape — fill everything up -// to Largest Object, which draft-19 spelled as an Absolute Joining FETCH with -// JoiningStart=0. -// -// The spelling matters, and it is easy to get wrong: §5.1.3 says the fill range -// comes from "the Location filter inside FILL_PARAMETERS, or the subscription's -// Location filter if it is omitted", and only "when the subscription has no -// Location filter, or the LOCATION_FILTER inside FILL_PARAMETERS is -// zero-length, the fill range is the entire track". So an *omitted* inner -// filter here would inherit the subscription's Next Object filter and select an -// empty range — no fill stream at all. The whole track needs the explicit -// zero-length filter. +// TestSubscribe_FillWholeTrack: a zero-length LOCATION_FILTER inside +// FILL_PARAMETERS fills the whole track up to Largest Object (§5.1.3, §5.1.6); +// an omitted one would inherit the subscription's filter instead. func TestSubscribe_FillWholeTrack(t *testing.T) { t.Parallel() pubSess, _, publisherAlias := publishAndCache(t) @@ -439,12 +411,9 @@ func TestSubscribe_FillWholeTrack(t *testing.T) { } } -// TestSubscribe_FillInheritsSubscriptionFilter pins the fallback in §5.1.3: with -// no LOCATION_FILTER inside FILL_PARAMETERS the fill range is the subscription's -// own filter. Paired with a Next Object subscription that range is empty, so no -// fill stream opens — which is the correct reading of "or the subscription's -// Location filter if it is omitted", and NOT the same as the zero-length filter -// that means the whole track. +// TestSubscribe_FillInheritsSubscriptionFilter: with no LOCATION_FILTER inside +// FILL_PARAMETERS the fill range is the subscription's own (§5.1.3), which for a +// Next Object filter is empty, so no fill stream opens. func TestSubscribe_FillInheritsSubscriptionFilter(t *testing.T) { t.Parallel() pubSess, _, publisherAlias := publishAndCache(t) @@ -477,16 +446,9 @@ func TestSubscribe_FillInheritsSubscriptionFilter(t *testing.T) { } } -// TestSubscribe_RequestUpdateOpensSecondFill pins the REQUEST_UPDATE half of -// §5.1.3, which nothing else reaches: "As a result of REQUEST_UPDATE, a -// subscription can have multiple fill fetch streams open at once, each -// identified by its Request ID; opening a new fill fetch stream does not -// implicitly cancel any previously opened fill fetch streams." -// -// The initial fill is keyed to the SUBSCRIBE's Request ID and the second to the -// REQUEST_UPDATE's own. Keying both to the subscription would strand the -// subscriber's demux handler for the second fill, and no other test would -// notice. +// TestSubscribe_RequestUpdateOpensSecondFill: FILL_PARAMETERS on a +// REQUEST_UPDATE opens a second fill fetch stream keyed to the update's own +// Request ID, without cancelling the first (§5.1.3). func TestSubscribe_RequestUpdateOpensSecondFill(t *testing.T) { t.Parallel() pubSess, _, publisherAlias := publishAndCache(t) @@ -530,14 +492,9 @@ func TestSubscribe_RequestUpdateOpensSecondFill(t *testing.T) { } } -// TestSubscribe_FillNotOpenedWhileForwardPaused pins §5.1.3.1's two negatives: -// "FILL_PARAMETERS carried while Forward State is 0 opens no fill fetch stream. -// Transitioning to Forward State 1 without re-sending FILL_PARAMETERS does not -// open one either." -// -// Both are invisible without this test — a relay that ignored Forward State, or -// that retained FILL_PARAMETERS as subscription state and replayed it on -// resume, would pass every other fill test in the suite. +// TestSubscribe_FillNotOpenedWhileForwardPaused: FILL_PARAMETERS while Forward +// State is 0 opens no fill stream, nor does resuming without re-sending it +// (§5.1.3.1). func TestSubscribe_FillNotOpenedWhileForwardPaused(t *testing.T) { t.Parallel() pubSess, _, publisherAlias := publishAndCache(t) @@ -598,11 +555,8 @@ func acceptFillStream(t *testing.T, sess *session.Session) uint64 { return fs.Header.RequestID } -// TestSubscribe_NoFillParametersOpensNoStream pins the other half of §10.2.15: -// "a subscription with no FILL_PARAMETERS opens none". Presence of the -// parameter is the whole request signal, so a plain SUBSCRIBE must not produce -// a fetch stream — a subscriber that gets one would mistake filled Objects for -// live ones. +// TestSubscribe_NoFillParametersOpensNoStream: a SUBSCRIBE without +// FILL_PARAMETERS opens no fetch stream (§10.2.15). func TestSubscribe_NoFillParametersOpensNoStream(t *testing.T) { t.Parallel() pubSess, _, publisherAlias := publishAndCache(t) @@ -629,19 +583,13 @@ func TestSubscribe_NoFillParametersOpensNoStream(t *testing.T) { } } -// TestFetch_PartialRangeCarriesPropertiesAndPriority verifies the -// FetchObject encoding includes Properties (when present) and -// PublisherPriority delta. We publish two objects with distinct -// priorities, FETCH them, and confirm the decoded objects carry the -// publisher's per-subgroup priority. +// TestFetch_PartialRangeCarriesPriority: a FETCH of part of a group returns +// both of its Objects. func TestFetch_PartialRangeCarriesPriority(t *testing.T) { t.Parallel() pubSess, _, publisherAlias := publishAndCache(t) - // One subgroup, two objects — they share the subgroup's - // publisher priority. For this test we just verify the field - // round-trips at all; the inherited §12.4 default is pinned by - // TestDefaultPriority_Subgroup. + // The inherited §12.4 default is pinned by TestDefaultPriority_Subgroup. publishObjects(t, pubSess, publisherAlias, 7, 2) time.Sleep(50 * time.Millisecond) @@ -659,10 +607,8 @@ func TestFetch_PartialRangeCarriesPriority(t *testing.T) { } } -// TestFetch_OKEndLocationCappedToWatermark pins §10.14: a FETCH whose requested -// range extends beyond the relay's Largest Object has FETCH_OK.EndLocation -// capped at Largest Object. draft-20 made both the request range and this field -// inclusive, so the cap is Largest itself rather than draft-19's Largest + 1. +// TestFetch_OKEndLocationCappedToWatermark: FETCH_OK's inclusive EndLocation is +// capped at Largest Object when the request reaches past it (§10.14). func TestFetch_OKEndLocationCappedToWatermark(t *testing.T) { t.Parallel() pubSess, _, publisherAlias := publishAndCache(t) @@ -685,16 +631,9 @@ func TestFetch_OKEndLocationCappedToWatermark(t *testing.T) { } } -// TestSubscribe_FillOpensNoStreamOnEmptyTrack pins §5.1.3's "If the fill range -// is empty, or starts after Largest Object, the publisher does not open a fill -// fetch stream." -// -// The track exists — a publisher has claimed it — so SUBSCRIBE succeeds and -// simply carries no LARGEST_OBJECT. draft-19 answered the equivalent Joining -// FETCH with INVALID_RANGE; a fill has no REQUEST_ERROR of its own, so the -// correct signal is the absence of a stream. This is the case the fea80fc -// outage surfaced, where an upstream watermark was dropped rather than the -// track being genuinely empty. +// TestSubscribe_FillOpensNoStreamOnEmptyTrack: on a track with no Objects the +// fill range is empty, so no fill stream opens and SUBSCRIBE_OK has no +// LARGEST_OBJECT (§5.1.3). func TestSubscribe_FillOpensNoStreamOnEmptyTrack(t *testing.T) { t.Parallel() pubSess, _, _ := publishAndCache(t) // track published, no objects written @@ -726,15 +665,8 @@ func TestSubscribe_FillOpensNoStreamOnEmptyTrack(t *testing.T) { } } -// TestSubscribe_FillRelativeStartClampsAtOrigin pins the clamp draft-20 chose -// where draft-19 rejected. §5.1.2: "If a relative start group results in a -// computed absolute group less than 0, the computed value is set to 0." -// -// draft-19's Relative Joining FETCH answered INVALID_RANGE when the count of -// groups back exceeded the largest group; draft-20 clamps to the origin -// instead, so a subscriber asking for more history than exists gets all of it -// rather than an error. Reaching for group 5 back from group 0 must therefore -// fill from {0,0}, not fail. +// TestSubscribe_FillRelativeStartClampsAtOrigin: a relative fill start before +// group 0 clamps to {0,0} rather than failing (§5.1.2). func TestSubscribe_FillRelativeStartClampsAtOrigin(t *testing.T) { t.Parallel() pubSess, _, publisherAlias := publishAndCache(t) @@ -780,12 +712,9 @@ func TestSubscribe_FillRelativeStartClampsAtOrigin(t *testing.T) { } } -// TestFetch_ObjectIDDeltaEncoding pins the relay's FETCH encoder to -// §11.4.4.1 on the wire rather than through a decoder sharing its reading: -// without a Group ID Delta "the Object ID is the prior Object's ID plus the -// Object ID Delta" (no +1, unlike the subgroup rule), and a consecutive ID is -// sent by omitting the field. Objects 0, 1 and 5 of one group must therefore -// encode as: absolute 0, delta omitted, delta 4. +// TestFetch_ObjectIDDeltaEncoding: on the wire, Objects 0, 1 and 5 of one group +// encode as absolute 0, delta omitted, delta 4 — no +1 without a Group ID Delta +// (§11.4.4.1). func TestFetch_ObjectIDDeltaEncoding(t *testing.T) { t.Parallel() pubSess, alias := newCam1Publisher(t, nil) diff --git a/pkg/relay/handler_fetch_unknown_test.go b/pkg/relay/handler_fetch_unknown_test.go index fdd7b8d8..8c958357 100644 --- a/pkg/relay/handler_fetch_unknown_test.go +++ b/pkg/relay/handler_fetch_unknown_test.go @@ -12,12 +12,10 @@ import ( "github.com/floatdrop/moq-go/pkg/relay" ) -// unknownGapTopology wires the stitch-test topology (upstream publisher → -// relay ← live subscriber) with a configurable upstream FETCH answer, and -// returns a fetch-only downstream client. The upstream pushes single-object -// groups [liveLo, liveHi] on the relay's on-demand SUBSCRIBE, so the relay's -// cache floor lands at liveLo and a downstream FETCH from group 0 always has -// a below-floor portion to account for. +// unknownGapTopology wires upstream publisher → relay ← live subscriber, with +// onFetch answering the relay's upstream FETCH, and returns a fetch-only +// client. The upstream pushes single-object groups liveLo..liveHi, so the cache +// floor is liveLo and a FETCH from group 0 has a below-floor part. func unknownGapTopology( t *testing.T, ns wire.TrackNamespace, @@ -79,14 +77,9 @@ func unknownGapTopology( // a fetch that lands early gets a legitimately different answer, with the // unknown-range floor sitting wherever the cache happened to reach. // - // Wait on the RELAY's watermark rather than on this subscriber receiving - // everything. The cache is the precondition the callers actually depend on, - // and a subscriber is the wrong proxy for it: the relay may drop or reset a - // lagging one (§3.3.4), so under load "the subscriber saw every group" can - // stay false forever while the cache is perfectly well populated. An - // earlier version of this barrier asserted exactly that and timed out under - // -race. TRACK_STATUS_OK carries LARGEST_OBJECT (§10.2.17), so the relay - // answers the question directly. + // Wait on the relay's watermark (TRACK_STATUS, §10.2.17), not on the + // subscriber: the relay may drop a lagging subscriber (§3.3.4) while the + // cache is fully populated. go drainAll(t.Context(), live) fetchClient := dialAnotherClient(t, upSess) @@ -94,11 +87,9 @@ func unknownGapTopology( return fetchClient } -// TestFetch_UnknownRangeMarkerWhenUpstreamRejects pins the §11.4.4 truthfulness -// fix: when the below-floor portion of a FETCH cannot be stitched (the upstream -// rejects the FETCH), the relay must not leave it as a plain gap — a gap in a -// FIN-terminated response asserts non-existence — but cover it with an End of -// Unknown Range marker (0x10C) preceding the cached objects. +// TestFetch_UnknownRangeMarkerWhenUpstreamRejects: a below-floor part the +// upstream refuses to serve is covered by an End of Unknown Range marker before +// the cached Objects, not left as a gap (§11.4.4). func TestFetch_UnknownRangeMarkerWhenUpstreamRejects(t *testing.T) { video := ns("video") name := []byte("cam-unknown") @@ -173,10 +164,8 @@ func TestFetch_UnknownRangeMarkerDescending(t *testing.T) { } } -// TestFetch_PreservesUpstreamUnknownMarker pins marker propagation across a -// relay hop: the upstream's stitch response declares groups 0..2 unknown with -// its own 0x10C marker and serves the rest; the relay must re-emit that -// marker to the downstream fetcher instead of flattening it into a gap. +// TestFetch_PreservesUpstreamUnknownMarker: the upstream's own End of Unknown +// Range marker is re-emitted downstream, not flattened into a gap. func TestFetch_PreservesUpstreamUnknownMarker(t *testing.T) { video := ns("video") name := []byte("cam-propagate") @@ -241,12 +230,9 @@ func TestFetch_PreservesUpstreamUnknownMarker(t *testing.T) { } } -// TestFetch_UnknownMarkerWhenUpstreamCapsEndLocation pins the clean-FIN cap -// rule: a FIN-terminated upstream response asserts its gaps only up to the -// FETCH_OK EndLocation (§11.4.4). Here the upstream serves groups 0..2 and -// caps EndLocation at {2,1}, so the relay knows nothing about groups 3..4 — -// it must insert an unknown marker between the stitched head and the cached -// tail rather than let that gap read as non-existence. +// TestFetch_UnknownMarkerWhenUpstreamCapsEndLocation: a FIN'd upstream +// response asserts its gaps only up to its FETCH_OK EndLocation (§11.4.4), so +// the groups past it are marked unknown. func TestFetch_UnknownMarkerWhenUpstreamCapsEndLocation(t *testing.T) { video := ns("video") name := []byte("cam-capped") @@ -298,13 +284,9 @@ func TestFetch_UnknownMarkerWhenUpstreamCapsEndLocation(t *testing.T) { } } -// TestFetch_DiscardsOutOfRangeUpstreamElements pins the trust boundary on -// ingested stitch responses: the relay re-serializes upstream elements — -// marker Locations even become the downstream delta encoder's prior state — -// so an element outside the requested sub-range (here a 0x10C marker at -// group 7, beyond the below-floor range 0..4) must disqualify the response. -// The relay falls back to declaring the whole sub-range unknown instead of -// letting the rogue Location corrupt downstream Group IDs. +// TestFetch_DiscardsOutOfRangeUpstreamElements: an upstream element outside the +// requested sub-range disqualifies the response; the relay marks the whole +// sub-range unknown instead. func TestFetch_DiscardsOutOfRangeUpstreamElements(t *testing.T) { video := ns("video") name := []byte("cam-rogue") diff --git a/pkg/relay/handler_fetch_upstream_fail_test.go b/pkg/relay/handler_fetch_upstream_fail_test.go index 8baad9ae..4ebcdbe0 100644 --- a/pkg/relay/handler_fetch_upstream_fail_test.go +++ b/pkg/relay/handler_fetch_upstream_fail_test.go @@ -13,30 +13,10 @@ import ( "github.com/floatdrop/moq-go/pkg/relay" ) -// TestFetch_UpstreamOutcomeDecidesGapOrUnknown pins the single most dangerous -// decision in the §9.4 stitching path: whether a hole in a stitched FETCH -// response means "these objects do not exist" or "this relay could not find -// out". -// -// The two are different messages on the wire and different truths to a client. -// A plain gap in a FIN-terminated FETCH response is an assertion of -// non-existence (§9.1); a §11.4.4.2 End of Unknown Range marker (0x10C) says -// the range is undetermined and may be retried. Encoding the second as the -// first tells a subscriber that content it could have fetched does not exist, -// permanently and silently — nothing logs, nothing errors, and the response is -// perfectly well-formed either way. -// -// Every case here is the SAME topology, differing only in how the upstream -// behaves, so what they pin is the mapping from upstream outcome to downstream -// encoding and nothing else. -// -// Note for anyone editing the serve path: the `len(upstreamObjs) == 0` early -// return in stitchedFetchObjects is NOT what makes the authoritative-gap case -// work. Deleting it changes nothing observable, because merging an empty -// upstream slice with the cached one yields the cached one — it is a shortcut -// past the merge, not a decision. The decision lives in fetchUpstreamRange, -// which returns an empty slice for a clean empty FIN and at least one marker -// for every unknown outcome; mutating THAT is what turns these red. +// TestFetch_UpstreamOutcomeDecidesGapOrUnknown: how the upstream answers the +// stitch FETCH decides the encoding of the uncached sub-range (§9.4): a clean +// empty FIN is a plain gap, asserting non-existence (§9.1); a failure is an End +// of Unknown Range marker; a timeout an End of Timed-Out Range (§11.4.4.2). func TestFetch_UpstreamOutcomeDecidesGapOrUnknown(t *testing.T) { t.Parallel() for _, tc := range []struct { @@ -113,20 +93,10 @@ func TestFetch_UpstreamOutcomeDecidesGapOrUnknown(t *testing.T) { } } -// TestFetch_DescendingCappedUpstreamFallsBackToWholeUnknown covers the -// order-specific fallback in fetchUpstreamRange. -// -// When the upstream caps its FETCH_OK EndLocation below the sub-range the -// relay asked for (§10.13 lets it: End beyond its own Largest), the remainder -// is undetermined. Ascending order can say so precisely by appending one -// marker after the objects. Descending cannot: the unknown remainder precedes -// every object in descending stream order, and a leading marker cannot in -// general be followed by a same-group object with a lower ID, so the encoding -// would misplace it. The only encodable truth left is "the whole sub-range is -// unknown". -// -// This matters because the wrong branch here is not a crash — it is a -// correctly-framed response whose marker claims the wrong range. +// TestFetch_DescendingCappedUpstreamFallsBackToWholeUnknown: when the upstream +// caps FETCH_OK below the requested sub-range (§10.13), a descending response +// marks the whole sub-range unknown, anchored at its start, since a trailing +// marker cannot be placed. func TestFetch_DescendingCappedUpstreamFallsBackToWholeUnknown(t *testing.T) { t.Parallel() objs := runStitch(t, stitchOpts{ @@ -177,15 +147,8 @@ func TestFetch_DescendingCappedUpstreamFallsBackToWholeUnknown(t *testing.T) { } } -// TestFetch_StitchedObjectKeepsDatagramForwardingPreference pins the §11.4.4.1 -// Datagram bit surviving the relay hop. -// -// The bit records the Forwarding Preference the object was PUBLISHED with, and -// a FETCH response is supposed to report that faithfully even though the -// response itself always travels on a stream. Dropping it on the stitching -// path would silently rewrite history for exactly the objects that came from -// another relay — a subscriber comparing a stitched range against a live one -// would see the same object described two different ways. +// TestFetch_StitchedObjectKeepsDatagramForwardingPreference: a stitched +// upstream Object keeps its Datagram bit (§11.4.4.1). func TestFetch_StitchedObjectKeepsDatagramForwardingPreference(t *testing.T) { t.Parallel() objs := runStitch(t, stitchOpts{ @@ -282,10 +245,9 @@ func replyThen(end func(*session.OutgoingFetchStream)) func(*session.Session, *s } } -// runStitch builds the stitching topology from -// TestFetch_StitchesEvictedRangeFromUpstream — an upstream feeding a live tail -// the relay caches, plus a downstream FETCH spanning below the eviction floor -// — and returns the decoded objects of the stitched response. +// runStitch runs the stitch topology of TestFetch_StitchesEvictedRangeFromUpstream +// (an upstream feeding a cached live tail, and a FETCH reaching below the +// eviction floor) and returns the stitched response's elements. func runStitch(t *testing.T, opts stitchOpts) []*session.DecodedFetchObject { t.Helper() upSess, teardown := connectRelay(t, relay.Config{}) @@ -443,11 +405,8 @@ func fetchStitched( return readFetchResponse(t, sess, message.GroupOrderAscending, 5*time.Second), true } -// stitchMarker names which §11.4.4.2 outcome a stitched response encoded for -// the sub-range the relay could not answer from cache. draft-20 split what used -// to be a single "unknown" outcome in two: a timeout is now reported as a -// Timed-Out gap (§10.2.5), leaving End of Unknown Range for the cases where no -// source could vouch for the objects at all. +// stitchMarker names the §11.4.4.2 encoding a stitched response used for the +// sub-range it could not answer from cache. type stitchMarker int const ( @@ -468,6 +427,7 @@ func (m stitchMarker) String() string { return "an unknown marker kind" } +// stitchMarkerOf reports the first End of Range marker kind in objs. func stitchMarkerOf(objs []*session.DecodedFetchObject) stitchMarker { for _, o := range objs { switch { @@ -493,20 +453,9 @@ func stitchedGroups(objs []*session.DecodedFetchObject) []uint64 { return groups } -// TestFetch_RangeFilterKeepsTimedOutMarker pins that the §5.1.4 Range Filter -// pass does not eat §11.4.4.2 end-of-range markers. -// -// A marker is not an Object: §11.4.4.2 gives it no Subgroup ID, Priority or -// Properties, so handing one to MatchesObject tests {0, ObjectID, 0, nil} -// against the filter and any non-trivial filter rejects it. Dropping it turns -// the span into a plain gap, and §10.13 defines a gap in a FIN-terminated -// response as "objects that do not exist" — so a subscriber records permanent -// non-existence for a range that merely timed out, and never retries. -// -// draft-20 is what made this reachable: before End of Timed-Out Range existed, -// the only marker on this path was End of Unknown Range, which the filter pass -// special-cased by name. The fix is to ask whether the element is a marker at -// all rather than naming the kinds. +// TestFetch_RangeFilterKeepsTimedOutMarker: the Range Filter pass (§5.1.4) does +// not drop End of Range markers (§11.4.4.2), which carry nothing to match; +// dropping one would turn "timed out" into "does not exist" (§10.13). func TestFetch_RangeFilterKeepsTimedOutMarker(t *testing.T) { t.Parallel() diff --git a/pkg/relay/handler_namespace_fault_test.go b/pkg/relay/handler_namespace_fault_test.go index 259ae6f2..853b635c 100644 --- a/pkg/relay/handler_namespace_fault_test.go +++ b/pkg/relay/handler_namespace_fault_test.go @@ -13,11 +13,8 @@ import ( ) // requestStreamWriteFault fails every relay write on a client's first request -// stream, and closes fired the first time it does. Tests wait on fired rather -// than on the client call it breaks: these faults hit the REQUEST_OK, which -// [session.Request.Reply] writes with no teardown of its own, so the client is -// left hanging and the hook is the only precise signal that the branch under -// test has run. +// stream and closes fired the first time. The failed write is the REQUEST_OK, +// which leaves the client hanging, so tests wait on fired instead. func requestStreamWriteFault() (fault sessiontest.FaultFunc, fired <-chan struct{}, writes func() int) { var ( mu sync.Mutex @@ -53,15 +50,9 @@ func awaitFault(t *testing.T, fired <-chan struct{}) { } } -// TestPublishNamespace_FailedRequestOKIsNotAdvertised pins what -// handlePublishNamespace does when it cannot deliver the REQUEST_OK: it -// returns, which runs the deferred UnregisterPublisher and — crucially — -// skips the §6.2 NAMESPACE fanout below it. -// -// That ordering is the whole point. A publisher that never received its -// REQUEST_OK does not believe it is publishing, so advertising its namespace -// to subscribers would announce a namespace nobody is serving. Deleting the -// `return` leaves the suite green everywhere else while doing exactly that. +// TestPublishNamespace_FailedRequestOKIsNotAdvertised: a PUBLISH_NAMESPACE whose +// REQUEST_OK could not be written is unregistered and never advertised to +// subscribers (§6.2). func TestPublishNamespace_FailedRequestOKIsNotAdvertised(t *testing.T) { t.Parallel() fault, fired, _ := requestStreamWriteFault() @@ -115,14 +106,9 @@ func TestPublishNamespace_FailedRequestOKIsNotAdvertised(t *testing.T) { } } -// TestSubscribeNamespace_FailedRequestOKLeavesNoSubscriber pins the other -// half of the reply-before-register ordering handleSubscribeNamespace -// documents: because the REQUEST_OK is written BEFORE RegisterSubscriber, a -// failed write must leave no subscriber behind at all. -// -// The assertion is that the relay never writes to that stream again. A -// registered-anyway subscriber would be picked up by MatchSubscribers and get -// the NAMESPACE fanout, which the hook would see as a second write. +// TestSubscribeNamespace_FailedRequestOKLeavesNoSubscriber: a +// SUBSCRIBE_NAMESPACE whose REQUEST_OK could not be written leaves no +// subscriber: the relay never writes to that stream again. func TestSubscribeNamespace_FailedRequestOKLeavesNoSubscriber(t *testing.T) { t.Parallel() fault, fired, writes := requestStreamWriteFault() diff --git a/pkg/relay/handler_publish_alias_window_test.go b/pkg/relay/handler_publish_alias_window_test.go index 828e3c64..40f56a1e 100644 --- a/pkg/relay/handler_publish_alias_window_test.go +++ b/pkg/relay/handler_publish_alias_window_test.go @@ -11,26 +11,10 @@ import ( "github.com/floatdrop/moq-go/pkg/relay" ) -// TestPublish_TrackEntryPrecedesAliasRouting pins the ordering issue #85 turned -// on, on the PUBLISH path. -// -// handlePublish registers the publisher's §11.1 Track Alias, which makes it -// resolve on inbound data streams, and only creates the track entry later in -// WriteMessageAfterSetup. A subgroup stream arriving in between reaches -// runFanout, resolves its alias, finds no entry, and is reset — losing those -// Objects from the cache and from live fanout alike, permanently, with nothing -// above DEBUG to say so. -// -// §10.11 makes this the expected sequence rather than a race a publisher has to -// lose: with FORWARD "omitted or equal to 1, the publisher will start -// transmitting objects immediately, possibly before PUBLISH_OK" — that is, -// before AddUpstream has run at all. -// -// So the Group MUST be written before Publish returns: Publish returns on -// REQUEST_OK, which the relay writes after AddUpstream, by which time the -// window has shut. The hook drives the ordering rather than a sleep — it -// reports the alias routable, waits for the write, then holds the window open -// while the relay routes (or drops) the stream. +// TestPublish_TrackEntryPrecedesAliasRouting: a publisher may send Objects +// before PUBLISH_OK (§10.11), so a subgroup routed by its alias (§11.1) before +// the relay created the track entry must not be lost. The hook holds that +// window open. func TestPublish_TrackEntryPrecedesAliasRouting(t *testing.T) { video := ns("video") name := []byte("cam-publish-alias-window") @@ -97,16 +81,8 @@ func TestPublish_TrackEntryPrecedesAliasRouting(t *testing.T) { waitRelayLargest(t, probe, video, name, groupID, 0) } -// TestPublish_RejectedAliasLeavesTrackUnknown pins the other half of the entry -// being created before the request is known to succeed: when the PUBLISH is -// then rejected, the speculative entry must not survive it. -// -// handleFetch reads "an entry exists" as "the track is known", so a lingering -// empty entry answers a FETCH with INVALID_RANGE ("no objects published", the -// §10.13 rule) where §10.6 wants DOES_NOT_EXIST — "the track or namespace is -// not available at the publisher". Those mean different things to a client -// deciding whether to retry, and nothing would reclaim the entry until an -// unrelated session teardown swept it. +// TestPublish_RejectedAliasLeavesTrackUnknown: a rejected PUBLISH leaves no +// track entry behind, so a FETCH is DOES_NOT_EXIST (§10.6), not INVALID_RANGE. func TestPublish_RejectedAliasLeavesTrackUnknown(t *testing.T) { video := ns("video") const alias = uint64(91) diff --git a/pkg/relay/handler_publish_fault_test.go b/pkg/relay/handler_publish_fault_test.go index b971b1ce..1977de32 100644 --- a/pkg/relay/handler_publish_fault_test.go +++ b/pkg/relay/handler_publish_fault_test.go @@ -12,25 +12,10 @@ import ( "github.com/floatdrop/moq-go/pkg/relay" ) -// TestPublish_FailedRequestOKRollsBackRegistration pins both halves of the -// rollback in handlePublish's REQUEST_OK failure branch. -// -// The registration is deliberately performed inside the broker's setup -// closure, so by the time the REQUEST_OK write fails the track already has an -// upstream and the publisher's §11.1 Track Alias is already claimed — on a -// session the publisher does not believe is publishing anything. Both must be -// undone, and they fail differently: -// -// - A leaked alias is claimed for the life of the session, so the publisher -// can never use that alias for another track. -// - A leaked upstream advertises a track no publisher is feeding, so -// subscribers are accepted onto a track that will never produce an object. -// -// Probing them takes some care. Re-offering the SAME track under the same -// alias proves nothing, because RegisterInboundTrackAlias accepts (and counts) -// a registration whose alias still maps to the same track key — an earlier -// version of this test -// did exactly that and stayed green with both rollback lines deleted. +// TestPublish_FailedRequestOKRollsBackRegistration: a PUBLISH whose REQUEST_OK +// could not be written releases its Track Alias (§11.1) and its upstream. The +// probe uses another track, since re-offering the same one under the same alias +// is accepted either way. func TestPublish_FailedRequestOKRollsBackRegistration(t *testing.T) { t.Parallel() fault, fired, _ := requestStreamWriteFault() diff --git a/pkg/relay/handler_subscribe_alias_window_test.go b/pkg/relay/handler_subscribe_alias_window_test.go index 9bcdff8f..c72e624f 100644 --- a/pkg/relay/handler_subscribe_alias_window_test.go +++ b/pkg/relay/handler_subscribe_alias_window_test.go @@ -11,21 +11,10 @@ import ( "github.com/floatdrop/moq-go/pkg/relay" ) -// TestSubscribeUpstream_TrackEntryPrecedesAliasRouting is the SUBSCRIBE -// counterpart of TestPublish_TrackEntryPrecedesAliasRouting. -// -// session.Subscribe registers the SUBSCRIBE_OK's §11.1 Track Alias inside its -// own response handler, so the alias resolves on inbound data streams the -// instant it returns — while AddUpstream, which would otherwise be the first -// thing to create the track entry, is still several statements away. A -// subgroup stream arriving in between reaches runFanout, resolves its alias, -// finds no entry, and is reset: those Objects are lost from the cache and from -// live fanout alike, permanently, with nothing above DEBUG to say so. -// -// The publisher writes its first Group immediately after SUBSCRIBE_OK, so the -// Group that loses is the oldest — which is what made -// TestFetch_UnknownRangeMarkerDescending fail on CI while passing everywhere -// else: it asserts on a complete tail and the floor kept going missing. +// TestSubscribeUpstream_TrackEntryPrecedesAliasRouting: the SUBSCRIBE +// counterpart of TestPublish_TrackEntryPrecedesAliasRouting. The SUBSCRIBE_OK's +// alias (§11.1) routes as soon as session.Subscribe returns, so the track entry +// must exist by then or the first Group is lost. func TestSubscribeUpstream_TrackEntryPrecedesAliasRouting(t *testing.T) { video := ns("video") name := []byte("cam-subscribe-alias-window") @@ -57,21 +46,9 @@ func TestSubscribeUpstream_TrackEntryPrecedesAliasRouting(t *testing.T) { }, "relay never served the full tail; the oldest Group was dropped in the alias window") } -// TestFetch_UnconfirmedTrackIsNotKnown pins the cost of creating that entry -// before the upstream round trip that confirms the track exists. -// -// For the duration of the round trip an entry stands for a track nobody has -// vouched for. handleFetch used to read bare existence as "track known", fall -// through to the §10.13 "no Objects have been published" rule, and answer -// INVALID_RANGE — "the range you asked for cannot be satisfied" — where §10.6 -// DOES_NOT_EXIST, "the track or namespace is not available at the publisher", -// is the truthful answer. A client deciding whether to retry needs them apart. -// -// The upstream here advertises the namespace and then never answers the -// relay's SUBSCRIBE, so the entry sits unvouched for as long as the test cares -// to look. Every answer over that window must be DOES_NOT_EXIST — polling -// rather than a single probe because the first FETCH may land before the entry -// exists, which is DOES_NOT_EXIST for the uninteresting reason. +// TestFetch_UnconfirmedTrackIsNotKnown: while the upstream has not answered the +// relay's SUBSCRIBE, a FETCH of the track is DOES_NOT_EXIST (§10.6), not +// INVALID_RANGE (§10.13). func TestFetch_UnconfirmedTrackIsNotKnown(t *testing.T) { video := ns("video") name := []byte("cam-never-answered") diff --git a/pkg/relay/harness_test.go b/pkg/relay/harness_test.go index 57f56bb6..03f0a2ef 100644 --- a/pkg/relay/harness_test.go +++ b/pkg/relay/harness_test.go @@ -4,6 +4,7 @@ import ( "context" "net" "runtime" + "slices" "sync" "sync/atomic" "testing" @@ -233,6 +234,7 @@ type clientSessionTracker struct { sessions []*session.Session } +// newClientSessionTracker returns an empty tracker. func newClientSessionTracker() *clientSessionTracker { return &clientSessionTracker{} } @@ -245,7 +247,7 @@ func (t *clientSessionTracker) add(s *session.Session) { func (t *clientSessionTracker) closeAll() { t.mu.Lock() - sessions := append([]*session.Session(nil), t.sessions...) + sessions := slices.Clone(t.sessions) t.sessions = nil t.mu.Unlock() for _, s := range sessions { diff --git a/pkg/relay/helpers_fetch_test.go b/pkg/relay/helpers_fetch_test.go new file mode 100644 index 00000000..1df96f88 --- /dev/null +++ b/pkg/relay/helpers_fetch_test.go @@ -0,0 +1,378 @@ +package relay_test + +import ( + "context" + "errors" + "fmt" + "io" + "math" + "testing" + "time" + + "github.com/floatdrop/moq-go/pkg/moqt/message" + "github.com/floatdrop/moq-go/pkg/moqt/session" + "github.com/floatdrop/moq-go/pkg/moqt/wire" +) + +// FETCH helpers: request ranges, response readers and decoders, and the +// TRACK_STATUS watermark wait that FETCH tests synchronise on. + +// waitRelayLargest polls TRACK_STATUS until the relay reports Largest Object +// {wantGroup, wantObject} (§10.2.17), failing after 10s. +func waitRelayLargest( + t *testing.T, + sess *session.Session, + ns wire.TrackNamespace, + name []byte, + wantGroup, wantObject uint64, +) { + t.Helper() + deadline := time.Now().Add(10 * time.Second) + var last string + for { + req, err := sess.TrackStatus(t.Context(), &message.TrackStatus{ + Namespace: ns, + Name: name, + }) + if err == nil { + p, ok := req.OK.Parameters.Find(message.ParamLargestObject) + _ = req.Close() + if ok && p.Group == wantGroup && p.Object == wantObject { + return + } + last = fmt.Sprintf("largest={%d,%d} present=%t", p.Group, p.Object, ok) + } else { + last = err.Error() + } + if time.Now().After(deadline) { + t.Fatalf("relay never reported largest {%d,%d}: %s", wantGroup, wantObject, last) + } + time.Sleep(10 * time.Millisecond) + } +} + +// fetchRangeFilter is the LOCATION_FILTER (§5.1.2) for the inclusive FETCH +// range [start, endIncl]; an end Object of MaxUint64 means the whole end group. +func fetchRangeFilter(start, endIncl message.Location) message.Parameter { + if endIncl.Object == math.MaxUint64 { + return message.AbsoluteRangeFilter(start, endIncl.Group-start.Group) + } + return message.AbsoluteRangeObjectFilter(start, endIncl.Group-start.Group, endIncl.Object) +} + +// fetchRequestRange returns the inclusive range a FETCH's LOCATION_FILTER asks +// for; ok is false when the filter is absent or open-ended. +func fetchRequestRange(m *message.Fetch) (start, end message.Location, ok bool) { + f, err := message.LocationFilterFromParam(m.Parameters) + if err != nil || f == nil { + return start, end, false + } + end, hasEnd := f.End() + if !hasEnd { + return start, end, false + } + return message.Location{Group: f.StartGroup, Object: f.StartObject}, end, true +} + +// fetchOKEnd is the end of [fetchRequestRange], for fake upstreams that echo it +// in FETCH_OK. +func fetchOKEnd(m *message.Fetch) message.Location { + _, end, _ := fetchRequestRange(m) + return end +} + +// decodedFetchObject is one Object of a FETCH response with absolute IDs. +type decodedFetchObject struct { + group, object uint64 + payload []byte +} + +// fetchAndDrain FETCHes [start, endIncl] in order and reads the response to +// FIN, returning the FETCH_OK and the Objects decoded by [decodeFetchStream]. +func fetchAndDrain( + t *testing.T, + sess *session.Session, + ns wire.TrackNamespace, + name []byte, + start, endIncl message.Location, + order message.GroupOrder, + extra ...message.Parameter, +) (*message.FetchOK, []decodedFetchObject) { + t.Helper() + params := message.Parameters{message.GroupOrderParam(order), fetchRangeFilter(start, endIncl)} + params = append(params, extra...) + reqStream, err := sess.Fetch(t.Context(), &message.Fetch{ + Namespace: ns, + Name: name, + Parameters: params, + }) + if err != nil { + t.Fatalf("Fetch: %v", err) + } + t.Cleanup(func() { reqStream.Close() }) + + ds, err := sess.AcceptDataStream(t.Context()) + if err != nil { + t.Fatalf("AcceptDataStream: %v", err) + } + fs, isFetch := ds.(*session.IncomingFetchStream) + if !isFetch { + t.Fatalf("got %T, want *IncomingFetchStream", ds) + } + return reqStream.OK, decodeFetchStream(t, fs, order) +} + +// decodeFetchStream reads fs to EOF, reversing the §11.4.4.1 delta encoding +// itself rather than through the session decoder. +func decodeFetchStream(t *testing.T, fs *session.IncomingFetchStream, order message.GroupOrder) []decodedFetchObject { + t.Helper() + var ( + out []decodedFetchObject + prevGroup uint64 + prevObject uint64 + havePrev bool + descending = order == message.GroupOrderDescending + ) + for { + fo, err := fs.ReadObject() + if err != nil { + if errors.Is(err, io.EOF) { + return out + } + t.Fatalf("ReadObject: %v", err) + } + + var g, o uint64 + switch { + case !havePrev: + // The first Object's deltas are absolute. + g = fo.GroupIDDelta + o = fo.ObjectIDDelta + case fo.SerializationFlags&message.FetchFlagGroupIDDelta != 0: + if descending { + g = prevGroup - fo.GroupIDDelta - 1 + } else { + g = prevGroup + fo.GroupIDDelta + 1 + } + o = fo.ObjectIDDelta + default: + g = prevGroup + if fo.SerializationFlags&message.FetchFlagObjectIDDelta != 0 { + o = prevObject + fo.ObjectIDDelta // no +1 here + } else { + o = prevObject + 1 + } + } + + out = append(out, decodedFetchObject{group: g, object: o, payload: fo.ObjectPayload}) + prevGroup = g + prevObject = o + havePrev = true + } +} + +// fetchElem is one element of a FETCH response: an Object or a §11.4.4.2 +// End of Range marker. +type fetchElem struct { + Group, Object uint64 + Unknown bool // End of Unknown Range + Marker bool // any End of Range marker +} + +// tryFetchElems FETCHes [{0,0}, {lastGroup,0}] with params and returns the +// response elements, or nil when the FETCH is refused. +func tryFetchElems( + t *testing.T, + sess *session.Session, + ns wire.TrackNamespace, + name []byte, + lastGroup uint64, + params message.Parameters, +) []fetchElem { + t.Helper() + fetchReq, err := sess.Fetch(t.Context(), &message.Fetch{ + Namespace: ns, + Name: name, + Parameters: append(message.Parameters{ + fetchRangeFilter(message.Location{}, message.Location{Group: lastGroup, Object: 0}), + }, params...), + }) + if err != nil { + return nil // not yet serviceable: the caller retries + } + defer fetchReq.Close() + order := message.GroupOrderAscending + if p, ok := params.Find(message.ParamGroupOrder); ok { + order = message.GroupOrder(p.Byte) + } + return collectFetchElems(t, sess, order, 3*time.Second) +} + +// collectFetchElems reads the next FETCH response on sess to FIN within +// timeout and returns its elements in arrival order. +func collectFetchElems( + t *testing.T, + sess *session.Session, + order message.GroupOrder, + timeout time.Duration, +) []fetchElem { + t.Helper() + var elems []fetchElem + for _, obj := range readFetchResponse(t, sess, order, timeout) { + elems = append(elems, fetchElem{ + Group: obj.GroupID, + Object: obj.ObjectID, + Unknown: obj.EndOfUnknownRange, + Marker: obj.IsEndOfRange(), + }) + } + return elems +} + +// readFetchResponse accepts the next data stream on sess, requires a FETCH +// response, and decodes it to FIN within timeout, markers included. +func readFetchResponse( + t *testing.T, + sess *session.Session, + order message.GroupOrder, + timeout time.Duration, +) []*session.DecodedFetchObject { + t.Helper() + type result struct { + objs []*session.DecodedFetchObject + err error + } + ch := make(chan result, 1) + go func() { + ds, err := sess.AcceptDataStream(t.Context()) + if err != nil { + ch <- result{err: err} + return + } + fs, ok := ds.(*session.IncomingFetchStream) + if !ok { + ch <- result{err: errors.New("not a fetch stream")} + return + } + fs.GroupOrder = order + var r result + for { + obj, err := fs.ReadDecoded() + if err != nil { + if !errors.Is(err, io.EOF) { + r.err = err + } + ch <- r + return + } + r.objs = append(r.objs, obj) + } + }() + select { + case r := <-ch: + if r.err != nil { + t.Fatalf("reading FETCH response: %v", r.err) + } + return r.objs + case <-time.After(timeout): + t.Fatal("FETCH response did not arrive within deadline") + return nil + } +} + +// realGroups returns the groups of the elements that are not End of Unknown +// Range markers. +func realGroups(elems []fetchElem) []uint64 { + var out []uint64 + for _, e := range elems { + if !e.Unknown { + out = append(out, e.Group) + } + } + return out +} + +// objectGroups returns the groups of the elements that are Objects. +func objectGroups(elems []fetchElem) []uint64 { + var out []uint64 + for _, e := range elems { + if !e.Marker { + out = append(out, e.Group) + } + } + return out +} + +// groupsEqual reports whether got is exactly wantLo..wantHi in order. +func groupsEqual(got []uint64, wantLo, wantHi uint64) bool { + if uint64(len(got)) != wantHi-wantLo+1 { + return false + } + for i, g := range got { + if g != wantLo+uint64(i) { + return false + } + } + return true +} + +// fetchRange FETCHes [start, end] and returns the response's Objects; served +// is false when the FETCH was refused or no stream arrived within 2s. +func fetchRange( + t *testing.T, + sess *session.Session, + ns wire.TrackNamespace, + name []byte, + start, end message.Location, +) (objs []*session.DecodedFetchObject, served bool) { + t.Helper() + fr, err := sess.Fetch(t.Context(), &message.Fetch{ + Namespace: ns, Name: name, + Parameters: message.Parameters{fetchRangeFilter(start, end)}, + }) + if err != nil { + return nil, false + } + defer fr.Close() + ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) + defer cancel() + ds, err := sess.AcceptDataStream(ctx) + if err != nil { + return nil, false + } + fs, ok := ds.(*session.IncomingFetchStream) + if !ok { + return nil, false + } + for { + o, err := fs.ReadDecoded() + if err != nil { + return objs, true + } + if !o.IsEndOfRange() { + objs = append(objs, o) + } + } +} + +// writeFetchGroupRange writes one Object per group startG..endG to a FETCH +// response in ascending delta encoding (§11.4.4). +func writeFetchGroupRange(out *session.OutgoingFetchStream, startG, endG uint64) { + first := true + for g := startG; g <= endG; g++ { + fo := &message.FetchObject{} + fo.SerializationFlags |= message.FetchFlagGroupIDDelta | message.FetchFlagObjectIDDelta + if first { + fo.GroupIDDelta = g // absolute + fo.SerializationFlags |= message.FetchFlagPriority + first = false + } else { + fo.GroupIDDelta = 0 // the next group + } + fo.ObjectIDDelta = 0 + fo.ObjectPayload = []byte{byte('a' + g)} + if err := out.WriteObject(fo); err != nil { + return + } + } +} diff --git a/pkg/relay/helpers_test.go b/pkg/relay/helpers_test.go index ff653cec..2c701ffe 100644 --- a/pkg/relay/helpers_test.go +++ b/pkg/relay/helpers_test.go @@ -4,8 +4,6 @@ import ( "context" "errors" "fmt" - "io" - "math" "testing" "time" @@ -16,8 +14,9 @@ import ( "github.com/floatdrop/moq-go/pkg/relay" ) -// Shared helpers for the relay_test package. The relay itself is started by -// connectRelay (harness_test.go); these drive clients of it. +// Shared helpers for the relay_test package: they drive clients of a relay +// started by connectRelay (harness_test.go). FETCH helpers are in +// helpers_fetch_test.go. // ns builds a Track Namespace from its fields. func ns(fields ...string) wire.TrackNamespace { @@ -524,365 +523,3 @@ func waitFor(t *testing.T, d time.Duration, cond func() bool, msg string) { t.Fatal(msg) } } - -// waitRelayLargest polls TRACK_STATUS until the relay reports Largest Object -// {wantGroup, wantObject} (§10.2.17), failing after 10s. -func waitRelayLargest( - t *testing.T, - sess *session.Session, - ns wire.TrackNamespace, - name []byte, - wantGroup, wantObject uint64, -) { - t.Helper() - deadline := time.Now().Add(10 * time.Second) - var last string - for { - req, err := sess.TrackStatus(t.Context(), &message.TrackStatus{ - Namespace: ns, - Name: name, - }) - if err == nil { - p, ok := req.OK.Parameters.Find(message.ParamLargestObject) - _ = req.Close() - if ok && p.Group == wantGroup && p.Object == wantObject { - return - } - last = fmt.Sprintf("largest={%d,%d} present=%t", p.Group, p.Object, ok) - } else { - last = err.Error() - } - if time.Now().After(deadline) { - t.Fatalf("relay never reported largest {%d,%d}: %s", wantGroup, wantObject, last) - } - time.Sleep(10 * time.Millisecond) - } -} - -// FETCH. - -// fetchRangeFilter is the LOCATION_FILTER (§5.1.2) for the inclusive FETCH -// range [start, endIncl]; an end Object of MaxUint64 means the whole end group. -func fetchRangeFilter(start, endIncl message.Location) message.Parameter { - if endIncl.Object == math.MaxUint64 { - return message.AbsoluteRangeFilter(start, endIncl.Group-start.Group) - } - return message.AbsoluteRangeObjectFilter(start, endIncl.Group-start.Group, endIncl.Object) -} - -// fetchRequestRange returns the inclusive range a FETCH's LOCATION_FILTER asks -// for; ok is false when the filter is absent or open-ended. -func fetchRequestRange(m *message.Fetch) (start, end message.Location, ok bool) { - f, err := message.LocationFilterFromParam(m.Parameters) - if err != nil || f == nil { - return start, end, false - } - end, hasEnd := f.End() - if !hasEnd { - return start, end, false - } - return message.Location{Group: f.StartGroup, Object: f.StartObject}, end, true -} - -// fetchOKEnd is the end of [fetchRequestRange], for fake upstreams that echo it -// in FETCH_OK. -func fetchOKEnd(m *message.Fetch) message.Location { - _, end, _ := fetchRequestRange(m) - return end -} - -// decodedFetchObject is one Object of a FETCH response with absolute IDs. -type decodedFetchObject struct { - group, object uint64 - payload []byte -} - -// fetchAndDrain FETCHes [start, endIncl] in order and reads the response to -// FIN, returning the FETCH_OK and the Objects decoded by [decodeFetchStream]. -func fetchAndDrain( - t *testing.T, - sess *session.Session, - ns wire.TrackNamespace, - name []byte, - start, endIncl message.Location, - order message.GroupOrder, - extra ...message.Parameter, -) (*message.FetchOK, []decodedFetchObject) { - t.Helper() - params := message.Parameters{message.GroupOrderParam(order), fetchRangeFilter(start, endIncl)} - params = append(params, extra...) - reqStream, err := sess.Fetch(t.Context(), &message.Fetch{ - Namespace: ns, - Name: name, - Parameters: params, - }) - if err != nil { - t.Fatalf("Fetch: %v", err) - } - t.Cleanup(func() { reqStream.Close() }) - - ds, err := sess.AcceptDataStream(t.Context()) - if err != nil { - t.Fatalf("AcceptDataStream: %v", err) - } - fs, isFetch := ds.(*session.IncomingFetchStream) - if !isFetch { - t.Fatalf("got %T, want *IncomingFetchStream", ds) - } - return reqStream.OK, decodeFetchStream(t, fs, order) -} - -// decodeFetchStream reads fs to EOF, reversing the §11.4.4.1 delta encoding -// itself rather than through the session decoder. -func decodeFetchStream(t *testing.T, fs *session.IncomingFetchStream, order message.GroupOrder) []decodedFetchObject { - t.Helper() - var ( - out []decodedFetchObject - prevGroup uint64 - prevObject uint64 - havePrev bool - descending = order == message.GroupOrderDescending - ) - for { - fo, err := fs.ReadObject() - if err != nil { - if errors.Is(err, io.EOF) { - return out - } - t.Fatalf("ReadObject: %v", err) - } - - var g, o uint64 - switch { - case !havePrev: - // The first Object's deltas are absolute. - g = fo.GroupIDDelta - o = fo.ObjectIDDelta - case fo.SerializationFlags&message.FetchFlagGroupIDDelta != 0: - if descending { - g = prevGroup - fo.GroupIDDelta - 1 - } else { - g = prevGroup + fo.GroupIDDelta + 1 - } - o = fo.ObjectIDDelta - default: - g = prevGroup - if fo.SerializationFlags&message.FetchFlagObjectIDDelta != 0 { - o = prevObject + fo.ObjectIDDelta // no +1 here - } else { - o = prevObject + 1 - } - } - - out = append(out, decodedFetchObject{group: g, object: o, payload: fo.ObjectPayload}) - prevGroup = g - prevObject = o - havePrev = true - } -} - -// fetchElem is one element of a FETCH response: an Object or a §11.4.4.2 -// End of Range marker. -type fetchElem struct { - Group, Object uint64 - Unknown bool // End of Unknown Range - Marker bool // any End of Range marker -} - -// tryFetchElems FETCHes [{0,0}, {lastGroup,0}] with params and returns the -// response elements, or nil when the FETCH is refused. -func tryFetchElems( - t *testing.T, - sess *session.Session, - ns wire.TrackNamespace, - name []byte, - lastGroup uint64, - params message.Parameters, -) []fetchElem { - t.Helper() - fetchReq, err := sess.Fetch(t.Context(), &message.Fetch{ - Namespace: ns, - Name: name, - Parameters: append(message.Parameters{ - fetchRangeFilter(message.Location{}, message.Location{Group: lastGroup, Object: 0}), - }, params...), - }) - if err != nil { - return nil // not yet serviceable: the caller retries - } - defer fetchReq.Close() - order := message.GroupOrderAscending - if p, ok := params.Find(message.ParamGroupOrder); ok { - order = message.GroupOrder(p.Byte) - } - return collectFetchElems(t, sess, order, 3*time.Second) -} - -// collectFetchElems reads the next FETCH response on sess to FIN within -// timeout and returns its elements in arrival order. -func collectFetchElems( - t *testing.T, - sess *session.Session, - order message.GroupOrder, - timeout time.Duration, -) []fetchElem { - t.Helper() - var elems []fetchElem - for _, obj := range readFetchResponse(t, sess, order, timeout) { - elems = append(elems, fetchElem{ - Group: obj.GroupID, - Object: obj.ObjectID, - Unknown: obj.EndOfUnknownRange, - Marker: obj.IsEndOfRange(), - }) - } - return elems -} - -// readFetchResponse accepts the next data stream on sess, requires a FETCH -// response, and decodes it to FIN within timeout, markers included. -func readFetchResponse( - t *testing.T, - sess *session.Session, - order message.GroupOrder, - timeout time.Duration, -) []*session.DecodedFetchObject { - t.Helper() - type result struct { - objs []*session.DecodedFetchObject - err error - } - ch := make(chan result, 1) - go func() { - ds, err := sess.AcceptDataStream(t.Context()) - if err != nil { - ch <- result{err: err} - return - } - fs, ok := ds.(*session.IncomingFetchStream) - if !ok { - ch <- result{err: errors.New("not a fetch stream")} - return - } - fs.GroupOrder = order - var r result - for { - obj, err := fs.ReadDecoded() - if err != nil { - if !errors.Is(err, io.EOF) { - r.err = err - } - ch <- r - return - } - r.objs = append(r.objs, obj) - } - }() - select { - case r := <-ch: - if r.err != nil { - t.Fatalf("reading FETCH response: %v", r.err) - } - return r.objs - case <-time.After(timeout): - t.Fatal("FETCH response did not arrive within deadline") - return nil - } -} - -// realGroups returns the groups of the elements that are not End of Unknown -// Range markers. -func realGroups(elems []fetchElem) []uint64 { - var out []uint64 - for _, e := range elems { - if !e.Unknown { - out = append(out, e.Group) - } - } - return out -} - -// objectGroups returns the groups of the elements that are Objects. -func objectGroups(elems []fetchElem) []uint64 { - var out []uint64 - for _, e := range elems { - if !e.Marker { - out = append(out, e.Group) - } - } - return out -} - -// groupsEqual reports whether got is exactly wantLo..wantHi in order. -func groupsEqual(got []uint64, wantLo, wantHi uint64) bool { - if uint64(len(got)) != wantHi-wantLo+1 { - return false - } - for i, g := range got { - if g != wantLo+uint64(i) { - return false - } - } - return true -} - -// fetchRange FETCHes [start, end] and returns the response's Objects; served -// is false when the FETCH was refused or no stream arrived within 2s. -func fetchRange( - t *testing.T, - sess *session.Session, - ns wire.TrackNamespace, - name []byte, - start, end message.Location, -) (objs []*session.DecodedFetchObject, served bool) { - t.Helper() - fr, err := sess.Fetch(t.Context(), &message.Fetch{ - Namespace: ns, Name: name, - Parameters: message.Parameters{fetchRangeFilter(start, end)}, - }) - if err != nil { - return nil, false - } - defer fr.Close() - ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) - defer cancel() - ds, err := sess.AcceptDataStream(ctx) - if err != nil { - return nil, false - } - fs, ok := ds.(*session.IncomingFetchStream) - if !ok { - return nil, false - } - for { - o, err := fs.ReadDecoded() - if err != nil { - return objs, true - } - if !o.IsEndOfRange() { - objs = append(objs, o) - } - } -} - -// writeFetchGroupRange writes one Object per group startG..endG to a FETCH -// response in ascending delta encoding (§11.4.4). -func writeFetchGroupRange(out *session.OutgoingFetchStream, startG, endG uint64) { - first := true - for g := startG; g <= endG; g++ { - fo := &message.FetchObject{} - fo.SerializationFlags |= message.FetchFlagGroupIDDelta | message.FetchFlagObjectIDDelta - if first { - fo.GroupIDDelta = g // absolute - fo.SerializationFlags |= message.FetchFlagPriority - first = false - } else { - fo.GroupIDDelta = 0 // the next group - } - fo.ObjectIDDelta = 0 - fo.ObjectPayload = []byte{byte('a' + g)} - if err := out.WriteObject(fo); err != nil { - return - } - } -} diff --git a/pkg/relay/inbound_goaway_test.go b/pkg/relay/inbound_goaway_test.go index a5e1e294..e7bcb9b8 100644 --- a/pkg/relay/inbound_goaway_test.go +++ b/pkg/relay/inbound_goaway_test.go @@ -9,12 +9,8 @@ import ( "github.com/floatdrop/moq-go/pkg/relay" ) -// §10.4 from the recipient's side. The GOAWAY's Timeout is "The time in -// milliseconds the sender will wait for graceful closure": closing the session -// is the sender's job, with GOAWAY_TIMEOUT, not the recipient's. What the -// recipient owes is restraint: "Upon receiving a GOAWAY on the control stream, -// an endpoint SHOULD NOT initiate new requests to the peer including -// SUBSCRIBE, PUBLISH, FETCH, [...]". +// GOAWAY from the recipient's side (§10.4): closing the session is the +// sender's job; the recipient SHOULD NOT initiate new requests to the peer. // TestRelay_InboundGoawayLeavesSessionOpen: the relay does not close a session // because its peer sent GOAWAY, whether the peer named a timeout or not (0: "no @@ -101,10 +97,8 @@ func TestRelay_NoForwardedPublishToGoingAwayHolder(t *testing.T) { requireNoForward(t, forwarded, "holder that sent GOAWAY") } -// TestRelay_NoUpstreamFetchToGoingAwayPublisher: a FETCH reaching below the -// relay's cache would be stitched with an upstream FETCH to the publisher, but -// not to one that sent GOAWAY; the range it would have filled is reported -// unknown instead. +// TestRelay_NoUpstreamFetchToGoingAwayPublisher: a FETCH below the cache is not +// stitched from a publisher that sent GOAWAY; the range is reported unknown. func TestRelay_NoUpstreamFetchToGoingAwayPublisher(t *testing.T) { t.Parallel() pubSess, teardown := connectRelay(t, relay.Config{}) diff --git a/pkg/relay/integration_test.go b/pkg/relay/integration_test.go index 8bc2aca6..6a58cca6 100644 --- a/pkg/relay/integration_test.go +++ b/pkg/relay/integration_test.go @@ -1,24 +1,7 @@ package relay_test -// Integration test suite. These tests exercise the relay end-to-end -// over the in-process [sessiontest] transport. They complement the -// unit-level tests scattered across the relay package and serve as -// living documentation of the headline scenarios the relay must handle. -// -// Related integration-level coverage in other files: -// -// - TestFanout_PublisherToSubscriberSingleObject — single-object E2E. -// The richer multi-object/datagram variant lives below as -// TestPublishSubscribeE2E. -// - TestFanout_StalledSubscriberDoesNotBlockFastOne — per-subscriber -// isolation: a stalled subscriber overflows without blocking a fast one. -// - TestFetch_FromCacheAscending / Descending — FETCH from cache. -// - TestFetch_CacheEvictionUnderLoad — cache eviction. -// - TestRelay_StopBroadcastsGoaway / _StopReturnsEarlyOnCleanDrain / -// _StopForceClosesOnTimeout + _InboundGoaway* — the GOAWAY half of -// the migration story; TestGracefulMigration below adds the -// subscriber-side migration cycle. -// - TestDiscovery_* — Discovery store integration. +// End-to-end scenarios over the in-process [sessiontest] transport: the +// headline behaviours the relay must handle. import ( "errors" @@ -31,11 +14,8 @@ import ( "github.com/floatdrop/moq-go/pkg/relay/internal/relaytest" ) -// TestPublishSubscribeE2E is the broad end-to-end happy path: a -// publisher claims a track, a subscriber on a separate session -// subscribes, and the relay forwards a mixed stream of subgroup -// objects and datagrams. Both transports MUST land at the subscriber -// with the relay-allocated outbound Track Alias. +// TestPublishSubscribeE2E: subgroup Objects and datagrams from a publisher both +// reach a subscriber under the relay-allocated Track Alias. func TestPublishSubscribeE2E(t *testing.T) { t.Parallel() @@ -174,11 +154,8 @@ func TestPublishSubscribeE2E(t *testing.T) { } } -// TestSubscriptionAggregation pins §9.4: two downstream subscribers -// for the same track must result in ONE upstream subscription. The -// upstream publisher counts the SUBSCRIBE requests it receives; the -// second downstream subscriber arriving while the upstream is -// Established must NOT trigger a fresh upstream SUBSCRIBE. +// TestSubscriptionAggregation: two downstream subscribers of one track share +// one upstream SUBSCRIBE (§9.4). func TestSubscriptionAggregation(t *testing.T) { t.Parallel() @@ -261,13 +238,9 @@ func TestSubscriptionAggregation(t *testing.T) { } } -// TestPublishNamespaceRouting exercises §6.1 / §9.5: -// -// - A subscriber's SUBSCRIBE_NAMESPACE for prefix ["video"] should -// observe a NAMESPACE event when a publisher PUBLISH_NAMESPACEs -// ["video", "cam1"]. -// - A SUBSCRIBE for that track is routed via the namespace prefix -// match to the publisher (the on-demand upstream subscribe path). +// TestPublishNamespaceRouting: a SUBSCRIBE_NAMESPACE holder sees NAMESPACE for a +// matching PUBLISH_NAMESPACE, and a SUBSCRIBE is routed to that publisher +// (§6.1, §9.5). func TestPublishNamespaceRouting(t *testing.T) { t.Parallel() @@ -304,13 +277,8 @@ func TestPublishNamespaceRouting(t *testing.T) { } } -// TestDeliveryTimeouts pins the parameter-passthrough contract: a -// PUBLISH carrying OBJECT_DELIVERY_TIMEOUT / SUBGROUP_DELIVERY_TIMEOUT -// is accepted by the relay and a subscriber can complete its -// subscription cycle. The wire-level enforcement of these timeouts is -// owned by the session layer ([session.OutgoingSubgroupStream]'s -// timer-driven reset), which has its own tests; the relay's concern is -// that it doesn't reject or strip these parameters. +// TestDeliveryTimeouts: a PUBLISH with OBJECT_DELIVERY_TIMEOUT and +// SUBGROUP_DELIVERY_TIMEOUT is accepted and its subscription completes. func TestDeliveryTimeouts(t *testing.T) { t.Parallel() @@ -345,16 +313,8 @@ func TestDeliveryTimeouts(t *testing.T) { } } -// TestGracefulMigration exercises the GOAWAY → migrate lifecycle: -// -// 1. Publisher and subscriber are connected to the relay. -// 2. Operator calls Stop on the relay. -// 3. Both peers observe GOAWAY via session.GoawayReceived(). -// 4. Both peers cleanly close their sessions in response (the -// "migrate" action; in a multi-relay deployment they would -// reconnect elsewhere). -// 5. Stop returns well before its grace period elapses (cooperative -// drain). +// TestGracefulMigration: on Stop both peers see GOAWAY, close their sessions, +// and Stop returns well before its grace period. func TestGracefulMigration(t *testing.T) { t.Parallel() diff --git a/pkg/relay/late_publisher_test.go b/pkg/relay/late_publisher_test.go index b490ac52..4f1f15bc 100644 --- a/pkg/relay/late_publisher_test.go +++ b/pkg/relay/late_publisher_test.go @@ -13,11 +13,10 @@ import ( "github.com/floatdrop/moq-go/pkg/relay" ) -// §9.5: "When a relay receives an authorized PUBLISH_NAMESPACE for a namespace -// that matches one or more existing subscriptions to other upstream sessions, -// it MUST send a SUBSCRIBE to the publisher that sent the PUBLISH_NAMESPACE -// for each matching subscription." +// A PUBLISH_NAMESPACE matching existing subscriptions gets a SUBSCRIBE for each +// (§9.5). +// acceptedSubscribe is one SUBSCRIBE a test publisher accepted. type acceptedSubscribe struct { track string // last namespace field + "/" + name alias uint64 @@ -71,6 +70,8 @@ func publishNamespaceLate( return late, subs } +// awaitAcceptedSubscribe requires the next accepted SUBSCRIBE to be for want +// ("namespace/name") within 2s. func awaitAcceptedSubscribe(t *testing.T, subs <-chan acceptedSubscribe, want string) acceptedSubscribe { t.Helper() select { @@ -85,6 +86,7 @@ func awaitAcceptedSubscribe(t *testing.T, subs <-chan acceptedSubscribe, want st return acceptedSubscribe{} } +// requireNoSubscribe fails if a SUBSCRIBE is accepted within 300ms. func requireNoSubscribe(t *testing.T, subs <-chan acceptedSubscribe) { t.Helper() select { @@ -94,10 +96,9 @@ func requireNoSubscribe(t *testing.T, subs <-chan acceptedSubscribe) { } } -// TestRelay_LatePublishNamespaceJoinsOnDemandSubscription: the existing -// upstream is the relay's own on-demand SUBSCRIBE to an earlier namespace -// publisher. The late publisher is subscribed too, and its Objects reach the -// downstream subscriber. +// TestRelay_LatePublishNamespaceJoinsOnDemandSubscription: a late namespace +// publisher is subscribed alongside the relay's on-demand upstream, and its +// Objects reach the subscriber. func TestRelay_LatePublishNamespaceJoinsOnDemandSubscription(t *testing.T) { t.Parallel() video := ns("video") @@ -162,6 +163,7 @@ func acceptOneSubscribeRequest(t *testing.T, sess *session.Session) <-chan *sess return got } +// awaitRequest returns the next request from reqs, failing after 2s. func awaitRequest(t *testing.T, reqs <-chan *session.Request) *session.Request { t.Helper() select { @@ -173,10 +175,9 @@ func awaitRequest(t *testing.T, reqs <-chan *session.Request) *session.Request { return nil } -// TestRelay_LatePublisherReleasedWhenSubscriberLeavesMidSubscribe: the -// track's last subscriber leaves while the relay waits for the late -// publisher's SUBSCRIBE_OK. The subscription must then be cancelled, not kept -// with nothing that would ever release it. +// TestRelay_LatePublisherReleasedWhenSubscriberLeavesMidSubscribe: if the last +// subscriber leaves while the late publisher's SUBSCRIBE_OK is pending, that +// subscription is cancelled. func TestRelay_LatePublisherReleasedWhenSubscriberLeavesMidSubscribe(t *testing.T) { t.Parallel() video := ns("video") @@ -257,10 +258,8 @@ func TestRelay_WithdrawnPublishNamespaceGetsNoMoreSubscribes(t *testing.T) { requireNoSubscribe(t, rest) } -// TestRelay_PublishNamespaceDuringPendingSubscribe: the late publisher -// registers while the track's first upstream SUBSCRIBE is still pending, so -// the track has neither an established upstream nor a downstream when its -// PUBLISH_NAMESPACE is handled. It must still be subscribed once the +// TestRelay_PublishNamespaceDuringPendingSubscribe: a publisher registering +// while the track's first upstream SUBSCRIBE is pending is subscribed once the // downstream is registered. func TestRelay_PublishNamespaceDuringPendingSubscribe(t *testing.T) { video := ns("video") @@ -299,10 +298,8 @@ func TestRelay_PublishNamespaceDuringPendingSubscribe(t *testing.T) { awaitAcceptedSubscribe(t, lateSubs, "video/"+name) } -// TestRelay_LatePublishNamespaceSkipsItsOwnDownstream: a session receiving a -// track from the relay is not asked to publish that track back when it sends -// PUBLISH_NAMESPACE, matching the on-demand path, which never subscribes on -// the requesting session. +// TestRelay_LatePublishNamespaceSkipsItsOwnDownstream: a session receiving the +// track is not asked to publish it back. func TestRelay_LatePublishNamespaceSkipsItsOwnDownstream(t *testing.T) { t.Parallel() pubSess, _ := newCam1Publisher(t, nil) @@ -354,6 +351,8 @@ func TestRelay_OverlappingPublishNamespacesSubscribeOnce(t *testing.T) { requireNoSubscribeRequest(t, reqs) } +// requireNoSubscribeRequest fails if a request arrives on reqs within the +// quiet period. func requireNoSubscribeRequest(t *testing.T, reqs <-chan *session.Request) { t.Helper() select { @@ -363,10 +362,9 @@ func requireNoSubscribeRequest(t *testing.T, reqs <-chan *session.Request) { } } -// TestRelay_LatePublisherResumedWhenForwardChangesMidSubscribe: the track's -// only subscriber switches to Forward=1 while the relay waits for the late -// publisher's SUBSCRIBE_OK, sent with Forward=0. §9.2 propagation cannot -// reach the late upstream yet, so the relay must resume it once registered. +// TestRelay_LatePublisherResumedWhenForwardChangesMidSubscribe: a subscriber +// switching to Forward=1 while the late publisher's Forward=0 SUBSCRIBE is +// pending gets that upstream resumed once registered (§9.2). func TestRelay_LatePublisherResumedWhenForwardChangesMidSubscribe(t *testing.T) { t.Parallel() video := ns("video") @@ -417,9 +415,8 @@ func TestRelay_LatePublisherResumedWhenForwardChangesMidSubscribe(t *testing.T) } // TestRelay_SkippedLatePublisherSubscribedWhenFirstSubscriberArrives: a -// publisher whose PUBLISH_NAMESPACE was skipped for a track with no -// subscriber is subscribed once one arrives. The skip defers the §9.5 -// SUBSCRIBE; it does not drop it. +// publisher skipped for a track with no subscriber is subscribed once one +// arrives. func TestRelay_SkippedLatePublisherSubscribedWhenFirstSubscriberArrives(t *testing.T) { t.Parallel() pubSess, _ := newCam1Publisher(t, nil) @@ -479,10 +476,8 @@ func TestRelay_LatePublisherResubscribedForNextSubscriber(t *testing.T) { awaitAcceptedSubscribe(t, lateSubs, "video/cam1") } -// TestRelay_WithdrawnSkippedPublisherNotAsked: a late publisher skipped for -// a track with no subscriber withdraws its PUBLISH_NAMESPACE; the track's -// first subscriber must not get it SUBSCRIBEd (§9.5: withdrawal stops new -// subscriptions). +// TestRelay_WithdrawnSkippedPublisherNotAsked: a skipped publisher that +// withdrew its PUBLISH_NAMESPACE is not subscribed later (§9.5). func TestRelay_WithdrawnSkippedPublisherNotAsked(t *testing.T) { t.Parallel() pubSess, _ := newCam1Publisher(t, nil) diff --git a/pkg/relay/limit_integration_test.go b/pkg/relay/limit_integration_test.go index 137bb968..e69852a1 100644 --- a/pkg/relay/limit_integration_test.go +++ b/pkg/relay/limit_integration_test.go @@ -51,11 +51,8 @@ func TestRelay_SubscriptionLimit(t *testing.T) { requireRetryInvited(t, err) } -// requireRetryInvited: §10.6.2 "EXCESSIVE_LOAD: The responder is overloaded -// and cannot process the request at this time. The sender SHOULD use the -// Retry Interval to indicate when the request can be retried." A per-session -// cap frees up when an earlier request ends, so the relay invites a retry -// after about a second, jittered against synchronized retries. +// requireRetryInvited requires an EXCESSIVE_LOAD refusal whose Retry Interval +// invites a retry after about a second, jittered (§10.6.2). func requireRetryInvited(t *testing.T, err error) { t.Helper() rej, _ := errors.AsType[*session.RequestRejectedError](err) @@ -65,10 +62,9 @@ func requireRetryInvited(t *testing.T, err error) { } } -// TestRelay_NamespaceRequestLimit pins §13.7.1: with -// MaxNamespaceRequestsPerSession=1 the relay accepts the first -// PUBLISH_NAMESPACE and rejects a second concurrent namespace request on the -// same session with REQUEST_ERROR EXCESSIVE_LOAD. +// TestRelay_NamespaceRequestLimit: with MaxNamespaceRequestsPerSession=1 a +// second concurrent namespace request on the session is EXCESSIVE_LOAD +// (§13.7.1). func TestRelay_NamespaceRequestLimit(t *testing.T) { t.Parallel() diff --git a/pkg/relay/malformed_track_test.go b/pkg/relay/malformed_track_test.go index 4d671547..8cf3602f 100644 --- a/pkg/relay/malformed_track_test.go +++ b/pkg/relay/malformed_track_test.go @@ -16,8 +16,9 @@ import ( // Malformed tracks (§2.4.2): the relay ends downstream subscriptions with // PUBLISH_DONE MALFORMED_TRACK, resets fetch streams, and cancels its own -// subscription upstream. Unknown Mandatory Track Properties (§2.5.1) refuse -// the track the same way. +// subscription upstream. An unknown Mandatory Track Property (§2.5.1) refuses +// the track with REQUEST_ERROR UNSUPPORTED_EXTENSION, or resets a fetch stream +// the relay already answered. // mandatoryProps carries the unknown Mandatory Track Property 0x4000. As Object // Properties it makes the track malformed (§2.5.1). @@ -237,10 +238,9 @@ func TestRelay_UpstreamSubscribeOKTrackPropertiesRejected(t *testing.T) { } } -// TestRelay_UpstreamMandatoryPropertyWinsOverOtherFailure: with two -// publishers for the namespace, one answering SUBSCRIBE_OK with an unknown -// Mandatory Track Property and the other refusing, the downstream subscriber -// still gets UNSUPPORTED_EXTENSION (§2.5.1), whichever publisher is tried last. +// TestRelay_UpstreamMandatoryPropertyWinsOverOtherFailure: with one publisher +// answering an unknown Mandatory Track Property and another refusing, the +// subscriber gets UNSUPPORTED_EXTENSION in either order (§2.5.1). func TestRelay_UpstreamMandatoryPropertyWinsOverOtherFailure(t *testing.T) { t.Parallel() for _, mandatoryFirst := range []bool{true, false} { diff --git a/pkg/relay/merge_tracks_update_test.go b/pkg/relay/merge_tracks_update_test.go index 733e81c4..efbaf9f7 100644 --- a/pkg/relay/merge_tracks_update_test.go +++ b/pkg/relay/merge_tracks_update_test.go @@ -7,10 +7,9 @@ import ( "github.com/floatdrop/moq-go/pkg/moqt/wire" ) -// TestMergeTracksUpdate: a type the update names replaces every stored -// parameter of that type; other types are kept (§10.9); a zero-length Range -// Filter removes that filter type (§5.1.4); TRACK_NAMESPACE_PREFIX and -// AUTHORIZATION_TOKEN are not kept. +// TestMergeTracksUpdate: an updated parameter type replaces every stored one of +// that type (§10.9), a zero-length Range Filter removes it (§5.1.4), and +// TRACK_NAMESPACE_PREFIX and AUTHORIZATION_TOKEN are not kept. func TestMergeTracksUpdate(t *testing.T) { objIDs := func(start uint64) message.Parameter { return message.RangeFilterParam(&message.RangeFilter{ diff --git a/pkg/relay/metrics_test.go b/pkg/relay/metrics_test.go index c75aa21e..a6c2908f 100644 --- a/pkg/relay/metrics_test.go +++ b/pkg/relay/metrics_test.go @@ -111,10 +111,9 @@ func (m *recordingMetrics) resetCount(cause relay.ResetCause) int { return m.resets[cause] } -// TestMetricsHooks drives a publish → subscribe → forward → fetch flow through -// the relay with a recording [relay.Metrics] installed and asserts each hook -// fires with the expected counts, including that the session/subscription -// gauges balance once the relay tears down. +// TestMetricsHooks: a publish, subscribe, forward and fetch flow fires each +// [relay.Metrics] hook with the expected counts, and the gauges balance after +// teardown. func TestMetricsHooks(t *testing.T) { rec := &recordingMetrics{} pubSess, teardown := connectRelay(t, relay.Config{Metrics: rec}) @@ -296,18 +295,9 @@ func drainFetch(t *testing.T, sess *session.Session) { } } -// TestLegString and TestResetCauseString pin the metric label values. -// -// These strings are an external contract, not a debug convenience: they become -// label values in an operator's Prometheus/OTel backend, where renaming one -// silently splits a time series in two and breaks every dashboard and alert -// built on it. The doc comments promise them "stable" — this is what holds -// them to that. -// -// The unknown arm carries its own weight. Both types document that a value -// they do not recognise renders as "unknown" rather than as a number, -// precisely so a new enum member added upstream cannot turn a bounded label -// into unbounded label cardinality. +// TestLegString and TestResetCauseString pin the metric label values, an +// external contract; an unrecognised value renders "unknown" to keep label +// cardinality bounded. func TestLegString(t *testing.T) { t.Parallel() for leg, want := range map[relay.Leg]string{ @@ -321,6 +311,7 @@ func TestLegString(t *testing.T) { } } +// TestResetCauseString: see TestLegString. func TestResetCauseString(t *testing.T) { t.Parallel() for cause, want := range map[relay.ResetCause]string{ diff --git a/pkg/relay/namespace_state_test.go b/pkg/relay/namespace_state_test.go index 1cd9fa51..f1184efc 100644 --- a/pkg/relay/namespace_state_test.go +++ b/pkg/relay/namespace_state_test.go @@ -17,12 +17,11 @@ import ( "github.com/floatdrop/moq-go/pkg/relay/internal/relaytest" ) -// §10.19: "The publisher MUST NOT send NAMESPACE_DONE for a namespace suffix -// before the corresponding NAMESPACE", and NAMESPACE_DONE means the relay -// stops "serving new subscriptions for tracks within the provided Track -// Namespace" (§10.18) — so it is per namespace, not per publisher. §10.9.2 -// covers TRACK_NAMESPACE_PREFIX updates. +// NAMESPACE / NAMESPACE_DONE state is per namespace, not per publisher, and +// NAMESPACE_DONE never precedes its NAMESPACE (§10.18, §10.19); +// TRACK_NAMESPACE_PREFIX updates follow §10.9.2. +// requireQuiet fails if a message arrives on msgs within 300ms. func requireQuiet(t *testing.T, msgs <-chan message.Message, what string) { t.Helper() select { @@ -32,6 +31,7 @@ func requireQuiet(t *testing.T, msgs <-chan message.Message, what string) { } } +// requireNamespace requires the next message to be NAMESPACE for suffix. func requireNamespace(t *testing.T, msgs <-chan message.Message, suffix ...string) { t.Helper() m := nextMessage(t, msgs) @@ -41,6 +41,8 @@ func requireNamespace(t *testing.T, msgs <-chan message.Message, suffix ...strin } } +// requireNamespaceDone requires the next message to be NAMESPACE_DONE for +// suffix. func requireNamespaceDone(t *testing.T, msgs <-chan message.Message, suffix ...string) { t.Helper() m := nextMessage(t, msgs) @@ -50,6 +52,7 @@ func requireNamespaceDone(t *testing.T, msgs <-chan message.Message, suffix ...s } } +// publishNS sends PUBLISH_NAMESPACE for the namespace fields from sess. func publishNS(t *testing.T, sess *session.Session, fields ...string) *session.NamespacePublication { t.Helper() p, err := sess.PublishNamespace(t.Context(), &message.PublishNamespace{Namespace: ns(fields...)}) @@ -59,6 +62,8 @@ func publishNS(t *testing.T, sess *session.Session, fields ...string) *session.N return p } +// subscribeNS sends SUBSCRIBE_NAMESPACE for the prefix fields and returns the +// subscription with the messages read from its stream. func subscribeNS( t *testing.T, sess *session.Session, @@ -105,6 +110,8 @@ func TestNamespace_ReplayedNamespaceGetsDone(t *testing.T) { requireNamespaceDone(t, msgs, "cam") } +// sendPrefixUpdate writes a TRACK_NAMESPACE_PREFIX REQUEST_UPDATE on stream +// without awaiting the reply, which arrives among the stream's messages. func sendPrefixUpdate(t *testing.T, sess *session.Session, stream session.Stream, fields ...string) { t.Helper() if err := message.Marshal(stream, &message.RequestUpdate{ @@ -115,11 +122,9 @@ func sendPrefixUpdate(t *testing.T, sess *session.Session, stream session.Stream } } -// TestNamespace_PrefixUpdateReconciles: after a TRACK_NAMESPACE_PREFIX update -// the subscriber's announced set matches the new prefix. Namespaces the new -// prefix drops are done before the REQUEST_OK (their suffixes are relative to -// the old prefix); namespaces it adds are announced after, relative to the new -// one (§10.9.2), and later events use it too. +// TestNamespace_PrefixUpdateReconciles: after a TRACK_NAMESPACE_PREFIX update, +// namespaces the new prefix drops are done before the REQUEST_OK and ones it +// adds are announced after, relative to the new prefix (§10.9.2). func TestNamespace_PrefixUpdateReconciles(t *testing.T) { t.Parallel() pubSess, teardown := connectRelay(t, relay.Config{}) @@ -140,12 +145,10 @@ func TestNamespace_PrefixUpdateReconciles(t *testing.T) { requireNamespaceDone(t, msgs, "b") } -// TestNamespace_PrefixUpdateOverlapRejected: an updated prefix that "would -// share a common prefix with another active subscription of the same type in -// the same session" gets REQUEST_ERROR PREFIX_OVERLAP (§10.2.20), and a failed -// update ends the request — "the responder MUST close the bidi stream" -// (§10.9.1). Once the requester FINs back (§3.3.2) its prefix is free for a -// new subscription. +// TestNamespace_PrefixUpdateOverlapRejected: an updated prefix overlapping +// another subscription of the session is PREFIX_OVERLAP (§10.2.20); the failed +// update ends the request (§10.9.1), freeing its prefix once the requester FINs +// (§3.3.2). func TestNamespace_PrefixUpdateOverlapRejected(t *testing.T) { t.Parallel() subSess, teardown := connectRelay(t, relay.Config{}) @@ -183,6 +186,7 @@ func TestNamespace_StopSendingEndsSubscription(t *testing.T) { requirePrefixReusable(t, subSess, "video") } +// requireStreamEnds requires msgs to close within 2s with no further message. func requireStreamEnds(t *testing.T, msgs <-chan message.Message) { t.Helper() select { @@ -406,9 +410,8 @@ func TestNamespace_RestartedWatchDropsStaleRemote(t *testing.T) { } // TestNamespace_RestartedWatchChangesOnlyWhatChanged: a restarted watch is -// reconciled against what the relay already knew. A namespace withdrawn while -// the watch was down is done, one added then is announced, and one still -// advertised causes nothing — no NAMESPACE_DONE followed by NAMESPACE again. +// reconciled with what the relay knew: withdrawn namespaces are done, added +// ones announced, and unchanged ones cause nothing. func TestNamespace_RestartedWatchChangesOnlyWhatChanged(t *testing.T) { t.Parallel() store := &cuttableWatchStore{MemoryStore: discovery.NewMemoryStore(), cut: make(chan struct{})} @@ -504,12 +507,8 @@ func TestNamespace_LargeSeedNotReset(t *testing.T) { } } -// TestNamespace_BlockedSubscriberReset: §10.19 "If the publisher is unable to -// send NAMESPACE or NAMESPACE_DONE messages in a timely manner because the -// SUBSCRIBE_NAMESPACE response stream is blocked by flow control, the -// publisher MAY reset the SUBSCRIBE_NAMESPACE response stream." A subscriber -// that stops reading has its stream reset once its queue reaches the bound, -// rather than the queue growing without one. +// TestNamespace_BlockedSubscriberReset: a SUBSCRIBE_NAMESPACE subscriber that +// stops reading has its stream reset once its queue reaches the bound (§10.19). func TestNamespace_BlockedSubscriberReset(t *testing.T) { t.Parallel() store := discovery.NewMemoryStore() @@ -559,10 +558,8 @@ func TestNamespace_BlockedSubscriberReset(t *testing.T) { } } -// TestNamespace_TricklingSubscriberReset: a subscriber that reads, but too -// slowly to keep up, is as blocked as one that stopped: every single write -// completes, yet the oldest unsent message waits longer and longer. Once -// enough are waiting long enough, the stream is reset. +// TestNamespace_TricklingSubscriberReset: a subscriber that reads too slowly to +// keep up is reset too, once enough messages have waited long enough. func TestNamespace_TricklingSubscriberReset(t *testing.T) { t.Parallel() store := discovery.NewMemoryStore() diff --git a/pkg/relay/newgroup_test.go b/pkg/relay/newgroup_test.go index 10eef3ed..c11d1b62 100644 --- a/pkg/relay/newgroup_test.go +++ b/pkg/relay/newgroup_test.go @@ -9,6 +9,7 @@ import ( "github.com/floatdrop/moq-go/pkg/relay" ) +// newGroupReqValue returns the NEW_GROUP_REQUEST value in ps, if present. func newGroupReqValue(ps message.Parameters) (uint64, bool) { if p, ok := ps.Find(message.ParamNewGroupRequest); ok { return p.Varint, true @@ -16,11 +17,9 @@ func newGroupReqValue(ps message.Parameters) (uint64, bool) { return 0, false } -// TestNewGroupRequest_ForwardedUpstreamOnUpdate is the §10.2.19 end-to-end -// test: a downstream subscriber sends a REQUEST_UPDATE carrying -// NEW_GROUP_REQUEST on a track that advertises DYNAMIC_GROUPS=1, and the relay -// forwards a REQUEST_UPDATE with the same NEW_GROUP_REQUEST to the original -// publisher. +// TestNewGroupRequest_ForwardedUpstreamOnUpdate: a NEW_GROUP_REQUEST in a +// downstream REQUEST_UPDATE on a DYNAMIC_GROUPS track reaches the publisher +// (§10.2.19). func TestNewGroupRequest_ForwardedUpstreamOnUpdate(t *testing.T) { t.Parallel() @@ -69,14 +68,9 @@ func TestNewGroupRequest_ForwardedUpstreamOnUpdate(t *testing.T) { } } -// TestNewGroupRequest_BackToBackUpdatesSurvive is the regression test for the -// upstream REQUEST_UPDATE response routing: the relay's upstream update rides -// the PUBLISH request stream, whose reader must route the publisher's -// REQUEST_OK back to the in-flight update instead of discarding it (the old -// DrainAndWait swallowed it, wedging the subscriber's update-dispatch loop -// forever after the first propagation). Two NEW_GROUP_REQUEST propagations -// back to back must both reach the publisher and both downstream updates must -// be answered. +// TestNewGroupRequest_BackToBackUpdatesSurvive: two NEW_GROUP_REQUEST updates in +// a row both reach the publisher and both downstream updates are answered; the +// publisher's REQUEST_OK is routed back to the relay's upstream update. func TestNewGroupRequest_BackToBackUpdatesSurvive(t *testing.T) { t.Parallel() diff --git a/pkg/relay/priority_test.go b/pkg/relay/priority_test.go index 6400d289..1b5511d3 100644 --- a/pkg/relay/priority_test.go +++ b/pkg/relay/priority_test.go @@ -12,10 +12,8 @@ import ( "github.com/floatdrop/moq-go/pkg/relay" ) -// prioritySpyConn wraps an underlying [session.Conn] and intercepts every -// outbound unidirectional stream it opens, returning a [session.SendStream] -// that ALSO implements [session.PrioritizedSendStream]. Every SetSendPriority -// call is appended to a shared slice the test can read after teardown. +// prioritySpyConn wraps a [session.Conn] so its outbound uni streams implement +// [session.PrioritizedSendStream] and record every SetSendPriority call. type prioritySpyConn struct { session.Conn @@ -45,6 +43,7 @@ func (c *prioritySpyConn) snapshot() []session.StreamPriority { return out } +// prioritySpyStream records SetSendPriority on its parent prioritySpyConn. type prioritySpyStream struct { session.SendStream @@ -55,12 +54,8 @@ func (s *prioritySpyStream) SetSendPriority(p session.StreamPriority) { s.parent.record(p) } -// spyPipeListener wraps a pipeListener and intercepts every server-side -// conn handed to the relay via Accept. The wrapped conn returns priority -// spy streams so the relay's outbound OpenSubgroup calls land in the -// recorder. (The subscriber's client-side conn doesn't need wrapping — -// the relay opens streams from *its* end of the pair, which is what the -// listener yields.) +// spyPipeListener is a pipeListener whose accepted (relay-side) conns are +// wrapped in a prioritySpyConn. type spyPipeListener struct { inner *pipeListener mu sync.Mutex @@ -70,6 +65,7 @@ type spyPipeListener struct { spies []*prioritySpyConn } +// newSpyPipeListener wraps a fresh pipeListener. func newSpyPipeListener() *spyPipeListener { return &spyPipeListener{inner: newPipeListener()} } func (l *spyPipeListener) Accept(ctx context.Context) (session.Conn, error) { @@ -100,12 +96,9 @@ func (l *spyPipeListener) LastSpy() *prioritySpyConn { return l.spies[len(l.spies)-1] } -// TestFanout_AppliesEffectivePriorityOnStreamOpen pins the end-to-end -// wiring: when the relay opens a downstream subgroup stream for a subscriber -// whose SUBSCRIBE carried SUBSCRIBER_PRIORITY=42, the underlying -// SendStream's SetSendPriority MUST be invoked with that byte before any -// objects are written. This proves both that applyPriority runs at the -// right moment AND that the OutgoingSubgroupStream forwards the call. +// TestFanout_AppliesEffectivePriorityOnStreamOpen: the relay sets a downstream +// subgroup stream's send priority to the subscriber's SUBSCRIBER_PRIORITY +// before writing Objects. func TestFanout_AppliesEffectivePriorityOnStreamOpen(t *testing.T) { t.Parallel() diff --git a/pkg/relay/publish_done_test.go b/pkg/relay/publish_done_test.go index 33cfc4fc..91497c2b 100644 --- a/pkg/relay/publish_done_test.go +++ b/pkg/relay/publish_done_test.go @@ -185,7 +185,7 @@ func TestPublishDone_AfterStreamsClose(t *testing.T) { } // TestPublishDone_EndedSubscriptionStopsAtNextObject: a subscription ended with -// UPDATE_FAILED (§10.9) while its upstream stays live has its open stream reset +// UPDATE_FAILED (§10.9.1) while its upstream stays live has its open stream reset // at the next Object, so PUBLISH_DONE is not held while the upstream runs. func TestPublishDone_EndedSubscriptionStopsAtNextObject(t *testing.T) { t.Parallel() diff --git a/pkg/relay/publish_skipped_test.go b/pkg/relay/publish_skipped_test.go index 06604e3f..3434c79a 100644 --- a/pkg/relay/publish_skipped_test.go +++ b/pkg/relay/publish_skipped_test.go @@ -10,13 +10,10 @@ import ( "github.com/floatdrop/moq-go/pkg/relay/internal/relaytest" ) -// TestPublishSkipped_EmittedWhenSubscriberOutOfStreamCredit pins §6.1 / §10.21: -// when a SUBSCRIBE_TRACKS subscriber has no bidirectional-stream credit left, -// the relay cannot open a PUBLISH stream for a newly-published matching track, -// so it sends PUBLISH_SKIPPED on the SUBSCRIBE_TRACKS response stream instead. -// The message carries only the namespace suffix beyond the subscriber's prefix -// (here prefix "video", published "video"/"cam7" → suffix "cam7") plus the -// track name. +// TestPublishSkipped_EmittedWhenSubscriberOutOfStreamCredit: with no bidi +// stream credit for a forwarded PUBLISH, the relay sends PUBLISH_SKIPPED with +// the namespace suffix and track name on the SUBSCRIBE_TRACKS stream (§6.1, +// §10.21). func TestPublishSkipped_EmittedWhenSubscriberOutOfStreamCredit(t *testing.T) { t.Parallel() primary, teardown := connectRelay(t, relay.Config{}) @@ -74,13 +71,9 @@ func TestPublishSkipped_EmittedWhenSubscriberOutOfStreamCredit(t *testing.T) { } } -// TestPublishSkipped_NotStickyAcrossRePublish pins §6.1 (a draft-19 change): -// a PUBLISH_SKIPPED prohibition is scoped to the single PUBLISH that could not -// be forwarded, NOT sticky across re-PUBLISHes. Here the subscriber's bidi -// credit stays 0, so the first PUBLISH is skipped; after the publisher FINs and -// re-PUBLISHes the same track, the relay MUST re-attempt the forward — and -// because credit is still exhausted, that surfaces as a SECOND PUBLISH_SKIPPED -// (draft-18 would have suppressed it silently). +// TestPublishSkipped_NotStickyAcrossRePublish: PUBLISH_SKIPPED covers one +// PUBLISH only (§6.1); a re-PUBLISH of the track is attempted again, and +// skipped again while credit is still exhausted. func TestPublishSkipped_NotStickyAcrossRePublish(t *testing.T) { t.Parallel() primary, teardown := connectRelay(t, relay.Config{}) diff --git a/pkg/relay/rangefilter_relay_test.go b/pkg/relay/rangefilter_relay_test.go index b5898b6b..200a8dab 100644 --- a/pkg/relay/rangefilter_relay_test.go +++ b/pkg/relay/rangefilter_relay_test.go @@ -17,10 +17,8 @@ import ( "github.com/floatdrop/moq-go/pkg/relay" ) -// filterRelayConfig is a relay that accepts Range Filters. It is the zero -// Config: [relay.DefaultMaxFilterRanges] is what a relay advertises unless it -// says otherwise, because §10.3.1.6's own default of 0 prohibits them and a -// relay that inherited it would reject filters it fully implements. +// filterRelayConfig is a relay that accepts Range Filters: the zero Config, +// which advertises [relay.DefaultMaxFilterRanges] rather than §10.3.1.6's 0. func filterRelayConfig() relay.Config { return relay.Config{} } @@ -54,11 +52,9 @@ func TestSubscribe_RangeFilterProhibitedWhenConfiguredOff(t *testing.T) { requireRejectedWithCode(t, err, moqt.RequestInvalidFilter) } -// TestFanout_ObjectIDRangeFilter pins §5.1.4 object filtering on live fanout: an -// OBJECTID_FILTER selecting [1,2] drops object 0 and 3, so the subscriber sees -// only IDs 1 and 2 (with deltas re-encoded against the forwarded IDs). The -// stream then ends with a reset, not a FIN: §11.4.3 allows a FIN only after -// every Object of the Subgroup (bar those before the Start Location). +// TestFanout_ObjectIDRangeFilter: OBJECTID_FILTER [1,2] forwards only Objects 1 +// and 2 (§5.1.4), and the stream ends with a reset, since Objects were omitted +// (§11.4.3). func TestFanout_ObjectIDRangeFilter(t *testing.T) { t.Parallel() pubSess, teardown := connectRelay(t, filterRelayConfig()) @@ -271,24 +267,9 @@ func TestFetch_ObjectIDRangeFilter(t *testing.T) { } } -// TestFetch_SubgroupFilterSelectsOneLayer is the temporal-layer backfill a live -// media application needs, on a relay configured with nothing. -// -// A group carrying L1T2 video is two subgroups: subgroup 0 is the base layer, -// which every later base frame references back to the keyframe, and subgroup 1 -// is an enhancement layer nothing references. A subscriber joining mid-group -// needs the base layer replayed from the keyframe and none of the enhancement -// layer — those frames are already past and nothing depends on them. -// -// SUBGROUP_FILTER is what says that, and it is the difference between a -// streamable backfill and a buffered one: a FETCH answers in ascending Object -// ID, so filtered to one subgroup the response is already decode order and can -// go frame by frame to a decoder, where the unfiltered answer interleaves two -// layers' ID ranges and has to be held whole and sorted first. -// -// The relay is [relay.Config]{} — the point of the test. §10.3.1.6's own -// MAX_FILTER_RANGES default is 0, which prohibits Range Filters, so a relay -// that inherited it would answer INVALID_FILTER to a filter it implements. +// TestFetch_SubgroupFilterSelectsOneLayer: on a default-configured relay, a +// SUBGROUP_FILTER FETCH returns one temporal layer (the base subgroup) alone, +// in ascending Object ID, which is decode order. func TestFetch_SubgroupFilterSelectsOneLayer(t *testing.T) { t.Parallel() pubSess, teardown := connectRelay(t, relay.Config{}) diff --git a/pkg/relay/relay_test.go b/pkg/relay/relay_test.go index 024794e3..04795a02 100644 --- a/pkg/relay/relay_test.go +++ b/pkg/relay/relay_test.go @@ -89,10 +89,8 @@ func TestRelay_AcceptsSession(t *testing.T) { } } -// TestRelay_StopBroadcastsGoaway pins §10.4: Stop must -// broadcast a GOAWAY to every active session before tearing it down, -// and the message carries Timeout = Config.GoawayTimeout (in ms) and -// an empty NewSessionURI (the relay isn't redirecting). +// TestRelay_StopBroadcastsGoaway: Stop sends every session GOAWAY with Timeout +// = Config.GoawayTimeout and no NewSessionURI (§10.4). func TestRelay_StopBroadcastsGoaway(t *testing.T) { t.Parallel() const grace = 250 * time.Millisecond @@ -158,16 +156,9 @@ func (s *withdrawSpyStore) Withdraw(ctx context.Context, relayAddr string) error return s.MemoryStore.Withdraw(ctx, relayAddr) } -// TestRelay_StopWithdrawsFromDiscoveryBeforeGoaway pins the shutdown ordering -// cross-relay deployments depend on: Stop must remove this relay's -// advertisements from Discovery before it closes the listener and before any -// GOAWAY goes out. A peer whose FindTrack / FindNamespace resolves to this relay -// during the drain would otherwise dial an endpoint that is already dead. -// -// The store's Withdraw blocks until the test has finished asserting, which is -// what makes this deterministic rather than a race against Stop's own progress: -// while the withdrawal is in flight, Stop cannot have closed the listener or sent -// a GOAWAY, so both "not yet" assertions are checked against a frozen shutdown. +// TestRelay_StopWithdrawsFromDiscoveryBeforeGoaway: Stop withdraws this relay's +// Discovery advertisements before closing the listener or sending GOAWAY. The +// store's Withdraw blocks until the assertions are done. func TestRelay_StopWithdrawsFromDiscoveryBeforeGoaway(t *testing.T) { t.Parallel() const ( @@ -321,18 +312,9 @@ func TestRelay_StopWithdrawFailureIsNotFatal(t *testing.T) { } } -// TestRelay_RunGoawaysOnShutdownSignal pins the contract the relay binaries -// depend on: when the context handed to Run is cancelled — what -// [os/signal.NotifyContext] does on SIGINT/SIGTERM — every live session still -// receives a GOAWAY (§10.4), and Run does not return until the drain it started -// has finished. -// -// Both halves are load-bearing, and each was independently broken when the -// binaries wired the signal context straight into Start and called Stop from a -// background goroutine: Start propagates its context to the per-session -// handlers, so the signal tore the sessions down before Stop could GOAWAY them, -// and Start returns as soon as Stop closes the listener, so main exited -// mid-drain. +// TestRelay_RunGoawaysOnShutdownSignal: cancelling Run's context (as a signal +// does) still sends every session GOAWAY (§10.4), and Run returns only once the +// drain has finished. func TestRelay_RunGoawaysOnShutdownSignal(t *testing.T) { t.Parallel() const grace = 250 * time.Millisecond @@ -397,10 +379,8 @@ func TestRelay_RunGoawaysOnShutdownSignal(t *testing.T) { } } -// TestRelay_StopReturnsEarlyOnCleanDrain pins the drain-success path: when -// the client closes its session cleanly after observing GOAWAY, Stop -// returns well before GoawayTimeout elapses. The whole point of GOAWAY -// is to give peers a chance to migrate without paying the full timeout. +// TestRelay_StopReturnsEarlyOnCleanDrain: when the client closes after GOAWAY, +// Stop returns well before GoawayTimeout. func TestRelay_StopReturnsEarlyOnCleanDrain(t *testing.T) { t.Parallel() const grace = 5 * time.Second // generous; we want to prove Stop is faster than this @@ -444,10 +424,8 @@ func TestRelay_StopReturnsEarlyOnCleanDrain(t *testing.T) { } } -// TestRelay_StopForceClosesOnTimeout pins the timeout path: a client -// that observes GOAWAY but ignores it gets force-closed at the -// GoawayTimeout boundary, with session error code GoawayTimeout -// (§10.4 / IANA §15.11.1). +// TestRelay_StopForceClosesOnTimeout: a client that ignores GOAWAY is closed at +// GoawayTimeout with session error GOAWAY_TIMEOUT (§10.4). func TestRelay_StopForceClosesOnTimeout(t *testing.T) { t.Parallel() const grace = 200 * time.Millisecond @@ -490,10 +468,8 @@ func TestRelay_StopForceClosesOnTimeout(t *testing.T) { } } -// TestRelay_InboundGoawayCleanDrainExitsEarly pins the cooperative path of -// an inbound GOAWAY: if the peer sends GOAWAY and then closes the session -// itself before its timeout expires, the relay's watcher exits via -// sess.Done() without waiting the full timeout. +// TestRelay_InboundGoawayCleanDrainExitsEarly: after an inbound GOAWAY, a peer +// that closes its session ends the relay's wait early. func TestRelay_InboundGoawayCleanDrainExitsEarly(t *testing.T) { t.Parallel() const peerGrace = 5 * time.Second // generous; we'll close before this @@ -567,6 +543,7 @@ func TestRelay_StartReturnsListenerError(t *testing.T) { } } +// errListener is a [relay.Listener] whose Accept always fails with err. type errListener struct{ err error } func (l *errListener) Accept(context.Context) (session.Conn, error) { return nil, l.err } diff --git a/pkg/relay/relay_upstream_test.go b/pkg/relay/relay_upstream_test.go index 5ad5a472..abd57bca 100644 --- a/pkg/relay/relay_upstream_test.go +++ b/pkg/relay/relay_upstream_test.go @@ -32,10 +32,8 @@ func rankedAddrs(ns wire.TrackNamespace, addrs []string) []string { return out } -// TestRankByAffinityConverges is the property the whole scheme rests on: the -// ranking is a pure function of (namespace, candidate set), so relays that -// receive the advertisements in different orders still compute the same order — -// and therefore agree on the top-fanIn upstreams. +// TestRankByAffinityConverges: the ranking depends only on the namespace and +// candidate set, so relays agree on it whatever order they learned it in. func TestRankByAffinityConverges(t *testing.T) { t.Parallel() @@ -60,10 +58,8 @@ func TestRankByAffinityConverges(t *testing.T) { } } -// TestRankByAffinitySpreads guards against a degenerate hash: if the address -// were left out of the weight, every namespace would tie and fall back to the -// same alphabetically-first relay. Distinct namespaces must land on more than -// one top relay. +// TestRankByAffinitySpreads: distinct namespaces land on more than one top +// relay. func TestRankByAffinitySpreads(t *testing.T) { t.Parallel() @@ -78,10 +74,8 @@ func TestRankByAffinitySpreads(t *testing.T) { } } -// TestRankByAffinitySubsetOrderStable pins the subset-order-preservation that -// keeps two leaves convergent even when their candidate sets differ by an -// unreachable entry: removing any relay must not reorder the rest, because each -// weight is independent of the others present. +// TestRankByAffinitySubsetOrderStable: removing a candidate does not reorder +// the rest. func TestRankByAffinitySubsetOrderStable(t *testing.T) { t.Parallel() @@ -96,10 +90,8 @@ func TestRankByAffinitySubsetOrderStable(t *testing.T) { } } -// TestNewUpstreamPoolFanInPassthrough pins that UpstreamFanIn is carried -// verbatim: the pool applies no default, because zero (and any negative) already -// means "unbounded" — the §9.5 full fan-in — which resolveUpstreams enforces via -// its `fanIn > 0` guard rather than by normalizing the value here. +// TestNewUpstreamPoolFanInPassthrough: UpstreamFanIn is kept verbatim; zero or +// negative means unbounded, the full fan-in of §9.5. func TestNewUpstreamPoolFanInPassthrough(t *testing.T) { t.Parallel() @@ -112,10 +104,9 @@ func TestNewUpstreamPoolFanInPassthrough(t *testing.T) { } } -// TestResolveUpstreamsSkipsGoingAwayRelay: a pooled session to a remote relay -// that sent GOAWAY takes no new requests (§10.4), so it must not take one of -// the UpstreamFanIn slots either; resolution falls through to the next-ranked -// relay while the draining one is still listed in Discovery. +// TestResolveUpstreamsSkipsGoingAwayRelay: a pooled session whose relay sent +// GOAWAY (§10.4) takes no UpstreamFanIn slot; resolution falls through to the +// next-ranked relay. func TestResolveUpstreamsSkipsGoingAwayRelay(t *testing.T) { t.Parallel() ctx := t.Context() diff --git a/pkg/relay/session_cleanup_test.go b/pkg/relay/session_cleanup_test.go index d1fe4ac8..ffbf98be 100644 --- a/pkg/relay/session_cleanup_test.go +++ b/pkg/relay/session_cleanup_test.go @@ -11,15 +11,9 @@ import ( "github.com/floatdrop/moq-go/pkg/relay" ) -// TestSessionCleanup_PublisherSessionDeath verifies the per-session -// belt-and-suspenders sweep: when a publisher session closes ungracefully -// (e.g. the underlying conn dies), the relay's handleConn defer evicts -// every registry entry that referenced it. -// -// We can't directly observe the relay's registries from the test, so we -// assert the externally-visible consequence: a fresh subscriber on a fresh -// session that tries to SUBSCRIBE for the dead publisher's track gets -// RequestDoesNotExist rather than succeeding from stale upstream state. +// TestSessionCleanup_PublisherSessionDeath: after a publisher session dies, a +// SUBSCRIBE for its track is refused DOES_NOT_EXIST rather than served from +// stale upstream state. func TestSessionCleanup_PublisherSessionDeath(t *testing.T) { t.Parallel() @@ -66,15 +60,8 @@ func TestSessionCleanup_PublisherSessionDeath(t *testing.T) { } } -// TestSessionCleanup_SubscriberSessionDeath verifies the dual: when a -// subscriber session dies, its DownstreamSub on a still-active publisher's -// track is evicted. Without the sweep the registry would hold a dangling -// reference to a dead session, and shutdown would have to scan and reap it -// independently. -// -// Observable: the publisher session stays alive and usable; shutdown -// completes cleanly within the test deadline (the harness's `teardown` -// closure would time out otherwise). +// TestSessionCleanup_SubscriberSessionDeath: after a subscriber session dies the +// publisher's session stays usable and teardown completes. func TestSessionCleanup_SubscriberSessionDeath(t *testing.T) { t.Parallel() diff --git a/pkg/relay/session_handler_fault_test.go b/pkg/relay/session_handler_fault_test.go index 9f132fd6..0568b242 100644 --- a/pkg/relay/session_handler_fault_test.go +++ b/pkg/relay/session_handler_fault_test.go @@ -14,27 +14,13 @@ import ( var errRejectWrite = errors.New("transport gone") -// firstRequestStream is the relay-side [sessiontest.FaultOp] stream ordinal of -// a client's first bidirectional request stream. The MoQT control stream is a -// pair of unidirectional streams, so the relay's per-conn ordinals run: 1 the -// inbound control stream it accepts, 2 the outbound one it opens, and 3 the -// first request stream. Faulting by ordinal is only deterministic while the -// test drives one request at a time on that conn. +// firstRequestStream is the relay-side [sessiontest.FaultOp] ordinal of a +// client's first request stream: 1 and 2 are the control streams. It is +// deterministic only while one request at a time runs on the conn. const firstRequestStream = 3 -// TestSessionHandler_FailedRejectWriteKeepsTheSessionAlive pins the contract -// rejectAuth's doc comment states: "Any write failure is logged but otherwise -// swallowed — the stream is being torn down anyway." Swallowed means -// *stream*-scoped. A REQUEST_ERROR the relay cannot deliver must not cost the -// peer its whole session, because §9.5's "one bad request must not break an -// unrelated subscription" is exactly as true when the failure is ours. -// -// Nothing else in the suite reaches that branch: an in-process pipe never -// fails a write, so before [sessiontest.Faulty] the error arm was unreachable -// and turning the swallow into a session close would have gone unnoticed. -// -// The relay's own writes are faulted by ordinal — [firstRequestStream] is the -// stream carrying this REQUEST_ERROR. +// TestSessionHandler_FailedRejectWriteKeepsTheSessionAlive: a REQUEST_ERROR the +// relay cannot write fails only that request, not the peer's session. func TestSessionHandler_FailedRejectWriteKeepsTheSessionAlive(t *testing.T) { t.Parallel() auth := &denyAuthorizer{err: relay.Deny(moqt.RequestUnauthorized, "test denial")} diff --git a/pkg/relay/session_handler_test.go b/pkg/relay/session_handler_test.go index 64deea6c..c7f7b39e 100644 --- a/pkg/relay/session_handler_test.go +++ b/pkg/relay/session_handler_test.go @@ -12,23 +12,8 @@ import ( "github.com/floatdrop/moq-go/pkg/relay" ) -// (Earlier scaffolding tests for SUBSCRIBE / PUBLISH "rejects with -// NotSupported" were removed once those handlers became real. See -// session_pubsub_test.go for the success / no-upstream / aggregation -// tests.) - -// (Namespace-handler success tests live in the namespace test file -// below; the 5b "rejects with NotSupported" cases for PUBLISH_NAMESPACE, -// SUBSCRIBE_NAMESPACE, SUBSCRIBE_TRACKS were removed when those handlers -// became real in 5c.) - -// TestSessionHandler_AuthDenialMapsToRequestError verifies the authorizer -// wiring: a policy that rejects SUBSCRIBE causes the relay to emit a -// REQUEST_ERROR with the policy's chosen code, NOT the placeholder NotSupported. -// -// This pins the precedence: authorization runs BEFORE the not-yet-implemented -// fall-through, so custom policies see their codes on the wire even while the -// handler bodies are stubs. +// TestSessionHandler_AuthDenialMapsToRequestError: an Authorizer rejecting +// SUBSCRIBE produces REQUEST_ERROR with the policy's code. func TestSessionHandler_AuthDenialMapsToRequestError(t *testing.T) { t.Parallel() auth := &denyAuthorizer{ @@ -55,14 +40,129 @@ func TestSessionHandler_AuthDenialMapsToRequestError(t *testing.T) { } } -// TestSessionHandler_DispatchSurvivesPerRequestRejection drives three -// independent SUBSCRIBE requests for unknown tracks on the same session. -// The dispatch loop must reject each in turn without dying — §9.5 forbids -// "a single bad request breaks an unrelated subscription" semantics. -// -// 5d returns RequestDoesNotExist for tracks with no Established upstream; -// this test pins both the rejection code and the loop-survives-rejection -// invariant. +// TestRelay_AuthDenialUsesPolicyCode: each request type consults its own +// Authorizer method once, before any track lookup, and a denial reaches the +// requester as REQUEST_ERROR with the policy's code (UNAUTHORIZED for a plain +// error). +func TestRelay_AuthDenialUsesPolicyCode(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + err error + send func(t *testing.T, sess *session.Session) error + calls func(a *denyAuthorizer) int32 + }{ + { + "SUBSCRIBE", errors.New("token expired"), + func(t *testing.T, sess *session.Session) error { + _, err := sess.Subscribe(t.Context(), &message.Subscribe{Namespace: ns("video"), Name: []byte("cam1")}) + return err + }, + func(a *denyAuthorizer) int32 { return a.subscribeCalls.Load() }, + }, + { + "FETCH", errors.New("no fetch for you"), + func(t *testing.T, sess *session.Session) error { + _, err := sess.Fetch(t.Context(), &message.Fetch{Namespace: ns("video"), Name: []byte("cam1")}) + return err + }, + func(a *denyAuthorizer) int32 { return a.fetchCalls.Load() }, + }, + { + "TRACK_STATUS", errors.New("no status"), + func(t *testing.T, sess *session.Session) error { + _, err := sess.TrackStatus(t.Context(), &message.TrackStatus{Namespace: ns("video"), Name: []byte("cam1")}) + return err + }, + func(a *denyAuthorizer) int32 { return a.trackStatusCalls.Load() }, + }, + { + "PUBLISH_NAMESPACE", relay.Deny(moqt.RequestUnauthorized, "nope"), + func(t *testing.T, sess *session.Session) error { + _, err := sess.PublishNamespace(t.Context(), &message.PublishNamespace{Namespace: ns("video")}) + return err + }, + func(a *denyAuthorizer) int32 { return a.publishNamespaceCalls.Load() }, + }, + { + "SUBSCRIBE_NAMESPACE", relay.Deny(moqt.RequestUnauthorized, "no subscribing"), + func(t *testing.T, sess *session.Session) error { + _, err := sess.SubscribeNamespace(t.Context(), + &message.SubscribeNamespace{TrackNamespacePrefix: ns("video")}) + return err + }, + func(a *denyAuthorizer) int32 { return a.subscribeNamespaceCalls.Load() }, + }, + { + "SUBSCRIBE_TRACKS", relay.Deny(moqt.RequestUnauthorized, "no tracks"), + func(t *testing.T, sess *session.Session) error { + _, err := sess.SubscribeTracks(t.Context(), &message.SubscribeTracks{TrackNamespacePrefix: ns("video")}) + return err + }, + func(a *denyAuthorizer) int32 { return a.subscribeTracksCalls.Load() }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + auth := &denyAuthorizer{err: tc.err} + clientSess, teardown := connectRelay(t, relay.Config{Authorizer: auth}) + defer teardown() + + requireRejectedWithCode(t, tc.send(t, clientSess), moqt.RequestUnauthorized) + if got := tc.calls(auth); got != 1 { + t.Errorf("%s Authorizer calls = %d, want 1", tc.name, got) + } + }) + } +} + +// TestSessionHandler_TokenVerification: a TokenVerifier's denial of a USE_VALUE +// AUTHORIZATION_TOKEN is REQUEST_ERROR with its code, before any track lookup +// (§10.2.2); an accepted token lets the SUBSCRIBE reach its handler, which +// refuses the unknown track DOES_NOT_EXIST. +func TestSessionHandler_TokenVerification(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + token string + want moqt.RequestErrorCode + }{ + {"denied", "expired", moqt.RequestExpiredAuthToken}, + {"allowed", "valid", moqt.RequestDoesNotExist}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + verifier := session.TokenVerifierFunc( + func(_ context.Context, _ *session.Session, tok session.ResolvedToken) error { + if string(tok.Value) == "expired" { + return session.DenyToken(moqt.RequestExpiredAuthToken, "token expired") + } + return nil + }) + clientSess, teardown := connectRelay(t, relay.Config{ + SessionOptions: []session.Option{session.WithTokenVerifier(verifier)}, + }) + defer teardown() + + _, err := clientSess.Subscribe(t.Context(), &message.Subscribe{ + Namespace: ns("video"), + Name: []byte("cam1"), + Parameters: message.Parameters{ + message.AuthorizationTokenParam(message.Token{ + AliasType: message.AliasTypeUseValue, + TokenType: 1, + TokenValue: []byte(tc.token), + }), + }, + }) + requireRejectedWithCode(t, err, tc.want) + }) + } +} + +// TestSessionHandler_DispatchSurvivesPerRequestRejection: three SUBSCRIBEs for +// unknown tracks on one session are each refused DOES_NOT_EXIST, and the +// dispatch loop survives them. func TestSessionHandler_DispatchSurvivesPerRequestRejection(t *testing.T) { t.Parallel() clientSess, teardown := connectRelay(t, relay.Config{}) diff --git a/pkg/relay/session_namespace_test.go b/pkg/relay/session_namespace_test.go index 9475e6c0..5ce07394 100644 --- a/pkg/relay/session_namespace_test.go +++ b/pkg/relay/session_namespace_test.go @@ -127,10 +127,8 @@ func TestPublishNamespace_FanoutsToMatchingSubscriber(t *testing.T) { } } -// TestSubscribeTracks_AcceptedWithoutForwarding: SUBSCRIBE_TRACKS is -// registered and replied OK, but no PUBLISH messages flow yet (those -// arrive via handlePublish). The subscriber's stream stays open and -// silent until the subscriber cancels. +// TestSubscribeTracks_AcceptedWithoutForwarding: SUBSCRIBE_TRACKS with no +// matching track is accepted and its stream stays silent. func TestSubscribeTracks_AcceptedWithoutForwarding(t *testing.T) { t.Parallel() subSess, teardown := connectRelay(t, relay.Config{}) @@ -149,70 +147,8 @@ func TestSubscribeTracks_AcceptedWithoutForwarding(t *testing.T) { } } -// TestPublishNamespace_AuthDenialUsesPolicyCode pins the auth-precedence -// behaviour for the namespace dispatch arm: a custom policy that denies -// PUBLISH_NAMESPACE surfaces its own REQUEST_ERROR code, not REQUEST_OK. -func TestPublishNamespace_AuthDenialUsesPolicyCode(t *testing.T) { - t.Parallel() - auth := &denyAuthorizer{err: relay.Deny(moqt.RequestUnauthorized, "nope")} - clientSess, teardown := connectRelay(t, relay.Config{Authorizer: auth}) - defer teardown() - - _, err := clientSess.PublishNamespace(t.Context(), &message.PublishNamespace{ - Namespace: ns("video"), - }) - requireRejectedWithCode(t, err, moqt.RequestUnauthorized) - if got := auth.publishNamespaceCalls.Load(); got != 1 { - t.Errorf("publishNamespaceCalls = %d, want 1", got) - } -} - -// TestSubscribeNamespace_AuthDenialUsesPolicyCode is -// [TestPublishNamespace_AuthDenialUsesPolicyCode] for the SUBSCRIBE_NAMESPACE -// arm. Each namespace handler calls its own Authorize method and each has its -// own reject-before-register early return, so one arm passing says nothing -// about the others — a handler that skipped the check, or checked after -// registering, would leave this suite green. -func TestSubscribeNamespace_AuthDenialUsesPolicyCode(t *testing.T) { - t.Parallel() - auth := &denyAuthorizer{err: relay.Deny(moqt.RequestUnauthorized, "no subscribing")} - clientSess, teardown := connectRelay(t, relay.Config{Authorizer: auth}) - defer teardown() - - _, err := clientSess.SubscribeNamespace(t.Context(), &message.SubscribeNamespace{ - TrackNamespacePrefix: ns("video"), - }) - requireRejectedWithCode(t, err, moqt.RequestUnauthorized) - if got := auth.subscribeNamespaceCalls.Load(); got != 1 { - t.Errorf("subscribeNamespaceCalls = %d, want 1", got) - } -} - -// TestSubscribeTracks_AuthDenialUsesPolicyCode is the same for the -// SUBSCRIBE_TRACKS arm. -func TestSubscribeTracks_AuthDenialUsesPolicyCode(t *testing.T) { - t.Parallel() - auth := &denyAuthorizer{err: relay.Deny(moqt.RequestUnauthorized, "no tracks")} - clientSess, teardown := connectRelay(t, relay.Config{Authorizer: auth}) - defer teardown() - - _, err := clientSess.SubscribeTracks(t.Context(), &message.SubscribeTracks{ - TrackNamespacePrefix: ns("video"), - }) - requireRejectedWithCode(t, err, moqt.RequestUnauthorized) - if got := auth.subscribeTracksCalls.Load(); got != 1 { - t.Errorf("subscribeTracksCalls = %d, want 1", got) - } -} - -// ----- shared helpers -------------------------------------------------- - -// TestNamespaceStreams_AnswerRequestUpdate pins §10.9 on the namespace -// request streams: the relay previously held them open with a drain that -// discarded follow-ups unparsed, so a peer's REQUEST_UPDATE was never -// answered (the peer blocked until its ctx expired) and its §10.1 Request ID -// was never accounted for. Both the PUBLISH_NAMESPACE and the -// SUBSCRIBE_NAMESPACE streams must now reply REQUEST_OK. +// TestNamespaceStreams_AnswerRequestUpdate: a REQUEST_UPDATE on a +// PUBLISH_NAMESPACE or SUBSCRIBE_NAMESPACE stream gets REQUEST_OK (§10.9). func TestNamespaceStreams_AnswerRequestUpdate(t *testing.T) { t.Parallel() diff --git a/pkg/relay/session_pubsub_test.go b/pkg/relay/session_pubsub_test.go index 20169a60..a3a59493 100644 --- a/pkg/relay/session_pubsub_test.go +++ b/pkg/relay/session_pubsub_test.go @@ -37,10 +37,8 @@ func TestPublish_AcceptedAndRegistered(t *testing.T) { } } -// TestSubscribe_RejectsWhenNoUpstream: when no publisher has touched -// the track AND no namespace match is available, SUBSCRIBE returns -// RequestDoesNotExist (the on-demand upstream subscribe path only -// kicks in when a matching namespace publisher exists). +// TestSubscribe_RejectsWhenNoUpstream: with no publisher of the track and no +// matching namespace publisher, SUBSCRIBE is refused DOES_NOT_EXIST. func TestSubscribe_RejectsWhenNoUpstream(t *testing.T) { t.Parallel() clientSess, teardown := connectRelay(t, relay.Config{}) @@ -53,11 +51,8 @@ func TestSubscribe_RejectsWhenNoUpstream(t *testing.T) { requireRejectedWithCode(t, err, moqt.RequestDoesNotExist) } -// TestSubscribe_ServedFromExistingUpstream is the canonical aggregation -// test: a publisher claims a track, then a subscriber arrives on a separate -// session and receives SUBSCRIBE_OK immediately from the cached upstream -// state. No on-demand upstream subscribe is involved here; the upstream -// was already Established. +// TestSubscribe_ServedFromExistingUpstream: a SUBSCRIBE to an already +// published track is answered from the existing upstream (§9.4). func TestSubscribe_ServedFromExistingUpstream(t *testing.T) { t.Parallel() pubSess, teardown := connectRelay(t, relay.Config{}) @@ -104,12 +99,9 @@ func TestSubscribe_ServedFromExistingUpstream(t *testing.T) { } } -// TestPublish_ForwardsToSubscribeTracks verifies §6.1 / §9.5: when a -// SUBSCRIBE_TRACKS is open and a PUBLISH arrives for a matching namespace, the -// relay forwards the PUBLISH to the subscriber on its OWN new bidirectional -// stream (accepted via AcceptRequest), NOT multiplexed onto the -// SUBSCRIBE_TRACKS request stream. This is the precondition for PUBLISH_SKIPPED -// (§10.21): the forward consumes the subscriber's bidi-stream credit. +// TestPublish_ForwardsToSubscribeTracks: a PUBLISH matching a SUBSCRIBE_TRACKS +// is forwarded on its own new bidi stream, not on the SUBSCRIBE_TRACKS stream +// (§6.1, §9.5). func TestPublish_ForwardsToSubscribeTracks(t *testing.T) { t.Parallel() subSess, teardown := connectRelay(t, relay.Config{}) @@ -166,15 +158,9 @@ func TestPublish_ForwardsToSubscribeTracks(t *testing.T) { } } -// TestPublish_ForwardedAliasDoesNotCollide pins §11.1 for PUBLISH forwarded to -// a SUBSCRIBE_TRACKS holder: "The same Track Alias MUST NOT be used by a -// publisher to refer to two different Tracks simultaneously in the same -// session." Track Aliases are per session, so the relay must allocate the -// forwarded PUBLISH's alias from the subscriber session's own space, shared -// with the aliases it hands out in SUBSCRIBE_OK. Copying the upstream -// publisher's alias collides as soon as those two spaces overlap — here the -// subscriber already holds relay alias 1 for cam1 when a second publisher -// PUBLISHes rtp under its own alias 1. +// TestPublish_ForwardedAliasDoesNotCollide: a forwarded PUBLISH's Track Alias +// comes from the subscriber session's own alias space, so it cannot collide +// with one the relay handed out in SUBSCRIBE_OK (§11.1). func TestPublish_ForwardedAliasDoesNotCollide(t *testing.T) { t.Parallel() pub1, teardown := connectRelay(t, relay.Config{}) @@ -299,10 +285,8 @@ func TestSubscribeTracks_InvalidGroupOrderClosesSession(t *testing.T) { requireSessionClosed(t, subSess, "out-of-range GROUP_ORDER SUBSCRIBE_TRACKS (§10.2.8)") } -// TestPublish_DuplicateAliasRejected pins the §11.1 duplicate-alias rule: -// reusing the same Track Alias on the same session for a different -// {namespace, name} pair must fail. The session-level RegisterInboundTrackAlias -// already enforces this; here we verify the request-level surfacing. +// TestPublish_DuplicateAliasRejected: reusing a Track Alias on the session for +// another track refuses the PUBLISH (§11.1). func TestPublish_DuplicateAliasRejected(t *testing.T) { t.Parallel() clientSess, teardown := connectRelay(t, relay.Config{}) @@ -326,12 +310,9 @@ func TestPublish_DuplicateAliasRejected(t *testing.T) { requireRejectedWithCode(t, err, moqt.RequestMalformedTrack) } -// TestSubscribe_OnDemandUpstreamSubscribe is the canonical test for the -// on-demand upstream subscribe path: a -// publisher advertises a namespace via PUBLISH_NAMESPACE; a subscriber on a -// different session asks for a track under that namespace; the relay must -// issue an upstream SUBSCRIBE to the publisher, wait for SUBSCRIBE_OK, and -// only then reply SUBSCRIBE_OK downstream. +// TestSubscribe_OnDemandUpstreamSubscribe: a SUBSCRIBE under a published +// namespace makes the relay SUBSCRIBE upstream and answer downstream only after +// the upstream SUBSCRIBE_OK. func TestSubscribe_OnDemandUpstreamSubscribe(t *testing.T) { t.Parallel() pubSess, teardown := connectRelay(t, relay.Config{}) @@ -394,11 +375,8 @@ func TestSubscribe_OnDemandUpstreamSubscribe(t *testing.T) { } } -// upstreamForwardValue runs the publisher side of one on-demand upstream -// SUBSCRIBE and reports the FORWARD parameter the relay sent: (0/1, present) -// when FORWARD is on the SUBSCRIBE, or (0, false) when it is omitted (§10.2.18 -// default 1). It replies SUBSCRIBE_OK and drains follow-ups. The result arrives -// on the returned channel once the relay's upstream SUBSCRIBE is accepted. +// upstreamForwardValue answers one upstream SUBSCRIBE on pubSess and delivers +// the FORWARD it carried as (value, present); absent means 1 (§10.2.18). func upstreamForwardValue(t *testing.T, pubSess *session.Session) <-chan [2]int { t.Helper() out := make(chan [2]int, 1) @@ -502,10 +480,9 @@ func TestSubscribe_UpstreamForwardOmittedWhenDownstreamForwards(t *testing.T) { } } -// TestSubscribe_UpstreamResumedWhenForwardingSubscriberJoins pins §9.2: a -// Forward=0 subscriber establishes a paused (Forward=0) upstream; when a second -// Forward=1 subscriber reuses that upstream, the relay MUST resume it by -// sending an upstream REQUEST_UPDATE with Forward=1. +// TestSubscribe_UpstreamResumedWhenForwardingSubscriberJoins: a paused upstream +// is resumed with REQUEST_UPDATE FORWARD=1 when a Forward=1 subscriber joins +// (§9.2). func TestSubscribe_UpstreamResumedWhenForwardingSubscriberJoins(t *testing.T) { t.Parallel() pubSess, teardown := connectRelay(t, relay.Config{}) @@ -606,10 +583,8 @@ func TestSubscribe_UpstreamResumedWhenForwardingSubscriberJoins(t *testing.T) { } } -// acceptUpstreamSubscribe runs the publisher side of one on-demand upstream -// SUBSCRIBE: accept the relay's request, reply SUBSCRIBE_OK with the given -// alias, then drain follow-ups. The returned channel closes when the relay -// ends the subscription (reset / FIN errors the drain). +// acceptUpstreamSubscribe answers one upstream SUBSCRIBE on pubSess with alias +// and drains its follow-ups; the channel closes when the relay ends it. func acceptUpstreamSubscribe(t *testing.T, pubSess *session.Session, alias uint64) <-chan struct{} { t.Helper() ended := make(chan struct{}) @@ -632,11 +607,8 @@ func acceptUpstreamSubscribe(t *testing.T, pubSess *session.Session, alias uint6 return ended } -// TestSubscribe_UpstreamSurvivesInitiatingSubscriber is the §9.4 aggregation -// lifetime test: subscriber A triggers the on-demand upstream SUBSCRIBE, -// subscriber B reuses it, then A's whole session goes away. The upstream -// subscription serves B, so it must survive — B keeps receiving objects and -// does NOT get a spurious PUBLISH_DONE "upstream gone". +// TestSubscribe_UpstreamSurvivesInitiatingSubscriber: an on-demand upstream +// outlives the subscriber that triggered it while another still uses it (§9.4). func TestSubscribe_UpstreamSurvivesInitiatingSubscriber(t *testing.T) { t.Parallel() closed := &recordingMetrics{} @@ -726,10 +698,8 @@ func TestSubscribe_UpstreamSurvivesInitiatingSubscriber(t *testing.T) { } } -// TestSubscribe_LastDownstreamTearsDownUpstream pins the inverse lifetime -// rule: when the LAST downstream subscriber of an on-demand upstream leaves, -// the relay ends its upstream subscription (closes the request stream, -// §10.7) instead of letting the publisher stream into a void forever. +// TestSubscribe_LastDownstreamTearsDownUpstream: when the last downstream +// subscriber leaves, the relay ends its upstream subscription. func TestSubscribe_LastDownstreamTearsDownUpstream(t *testing.T) { t.Parallel() pubSess, teardown := connectRelay(t, relay.Config{}) @@ -765,10 +735,8 @@ func TestSubscribe_LastDownstreamTearsDownUpstream(t *testing.T) { } } -// TestSubscribe_NoMatchingPublisher_RejectsDoesNotExist pins the 5e -// fallback: if no PUBLISH_NAMESPACE matches, the relay still rejects with -// RequestDoesNotExist. The Discovery Store path will relax this for -// cross-relay tracks. +// TestSubscribe_NoMatchingPublisher_RejectsDoesNotExist: with no matching +// PUBLISH_NAMESPACE, SUBSCRIBE is refused DOES_NOT_EXIST. func TestSubscribe_NoMatchingPublisher_RejectsDoesNotExist(t *testing.T) { t.Parallel() pubSess, teardown := connectRelay(t, relay.Config{}) @@ -790,16 +758,9 @@ func TestSubscribe_NoMatchingPublisher_RejectsDoesNotExist(t *testing.T) { requireRejectedWithCode(t, err, moqt.RequestDoesNotExist) } -// TestSubscribe_UpstreamRejects_PropagatesRejection: when the upstream -// publisher rejects the relay's SUBSCRIBE, the downstream subscriber gets a -// REQUEST_ERROR whose code is chosen by meaning (§10.6.2 "The application -// SHOULD use a relevant error code"). A code about the track or the -// publisher's load passes through; one about the relay's own hop (its -// authorization, the upstream going away, a redirect it does not follow) or -// about the relay's own Next Object filter says nothing true about the -// downstream request, and becomes INTERNAL_ERROR. Either way the upstream's -// Retry Interval is kept: "retry in N ms" must not turn into "SHOULD NOT be -// retried". +// TestSubscribe_UpstreamRejects_PropagatesRejection: an upstream REQUEST_ERROR +// code about the track passes downstream; one about the relay's own hop becomes +// INTERNAL_ERROR (§10.6.2). The Retry Interval is kept either way. func TestSubscribe_UpstreamRejects_PropagatesRejection(t *testing.T) { t.Parallel() for _, tc := range []struct { @@ -852,32 +813,9 @@ func TestSubscribe_UpstreamRejects_PropagatesRejection(t *testing.T) { } } -// TestSubscribe_AuthDenialUsesPolicyCode pins auth precedence on the -// SUBSCRIBE arm even when the track does not exist locally — the auth check -// runs before the track lookup. -func TestSubscribe_AuthDenialUsesPolicyCode(t *testing.T) { - t.Parallel() - auth := &denyAuthorizer{err: errors.New("token expired")} - clientSess, teardown := connectRelay(t, relay.Config{Authorizer: auth}) - defer teardown() - - _, err := clientSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: ns("video"), - Name: []byte("cam1"), - }) - requireRejectedWithCode(t, err, moqt.RequestUnauthorized) - if got := auth.subscribeCalls.Load(); got != 1 { - t.Errorf("subscribeCalls = %d, want 1", got) - } -} - -// TestSubscribe_PublisherDisappears_EmitsPublishDone pins §10.12 -// publisher-side termination. When the upstream publisher's session -// dies (here: we explicitly close the publisher session), the relay -// must notify every dependent downstream subscriber by writing a -// PUBLISH_DONE message with [moqt.PublishDoneTrackEnded] on each -// subscriber's request stream. Before this fix, the subscriber would -// see an idle stream that never produced another byte. +// TestSubscribe_PublisherDisappears_EmitsPublishDone: when the publisher's +// session ends, each downstream subscriber gets PUBLISH_DONE TRACK_ENDED +// (§10.12). func TestSubscribe_PublisherDisappears_EmitsPublishDone(t *testing.T) { t.Parallel() pubSess, teardown := connectRelay(t, relay.Config{}) @@ -928,11 +866,8 @@ func TestSubscribe_PublisherDisappears_EmitsPublishDone(t *testing.T) { } } -// TestSubscribe_PublisherDisappears_StreamClosesAfterPublishDone pins -// the second half of the contract: after the relay sends -// PUBLISH_DONE it must also FIN the request stream so the subscriber -// can release its handler. message.Parse on a FIN'd stream returns -// io.EOF after the last message is consumed. +// TestSubscribe_PublisherDisappears_StreamClosesAfterPublishDone: the relay +// FINs the request stream after PUBLISH_DONE. func TestSubscribe_PublisherDisappears_StreamClosesAfterPublishDone(t *testing.T) { t.Parallel() pubSess, teardown := connectRelay(t, relay.Config{}) @@ -981,13 +916,9 @@ func TestSubscribe_PublisherDisappears_StreamClosesAfterPublishDone(t *testing.T } } -// TestSubscribe_NoAliasCollisionWhenAlsoPublishing is a regression test for a -// conferencing client that PUBLISHes and SUBSCRIBEs on the same session. The -// relay's outbound alias space (used to deliver tracks downstream) is -// independent of the inbound aliases the client chose for its own PUBLISHes -// (§11.1). Both spaces start at 0, so a session that publishes alias 0 and -// then subscribes to a peer track (relay allocates outbound alias 0) must not -// see a spurious "alias collision" that rejects the SUBSCRIBE. +// TestSubscribe_NoAliasCollisionWhenAlsoPublishing: the relay's outbound alias +// space is independent of the aliases a session PUBLISHes with (§11.1), so +// both starting at 0 is no collision. func TestSubscribe_NoAliasCollisionWhenAlsoPublishing(t *testing.T) { t.Parallel() clientSess, teardown := connectRelay(t, relay.Config{}) @@ -1029,16 +960,9 @@ func TestSubscribe_NoAliasCollisionWhenAlsoPublishing(t *testing.T) { defer subStream.Close() } -// TestPublish_SavesLargestObjectFromPublish pins §10.2.17 item 1 on the PUBLISH -// path: a LARGEST_OBJECT on an inbound PUBLISH is one of the values the relay's -// own watermark MUST be the largest of, so it has to reach the track entry even -// though no object has arrived yet. -// -// Observable through the next SUBSCRIBE: §10.2.17 requires the relay to include -// LARGEST_OBJECT once objects exist on the track, and that value is the -// subscriber's live edge, which §5.1.3 has it size a fill against. Before the fix the -// parameter was dropped, so the relay claimed to know nothing and the backfill -// was unreachable. +// TestPublish_SavesLargestObjectFromPublish: LARGEST_OBJECT on an inbound +// PUBLISH feeds the relay's watermark before any Object arrives, and the next +// SUBSCRIBE_OK carries it (§10.2.17). func TestPublish_SavesLargestObjectFromPublish(t *testing.T) { t.Parallel() pubSess, teardown := connectRelay(t, relay.Config{}) @@ -1073,19 +997,9 @@ func TestPublish_SavesLargestObjectFromPublish(t *testing.T) { } } -// TestPublish_ForwardedPublishCarriesEntryLargestObject pins the other half of -// §10.2.17 for the PUBLISH-forwarding path: the relay MUST send the largest of -// everything it has observed, so the LARGEST_OBJECT on a PUBLISH it generates for -// a SUBSCRIBE_TRACKS holder is re-derived from the track entry rather than copied -// through from the upstream's own PUBLISH. -// -// Two publishers on one track (§9.5) is what separates the two behaviours. The -// second announces a *lower* watermark than the first, so copying it through -// would advertise {3,4} when the relay has already observed {9,9} — a value below -// its own maximum, which is exactly what §10.2.17 forbids. With one publisher the -// two readings coincide and the bug is invisible, which is why this test needs -// the second one. The subscriber arrives after both, and gets one PUBLISH for -// the track. +// TestPublish_ForwardedPublishCarriesEntryLargestObject: a forwarded PUBLISH +// carries the largest LARGEST_OBJECT the relay observed, not the upstream +// PUBLISH's own (§10.2.17). Two publishers, the second with a lower value. func TestPublish_ForwardedPublishCarriesEntryLargestObject(t *testing.T) { t.Parallel() pubA, teardown := connectRelay(t, relay.Config{}) diff --git a/pkg/relay/session_update_test.go b/pkg/relay/session_update_test.go index 6a56e3c1..1714d32b 100644 --- a/pkg/relay/session_update_test.go +++ b/pkg/relay/session_update_test.go @@ -14,12 +14,8 @@ import ( "github.com/floatdrop/moq-go/pkg/relay/internal/relaytest" ) -// TestRequestUpdate_PriorityChangeReturnsOK pins the §10.9 control-plane -// contract: a REQUEST_UPDATE carrying a well-formed parameter change (here -// SUBSCRIBER_PRIORITY) on an established SUBSCRIBE stream is answered with -// exactly one REQUEST_OK. The update rides the original bidi stream but -// consumes a fresh Request ID from the sender's space (§10.1), which -// Subscription.Update allocates. +// TestRequestUpdate_PriorityChangeReturnsOK: a well-formed REQUEST_UPDATE on a +// SUBSCRIBE gets exactly one REQUEST_OK (§10.9). func TestRequestUpdate_PriorityChangeReturnsOK(t *testing.T) { t.Parallel() @@ -58,12 +54,9 @@ func TestRequestUpdate_PriorityChangeReturnsOK(t *testing.T) { } } -// TestRequestUpdate_MalformedRejectedWithUpdateFailed pins the §10.9 failure -// path: a REQUEST_UPDATE whose parameters are malformed but request-scoped -// (here a LOCATION_FILTER whose AbsoluteRange overflows, §5.1.2 — kept -// request-scoped for now, unlike GROUP_ORDER/FORWARD which close the session) -// is answered with REQUEST_ERROR, and the relay MUST additionally terminate the -// subscription with a PUBLISH_DONE carrying the UPDATE_FAILED (0x8) status code. +// TestRequestUpdate_MalformedRejectedWithUpdateFailed: a request-scoped +// malformed update (an overflowing AbsoluteRange, §5.1.2) gets REQUEST_ERROR, +// then PUBLISH_DONE UPDATE_FAILED (§10.9.1). func TestRequestUpdate_MalformedRejectedWithUpdateFailed(t *testing.T) { t.Parallel() @@ -113,10 +106,8 @@ func TestRequestUpdate_MalformedRejectedWithUpdateFailed(t *testing.T) { } } -// TestRequestUpdate_InvalidGroupOrderClosesSession pins §10.2.8: an -// out-of-range GROUP_ORDER in a REQUEST_UPDATE is a session-level -// PROTOCOL_VIOLATION — the wire value is invalid, so it supersedes §10.9's -// request-scoped update-failure path and the relay closes the whole session. +// TestRequestUpdate_InvalidGroupOrderClosesSession: an out-of-range GROUP_ORDER +// in a REQUEST_UPDATE closes the session (§10.2.8). func TestRequestUpdate_InvalidGroupOrderClosesSession(t *testing.T) { t.Parallel() @@ -151,11 +142,9 @@ func TestRequestUpdate_InvalidGroupOrderClosesSession(t *testing.T) { requireSessionClosed(t, subSess, "out-of-range GROUP_ORDER REQUEST_UPDATE (§10.2.8)") } -// TestRequestUpdate_ForwardPauseAndResume is the §9.2 data-plane test: -// flipping a subscription's Forward State to 0 via REQUEST_UPDATE pauses -// object delivery (the relay stops forwarding), and flipping it back to 1 -// resumes delivery. Objects published while paused are not delivered; objects -// published after resume are. +// TestRequestUpdate_ForwardPauseAndResume: FORWARD=0 pauses delivery and +// FORWARD=1 resumes it; Objects published while paused are not delivered +// (§9.2). func TestRequestUpdate_ForwardPauseAndResume(t *testing.T) { t.Parallel() @@ -276,11 +265,8 @@ func TestRequestUpdate_ForwardPauseAndResume(t *testing.T) { t.Fatal("no object delivered within 2s of Forward State 1 (resume failed)") } - // Regression: the Forward 0→1 flip above runs the §9.2 upstream - // propagation path. It used to wedge the relay's update-dispatch loop - // (an upstream REQUEST_UPDATE awaited a response the drain goroutine - // swallowed — and a leaf publisher never answers at all), so any later - // update was never processed. A third update must still get REQUEST_OK. + // The Forward 0→1 flip ran the §9.2 upstream propagation path, which must + // not wedge the update-dispatch loop: a third update still gets REQUEST_OK. ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) defer cancel() if _, err := subSess.UpdateRequest(ctx, subStream, @@ -289,13 +275,8 @@ func TestRequestUpdate_ForwardPauseAndResume(t *testing.T) { } } -// TestRequestUpdate_FetchValidUpdateReturnsOK pins the §10.9 FETCH arm of the -// control-plane contract: a well-formed REQUEST_UPDATE (here a GROUP_ORDER -// change) on an established FETCH request stream is answered with exactly one -// REQUEST_OK. A FETCH response is a finished snapshot by the time its data -// stream is FIN'd, so the relay has no live parameters left to mutate — but it -// must still honour the single mandated REQUEST_OK / REQUEST_ERROR reply. The -// update reuses the FETCH's original Request ID on the same bidi stream. +// TestRequestUpdate_FetchValidUpdateReturnsOK: a well-formed REQUEST_UPDATE on +// a FETCH gets exactly one REQUEST_OK (§10.9). func TestRequestUpdate_FetchValidUpdateReturnsOK(t *testing.T) { t.Parallel() pubSess, _, publisherAlias := publishAndCache(t) @@ -346,11 +327,8 @@ func TestRequestUpdate_FetchValidUpdateReturnsOK(t *testing.T) { } } -// TestRequestUpdate_InvalidRequestIDClosesSession pins the §10.1 receiver -// rule on follow-ups: a REQUEST_UPDATE consumes a Request ID from the -// sender's space, so one whose ID has the wrong parity for the sender (a -// client must use even IDs; here it sends an odd one) is a session-fatal -// INVALID_REQUEST_ID, not a per-request error. +// TestRequestUpdate_InvalidRequestIDClosesSession: a REQUEST_UPDATE whose +// Request ID has the wrong parity for its sender closes the session (§10.1). func TestRequestUpdate_InvalidRequestIDClosesSession(t *testing.T) { t.Parallel() diff --git a/pkg/relay/shared_alias_test.go b/pkg/relay/shared_alias_test.go index a31d9184..81e1835f 100644 --- a/pkg/relay/shared_alias_test.go +++ b/pkg/relay/shared_alias_test.go @@ -10,12 +10,9 @@ import ( "github.com/floatdrop/moq-go/pkg/relay" ) -// TestRelay_SharedTrackAliasSurvivesFirstSubscriptionEnd: §5.1 "A publisher -// MAY assign the same or different Track Aliases to these subscriptions" — -// concurrent subscriptions to the same Track. Two downstream SUBSCRIBEs racing -// for a track with no upstream yet send two upstream SUBSCRIBEs to the -// publisher; answered with the same alias, the survivor must keep routing when -// the first one ends. +// TestRelay_SharedTrackAliasSurvivesFirstSubscriptionEnd: two racing upstream +// SUBSCRIBEs answered with the same Track Alias (§5.1) keep routing after the +// first one ends. func TestRelay_SharedTrackAliasSurvivesFirstSubscriptionEnd(t *testing.T) { t.Parallel() const alias = uint64(42) diff --git a/pkg/relay/shutdown_straggler_test.go b/pkg/relay/shutdown_straggler_test.go index 983c0b47..72675704 100644 --- a/pkg/relay/shutdown_straggler_test.go +++ b/pkg/relay/shutdown_straggler_test.go @@ -21,14 +21,9 @@ func (stragglerListener) Accept(ctx context.Context) (session.Conn, error) { func (stragglerListener) Addr() net.Addr { return nil } func (stragglerListener) Close() error { return nil } -// TestRelay_addSessionDrainsStraggler pins the straggler partition that -// beginShutdown + addSession enforce. A session that registers AFTER Stop has -// snapshotted the live-session set (so the snapshot misses it) must still be -// driven through the full GOAWAY / grace / force-close lifecycle — by -// addSession's drainStraggler, since Stop's bulk drain never saw it. -// -// This is the path that the deleted per-session stopWatch goroutine used to -// cover; the test guards against a regression in the move to drainStraggler. +// TestRelay_addSessionDrainsStraggler: a session registered after Stop took its +// snapshot still goes through GOAWAY, grace and force-close, via +// addSession's drainStraggler. func TestRelay_addSessionDrainsStraggler(t *testing.T) { t.Parallel() const grace = 150 * time.Millisecond diff --git a/pkg/relay/subscribe_tracks_test.go b/pkg/relay/subscribe_tracks_test.go index 93dc6f65..fba88389 100644 --- a/pkg/relay/subscribe_tracks_test.go +++ b/pkg/relay/subscribe_tracks_test.go @@ -17,6 +17,8 @@ import ( // alias it chose (§10.11), REQUEST_UPDATE answered (§10.9), REQUEST_ERROR ends // it, PUBLISH_DONE closes it (§10.12). +// TestForwardedPublish_DeliversObjects: Objects of a forwarded track arrive on +// the alias of the forwarded PUBLISH (§10.11). func TestForwardedPublish_DeliversObjects(t *testing.T) { t.Parallel() subSess, teardown := connectRelay(t, relay.Config{}) @@ -535,6 +537,8 @@ func priorityTrackProps() []wire.KVPair { // noProps is INCLUDE_PROPERTIES=0. func noProps() message.Parameter { return message.IncludePropertiesParam(false) } +// TestIncludeProperties_SubscribeOK: SUBSCRIBE_OK has empty Track Properties, +// and forwarded subgroups carry the priority inline. func TestIncludeProperties_SubscribeOK(t *testing.T) { t.Parallel() pubSess, alias := newCam1Publisher(t, priorityTrackProps()) @@ -562,6 +566,8 @@ func TestIncludeProperties_SubscribeOK(t *testing.T) { } } +// TestIncludeProperties_Datagram: forwarded datagrams carry the priority +// explicitly. func TestIncludeProperties_Datagram(t *testing.T) { t.Parallel() pubSess, alias := newCam1Publisher(t, priorityTrackProps()) @@ -584,6 +590,8 @@ func TestIncludeProperties_Datagram(t *testing.T) { } } +// TestIncludeProperties_FetchAndTrackStatus: TRACK_STATUS_OK and FETCH_OK have +// empty Track Properties. func TestIncludeProperties_FetchAndTrackStatus(t *testing.T) { t.Parallel() pubSess, alias := newCam1Publisher(t, priorityTrackProps()) @@ -618,6 +626,8 @@ func TestIncludeProperties_FetchAndTrackStatus(t *testing.T) { go drainAll(t.Context(), c) } +// TestIncludeProperties_SubscribeTracks: the forwarded PUBLISH has empty Track +// Properties, and its subgroups carry the priority inline. func TestIncludeProperties_SubscribeTracks(t *testing.T) { t.Parallel() holder, teardown := connectRelay(t, relay.Config{}) diff --git a/pkg/relay/token_verify_test.go b/pkg/relay/token_verify_test.go deleted file mode 100644 index 4358c7b8..00000000 --- a/pkg/relay/token_verify_test.go +++ /dev/null @@ -1,71 +0,0 @@ -package relay_test - -import ( - "context" - "testing" - - "github.com/floatdrop/moq-go/pkg/moqt" - "github.com/floatdrop/moq-go/pkg/moqt/message" - "github.com/floatdrop/moq-go/pkg/moqt/session" - "github.com/floatdrop/moq-go/pkg/relay" -) - -// TestSessionHandler_TokenDenialMapsToRequestError verifies the token-verifier -// wiring end to end: a SUBSCRIBE carrying a USE_VALUE AUTHORIZATION_TOKEN is -// rejected by a session-level TokenVerifier, and the relay turns that denial -// into a REQUEST_ERROR with the verifier's chosen code (§10.2.2) — before any -// track lookup runs, so the code is the token code, not RequestDoesNotExist. -func TestSessionHandler_TokenDenialMapsToRequestError(t *testing.T) { - t.Parallel() - verifier := session.TokenVerifierFunc(func(_ context.Context, _ *session.Session, tok session.ResolvedToken) error { - if string(tok.Value) == "expired" { - return session.DenyToken(moqt.RequestExpiredAuthToken, "token expired") - } - return nil - }) - clientSess, teardown := connectRelay(t, relay.Config{ - SessionOptions: []session.Option{session.WithTokenVerifier(verifier)}, - }) - defer teardown() - - _, err := clientSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: ns("video"), - Name: []byte("cam1"), - Parameters: message.Parameters{ - message.AuthorizationTokenParam(message.Token{ - AliasType: message.AliasTypeUseValue, - TokenType: 1, - TokenValue: []byte("expired"), - }), - }, - }) - requireRejectedWithCode(t, err, moqt.RequestExpiredAuthToken) -} - -// TestSessionHandler_TokenAllowReachesHandler verifies that a token the -// verifier accepts does not short-circuit dispatch: the request reaches the -// SUBSCRIBE handler, which (with no upstream) rejects with RequestDoesNotExist. -// This proves the verifier authorizes rather than blanket-denies. -func TestSessionHandler_TokenAllowReachesHandler(t *testing.T) { - t.Parallel() - verifier := session.TokenVerifierFunc(func(_ context.Context, _ *session.Session, _ session.ResolvedToken) error { - return nil // accept everything - }) - clientSess, teardown := connectRelay(t, relay.Config{ - SessionOptions: []session.Option{session.WithTokenVerifier(verifier)}, - }) - defer teardown() - - _, err := clientSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: ns("video"), - Name: []byte("cam1"), - Parameters: message.Parameters{ - message.AuthorizationTokenParam(message.Token{ - AliasType: message.AliasTypeUseValue, - TokenType: 1, - TokenValue: []byte("valid"), - }), - }, - }) - requireRejectedWithCode(t, err, moqt.RequestDoesNotExist) -} From c41519d1839dd7cc13d62d4e04c3200f86bc0385 Mon Sep 17 00:00:00 2001 From: Vsevolod Strukchinsky Date: Sat, 26 Sep 2026 10:43:50 +0500 Subject: [PATCH 12/12] =?UTF-8?q?docs:=20review=20follow-ups=20on=20the=20?= =?UTF-8?q?comment=20trim=20=E2=80=94=20restore=20markers,=20contracts=20a?= =?UTF-8?q?nd=20invariants?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the moqt-reviewer pass over the cleanup branch (comment lines only): - Restore the "Assumption:" on RegisterInboundTrack: the draft does not say which Track Properties a shared alias carries (the trim had presented the repo's choice as §2.5). - Restore two exported-API details: OpenPublish's caller allocates the alias, and AcceptPublish's refusal codes (UNSUPPORTED_EXTENSION for an unknown Mandatory Track Property, MALFORMED_TRACK for unparseable properties). - Restore four invariants the code does not show: - why the relay's upstream sub is set to Forward=0; - the trackKnown coupling for the eagerly created entry; - RunWriter taking one message at a time for the queue bound; - why UpdatePrefix's old-prefix lookup is exact. - Citations: - the remaining omitted-FORWARD §10.7 → §10.2.18; - multi-publisher dedup and redundant upstreams §9.5 → §9.3, matching the tests; - the §5.1.5 quote corrected. - §11.4.3: mark the relay's use of only "one greater than the previous Object" as a choice among the draft's three next-Object tests. - Fix a stale [subgroupWriter.join] link, and reflow three overlong comment lines. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/moqt/session/request.go | 10 ++++++---- pkg/moqt/session/trackalias.go | 14 +++++++++----- pkg/relay/handler_fanout.go | 10 ++++++---- pkg/relay/handler_malformed.go | 2 +- pkg/relay/handler_subscribe.go | 10 +++++++--- pkg/relay/internal/registry/namespace_state.go | 9 ++++++--- pkg/relay/internal/registry/subscription.go | 7 +++---- pkg/relay/internal/registry/subscription_test.go | 4 ++-- pkg/relay/internal/registry/track_entry.go | 4 ++-- 9 files changed, 42 insertions(+), 28 deletions(-) diff --git a/pkg/moqt/session/request.go b/pkg/moqt/session/request.go index 35aef8e7..52c83882 100644 --- a/pkg/moqt/session/request.go +++ b/pkg/moqt/session/request.go @@ -798,10 +798,12 @@ func (r *Request) AcceptSubscribe(ok *message.SubscribeOK) (*Publication, error) // r.First MUST be a *message.Publish. // // Track Properties that fail validation (see -// [WithKnownMandatoryTrackProperties]) are rejected with REQUEST_ERROR and the -// error returned. On an alias collision *ErrDuplicateTrackAlias is returned -// without replying; the caller MUST close the session with -// [moqt.SessionDuplicateTrackAlias] (§11.1). +// [WithKnownMandatoryTrackProperties]) are rejected with REQUEST_ERROR — +// UNSUPPORTED_EXTENSION for an unknown Mandatory Track Property (§2.5.1), +// MALFORMED_TRACK for ones that do not parse — and the error returned. On an +// alias collision *ErrDuplicateTrackAlias is returned without replying; the +// caller MUST close the session with [moqt.SessionDuplicateTrackAlias] +// (§11.1). func (r *Request) AcceptPublish() (*IncomingPublication, error) { pub, isPub := r.First.(*message.Publish) if !isPub { diff --git a/pkg/moqt/session/trackalias.go b/pkg/moqt/session/trackalias.go index 1084e002..d515ba9f 100644 --- a/pkg/moqt/session/trackalias.go +++ b/pkg/moqt/session/trackalias.go @@ -15,7 +15,7 @@ import ( // // Allocation starts at 1: [Session.Publish] and [Request.AcceptSubscribe] // treat a zero TrackAlias as "allocate one for me", so an allocated 0 would be -// silently replaced. +// silently replaced. [Session.OpenPublish] does not: its caller allocates. func (s *Session) AllocOutboundTrackAlias() uint64 { return s.nextOutboundTrackAlias.Add(1) } @@ -59,10 +59,14 @@ type InboundTrack struct { // // Registering an alias again for the same track counts one more registration // (§5.1 allows subscriptions to share an alias); it stays registered until -// each is released by [Session.UnregisterInboundTrackAlias]. The latest Track -// Properties replace earlier ones (§2.5). If alias is registered for a -// different track, *ErrDuplicateTrackAlias is returned and the caller MUST -// close the session with SessionDuplicateTrackAlias (§11.1). +// each is released by [Session.UnregisterInboundTrackAlias]. Assumption: the +// latest Track Properties replace earlier ones — the draft does not say which +// a shared alias carries, and a release cannot tell which registration it +// ends, so the survivor may keep a released one's properties. +// +// If alias is registered for a different track, *ErrDuplicateTrackAlias is +// returned and the caller MUST close the session with +// SessionDuplicateTrackAlias (§11.1). func (s *Session) RegisterInboundTrack(alias uint64, key track.Key, trackProperties []byte) error { in := InboundTrack{ Key: key, diff --git a/pkg/relay/handler_fanout.go b/pkg/relay/handler_fanout.go index 15eb1255..c8bd73ab 100644 --- a/pkg/relay/handler_fanout.go +++ b/pkg/relay/handler_fanout.go @@ -154,7 +154,7 @@ func (h *sessionHandler) resolveInboundTrack( // runFanout forwards one inbound subgroup stream to every downstream // subscriber, remapping the Track Alias per subscriber. // -// §9.5: inbound streams carrying the same (GroupID, SubgroupID) share one +// §9.3: inbound streams carrying the same (GroupID, SubgroupID) share one // outbound writer per subscriber (§2.2: a Subgroup is not split across // streams), and [registry.TrackEntry.ClaimDelivered] drops duplicate objects // (§2.1). Each writer is a [subgroupWriter] goroutine behind a bounded queue. @@ -224,7 +224,7 @@ func (h *sessionHandler) runFanout(ctx context.Context, stream *session.Incoming } // This contributor's termination, applied outbound only if it is the last - // to leave the Subgroup (§9.5). + // to leave the Subgroup (§9.3). var ( inboundReset bool inboundResetCode = moqt.StreamResetCancelled @@ -409,7 +409,7 @@ func (h *sessionHandler) openWriterForSub( subHdr.InlinePriority = true } // cancelIO unblocks a writer wedged on a subscriber that stopped - // reading (see [subgroupWriter.join]). + // reading (see [joinWriters]). ioCtx, cancelIO := context.WithCancel(ctx) w := &subgroupWriter{ sub: sub, @@ -712,7 +712,9 @@ func (w *subgroupWriter) run() { } } - // §11.4.3: no non-consecutive Object on an existing stream. + // §11.4.3: only "the next Object" may go on an existing stream. Of + // the draft's three ways to tell, this relay uses only "one greater + // than the previous Object" (a choice) and reopens on any other gap. if hasWritten && fwd.absID != prevID+1 { w.metrics.SubgroupStreamReset(w.ref, w.hdr.SubgroupID, ResetCauseGap) if !reopen(fwd.first) { diff --git a/pkg/relay/handler_malformed.go b/pkg/relay/handler_malformed.go index e250084a..d4b704ab 100644 --- a/pkg/relay/handler_malformed.go +++ b/pkg/relay/handler_malformed.go @@ -12,7 +12,7 @@ import ( // endMalformedTrack handles a malformed track (§2.4.2) detected in an Object // src sent on entry's track: every downstream subscription ends with // PUBLISH_DONE MALFORMED_TRACK, and only the upstreams on src are cancelled, -// so a redundant publisher (§9.5) keeps serving. Callers never cache the +// so a redundant publisher (§9.3) keeps serving. Callers never cache the // Object. Open subgroup streams are reset now, since PUBLISH_DONE waits for // them (§10.12). Downstream fetch streams are not reset. // diff --git a/pkg/relay/handler_subscribe.go b/pkg/relay/handler_subscribe.go index 2d060505..9211345d 100644 --- a/pkg/relay/handler_subscribe.go +++ b/pkg/relay/handler_subscribe.go @@ -461,7 +461,8 @@ func (h *sessionHandler) subscribeUpstreamOnSession( // Create the entry before Subscribe: the alias resolves as soon as // Subscribe returns and streams may already be arriving, which runFanout // can only route to an existing entry. Not inside the round trip, which - // would widen the window streams wait for their alias. + // would widen the window streams wait for their alias. handleFetch's + // trackKnown keeps the empty entry off the wire meanwhile. var entryCreated bool _, entryCreated = h.tracks.GetOrCreateNew(fullName) @@ -482,6 +483,8 @@ func (h *sessionHandler) subscribeUpstreamOnSession( upstreamSub := registry.NewUpstreamSub( h.allocSubID(), sess, upstreamStream, upstreamStream.OK.TrackAlias, subMsg.RequestID, false) upstreamSub.SetFilter(filter) + // Match the Forward=0 sent upstream: NewUpstreamSub starts at 1, and a + // later §9.2 resume skips upstreams already at 1. if !wantForward { upstreamSub.SetForwardState(0) } @@ -680,8 +683,9 @@ func includeProperties(ps message.Parameters) bool { // unparseable Track Properties are MALFORMED_TRACK (an interpretation: the // draft does not cover them). An upstream REQUEST_ERROR code about the track // or the publisher's load passes through with its Retry Interval (§10.6.2), -// MALFORMED_TRACK included though §10.6.2 scopes it to FETCH; one about the relay's own hop or its Next Object filter, or an unknown one, -// becomes INTERNAL_ERROR. If the relay ever combines downstream filters +// MALFORMED_TRACK included though §10.6.2 scopes it to FETCH; one about the +// relay's own hop or its Next Object filter, or an unknown one, becomes +// INTERNAL_ERROR. If the relay ever combines downstream filters // upstream (§9.4), INVALID_RANGE must pass through too. Any other failure // reads as DOES_NOT_EXIST. func upstreamRejection(err error) *session.RequestRejectedError { diff --git a/pkg/relay/internal/registry/namespace_state.go b/pkg/relay/internal/registry/namespace_state.go index 9e9cdf61..59be5b66 100644 --- a/pkg/relay/internal/registry/namespace_state.go +++ b/pkg/relay/internal/registry/namespace_state.go @@ -130,6 +130,8 @@ func (r *NamespaceRegistry) UpdatePrefix(e *SubscriberEntry, prefix wire.TrackNa return } counts, names := r.namespaceSources(prefix) + // e.announced holds exactly the namespaces with sources under old, so + // oldNames names each one to be done. _, oldNames := r.namespaceSources(old) for k := range e.announced { if _, still := counts[k]; !still { @@ -225,9 +227,10 @@ func (e *SubscriberEntry) blockedLocked(now time.Time) bool { // RunWriter sends e's queued messages in order until e is unregistered, the // request finishes, a write fails, or the queue bound resets the stream. Its -// owner runs it once, for the subscription's lifetime. After a failed write it -// also stops reading the stream, so a peer's STOP_SENDING-only cancel (§3.3.3) -// ends the subscription. +// owner runs it once, for the subscription's lifetime. It takes one message +// at a time, so what it has not sent stays counted by the queue bound (see +// maxQueuedMessages). After a failed write it also stops reading the stream, +// so a peer's STOP_SENDING-only cancel (§3.3.3) ends the subscription. func (e *SubscriberEntry) RunWriter() { defer close(e.writerDone) for { diff --git a/pkg/relay/internal/registry/subscription.go b/pkg/relay/internal/registry/subscription.go index edf9ae25..b67b876f 100644 --- a/pkg/relay/internal/registry/subscription.go +++ b/pkg/relay/internal/registry/subscription.go @@ -529,8 +529,7 @@ func (d *DownstreamSub) takeReadyDoneLocked() (*pendingPublishDone, uint64) { // accepts the subscriber's SUBSCRIBE (replying SUBSCRIBE_OK) before building // the sub, so it is live from construction. // -// Forward State defaults to 1: §10.7 specifies that when the FORWARD -// parameter is omitted from SUBSCRIBE the subscription forwards objects. +// Forward State defaults to 1: an omitted FORWARD means 1 (§10.2.18). // installSubscribeParams overrides this to 0 only when the peer explicitly // sends FORWARD=0, and REQUEST_UPDATE can flip it later (§9.2 / §10.9). func NewDownstreamSub(id uint64, sess *session.Session, stream session.Stream, trackAlias uint64) *DownstreamSub { @@ -709,8 +708,8 @@ const ( // ForwardDecision decides whether an Object goes to this subscription, under // one lock acquisition (it runs per Object per subscriber). §5.1.5: "Pass = -// Forward AND Location AND Range". The Location filter uses the subscribe-time -// LargestObject snapshot, not the live watermark. +// Forward AND Location Filters AND Range Filters". The Location filter uses +// the subscribe-time LargestObject snapshot, not the live watermark. func (d *DownstreamSub) ForwardDecision( group, object, subgroupID uint64, priority uint8, objProps []byte, ) ForwardVerdict { diff --git a/pkg/relay/internal/registry/subscription_test.go b/pkg/relay/internal/registry/subscription_test.go index cc83c1e0..79c9b9da 100644 --- a/pkg/relay/internal/registry/subscription_test.go +++ b/pkg/relay/internal/registry/subscription_test.go @@ -69,14 +69,14 @@ func TestSubscription_TerminateLatch(t *testing.T) { } // TestSubscription_ForwardState covers the §9.2 Forward flag round-trip. -// A fresh upstream subscription starts at Forward State 1: per §10.7 a +// A fresh upstream subscription starts at Forward State 1: per §10.2.18 a // SUBSCRIBE (or accepted PUBLISH) without the FORWARD parameter implies 1, // and the relay's upstream requests never carry FORWARD. func TestSubscription_ForwardState(t *testing.T) { t.Parallel() sub := registry.NewUpstreamSub(1, nil, nil, 0, 0, false) if got := sub.ForwardState(); got != 1 { - t.Fatalf("initial ForwardState = %d, want 1 (§10.7 default)", got) + t.Fatalf("initial ForwardState = %d, want 1 (§10.2.18 default)", got) } sub.SetForwardState(0) if got := sub.ForwardState(); got != 0 { diff --git a/pkg/relay/internal/registry/track_entry.go b/pkg/relay/internal/registry/track_entry.go index 9563827b..a1716195 100644 --- a/pkg/relay/internal/registry/track_entry.go +++ b/pkg/relay/internal/registry/track_entry.go @@ -126,7 +126,7 @@ type TrackEntry struct { // mu-guarded control mutations. deliveredMu sync.Mutex - // delivered is the dedup ledger across multiple upstream publishers (§9.5): + // delivered is the dedup ledger across multiple upstream publishers (§9.3): // GroupID → set of Object IDs already forwarded downstream. The first upstream // to reach a {GroupID, ObjectID} forwards it; later copies from redundant or // lagging peers are dropped (§2.1 — SubgroupID is not part of object @@ -144,7 +144,7 @@ type TrackEntry struct { // subgroups holds the shared outbound fan-out state for each // (GroupID, SubgroupID) currently being produced by one or more upstreams. - // §9.5 lets N redundant upstreams feed one track; §2.2 requires that the + // §9.3 lets N redundant upstreams feed one track; §2.2 requires that the // objects of a single Subgroup go out on exactly ONE downstream stream per // subscriber. Sharing this state across every inbound runFanout goroutine // (each of which carries one (group, subgroup)) is what lets the relay merge