From f9b04a6566fe8f551e3d097a62b8cb33ef3d2417 Mon Sep 17 00:00:00 2001 From: Vsevolod Strukchinsky Date: Sat, 26 Sep 2026 15:35:03 +0500 Subject: [PATCH 1/5] test(registry): split track_test by topic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit track_test.go (1047 lines) mixed TrackRegistry/TrackEntry basics with the §2.1/§2.4.2/§9.1/§12.8/§12.9 dedup ledger. Split it into: - track_test.go (513): registry and entry basics, cache TTL policy, DeleteIfUnused. - ledger_test.go (450): ClaimDelivered, gap properties, final Objects, RecordDuplicate and the late-end cache purge, with small shared helpers (newTestEntry, mustClaim, cacheAndClaim) replacing the per-test claim closures and cache-then-claim loops. - helpers_test.go: newTestTrackName moved here, now built on ns(). Comments trimmed to contract/citation/why; every § citation is kept. Test set unchanged: 58 top-level tests and 166 PASS lines (tests + subtests) before and after, no renames. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/relay/internal/registry/helpers_test.go | 6 + pkg/relay/internal/registry/ledger_test.go | 450 ++++++++++++++ pkg/relay/internal/registry/track_test.go | 630 ++------------------ 3 files changed, 504 insertions(+), 582 deletions(-) create mode 100644 pkg/relay/internal/registry/ledger_test.go diff --git a/pkg/relay/internal/registry/helpers_test.go b/pkg/relay/internal/registry/helpers_test.go index 921ce74e..66b06436 100644 --- a/pkg/relay/internal/registry/helpers_test.go +++ b/pkg/relay/internal/registry/helpers_test.go @@ -3,6 +3,7 @@ package registry_test import ( "context" + "github.com/floatdrop/moq-go/pkg/moqt/track" "github.com/floatdrop/moq-go/pkg/moqt/wire" ) @@ -15,6 +16,11 @@ func ns(parts ...string) wire.TrackNamespace { return out } +// newTestTrackName returns a FullTrackName for name in a fixed test namespace. +func newTestTrackName(name string) track.FullTrackName { + return track.FullTrackName{Namespace: ns("test"), Name: []byte(name)} +} + // stubStream is a no-op session.Stream; the registry never reads from one. type stubStream struct{} diff --git a/pkg/relay/internal/registry/ledger_test.go b/pkg/relay/internal/registry/ledger_test.go new file mode 100644 index 00000000..7317a992 --- /dev/null +++ b/pkg/relay/internal/registry/ledger_test.go @@ -0,0 +1,450 @@ +package registry_test + +import ( + "errors" + "testing" + + "github.com/floatdrop/moq-go/pkg/moqt/message" + "github.com/floatdrop/moq-go/pkg/moqt/session" + "github.com/floatdrop/moq-go/pkg/relay/cache" + "github.com/floatdrop/moq-go/pkg/relay/internal/registry" +) + +// newTestEntry returns the entry for name in a fresh registry. +func newTestEntry(name string) *registry.TrackEntry { + return registry.NewTrackRegistry().GetOrCreate(newTestTrackName(name)) +} + +// mustClaim claims o and fails unless ClaimDelivered returns (wantFresh, nil). +func mustClaim(t *testing.T, e *registry.TrackEntry, o registry.ObjectInfo, wantFresh bool) { + t.Helper() + if fresh, err := e.ClaimDelivered(o); err != nil || fresh != wantFresh { + t.Fatalf("ClaimDelivered(%+v) = (%v, %v), want (%v, nil)", o, fresh, err, wantFresh) + } +} + +// cacheAndClaim caches each Object and claims it, as the relay does on +// arrival; every claim must be well-formed. +func cacheAndClaim(t *testing.T, e *registry.TrackEntry, objs ...registry.ObjectInfo) { + t.Helper() + for _, o := range objs { + obj := &cache.CachedObject{GroupID: o.Group, ObjectID: o.Object, SubgroupID: o.Subgroup, Status: o.Status} + if o.Status == message.ObjectStatusNormal { + obj.Payload = []byte("x") + } + e.Cache.Put(obj) + if _, err := e.ClaimDelivered(o); err != nil { + t.Fatalf("ClaimDelivered(%+v): %v", o, err) + } + } +} + +// TestTrackEntry_ClaimDelivered pins the §2.1 dedup ledger: the first claim of a +// {GroupID, ObjectID} wins, a repeat loses, distinct objects/groups are +// independent, and a group that has aged out of the window is treated as already +// delivered. +func TestTrackEntry_ClaimDelivered(t *testing.T) { + t.Parallel() + e := newTestEntry("dedup") + for _, c := range []struct { + group, object uint64 + fresh bool + }{ + {0, 5, true}, + {0, 5, false}, + {0, 2, true}, // a distinct Object in a Group already seen + {1, 5, true}, // a distinct Group + // Group 1000 moves the window past Group 0, so a late straggler from + // Group 0 counts as already delivered. + {1000, 0, true}, + {0, 9, false}, + // The current Group still dedups after the window advanced. + {1000, 1, true}, + {1000, 1, false}, + } { + mustClaim(t, e, registry.ObjectInfo{Group: c.group, Object: c.object}, c.fresh) + } +} + +// TestTrackEntry_ClaimDeliveredGapProperties pins how the ledger treats the +// Prior Group and Object ID Gaps (§12.8, §12.9) of earlier Objects. An Object +// inside an announced gap is known not to exist (§2.1) and dropped (§9.1); a gap +// covering a received Object is accepted (§2.1); two Prior Group ID Gap values +// in one Group make the track malformed (§12.8). +func TestTrackEntry_ClaimDeliveredGapProperties(t *testing.T) { + t.Parallel() + type claim struct { + group, object uint64 + gaps message.PriorGaps + } + const ( + forwarded = iota + dropped + malformed + ) + var none message.PriorGaps + objectGap := func(n uint64) message.PriorGaps { return message.PriorGaps{Object: n, HasObject: true} } + groupGap := func(n uint64) message.PriorGaps { return message.PriorGaps{Group: n, HasGroup: true} } + for _, tc := range []struct { + name string + claims []claim // all but the last are forwarded + want int // the last one's outcome + }{ + // §12.9 + {"object gap over missing IDs", []claim{{1, 0, none}, {1, 3, objectGap(2)}}, forwarded}, + {"object gap covering a received Object", []claim{{1, 1, none}, {1, 3, objectGap(2)}}, forwarded}, + {"Object inside an announced object gap", []claim{{1, 3, objectGap(2)}, {1, 2, none}}, dropped}, + {"Object below an announced object gap", []claim{{1, 3, objectGap(2)}, {1, 0, none}}, forwarded}, + {"same ID in another Group", []claim{{1, 3, objectGap(2)}, {2, 2, none}}, forwarded}, + {"a copy with the same object gap", []claim{{1, 3, objectGap(2)}, {1, 3, objectGap(2)}}, dropped}, + // §12.8 + {"group gap over missing Groups", []claim{{1, 0, none}, {4, 0, groupGap(2)}}, forwarded}, + {"group gap covering a received Group", []claim{{2, 5, none}, {4, 0, groupGap(2)}}, forwarded}, + {"Group inside an announced group gap", []claim{{4, 0, groupGap(2)}, {3, 0, none}}, dropped}, + {"Group covered after it arrived", []claim{{2, 5, none}, {4, 0, groupGap(2)}, {2, 6, none}}, dropped}, + {"same group gap twice in a Group", []claim{{4, 0, groupGap(2)}, {4, 1, groupGap(2)}}, forwarded}, + {"different group gaps in a Group", []claim{{4, 0, groupGap(2)}, {4, 1, groupGap(1)}}, malformed}, + {"group gap on one Object of a Group only", []claim{{4, 0, groupGap(2)}, {4, 1, none}}, forwarded}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + e := newTestEntry("gaps") + last := len(tc.claims) - 1 + for i, c := range tc.claims { + fresh, err := e.ClaimDelivered(registry.ObjectInfo{Group: c.group, Object: c.object, Gaps: c.gaps}) + got := forwarded + switch { + case err != nil: + got = malformed + if !errors.Is(err, session.ErrMalformedTrack) { + t.Fatalf("claim %d %+v: %v, want it to wrap session.ErrMalformedTrack", i, c, err) + } + case !fresh: + got = dropped + } + want := forwarded + if i == last { + want = tc.want + } + if got != want { + t.Fatalf("claim %d %+v: outcome %d (err %v), want %d", i, c, got, err, want) + } + } + }) + } +} + +// TestTrackEntry_ClaimDeliveredMalformedLeavesNoTrace: a malformed claim +// records nothing: neither its Object, its Prior Group ID Gap, nor its +// Subgroup's Publisher Priority. +func TestTrackEntry_ClaimDeliveredMalformedLeavesNoTrace(t *testing.T) { + t.Parallel() + e := newTestEntry("gaps") + groupGap := func(n uint64) message.PriorGaps { return message.PriorGaps{Group: n, HasGroup: true} } + + mustClaim(t, e, registry.ObjectInfo{Group: 9, Object: 0, Gaps: groupGap(2)}, true) // Groups 7-8 absent + if _, err := e.ClaimDelivered(registry.ObjectInfo{Group: 9, Object: 1, Gaps: groupGap(3)}); err == nil { + t.Fatal("a second Prior Group ID Gap value in Group 9 is not malformed") + } + mustClaim(t, e, registry.ObjectInfo{Group: 9, Object: 1}, true) // Object 1 was not recorded + mustClaim(t, e, registry.ObjectInfo{Group: 6, Object: 0}, true) // nor the gap of 3 (Groups 6-8) + mustClaim(t, e, registry.ObjectInfo{Group: 8, Object: 0}, false) + + if _, err := e.ClaimDelivered(registry.ObjectInfo{Group: 9, Object: 5, Priority: 7}); err == nil { + t.Fatal("another Publisher Priority in Subgroup 0 of Group 9 is not malformed") + } + mustClaim(t, e, registry.ObjectInfo{Group: 9, Object: 5}, true) + + // A duplicate records no Subgroup state: the §9.1 check may still reject + // it (the caller's), so Subgroup 3 keeps no priority from it. + mustClaim(t, e, registry.ObjectInfo{Group: 9, Object: 5, Subgroup: 3, Priority: 9}, false) + mustClaim(t, e, registry.ObjectInfo{Group: 9, Object: 6, Subgroup: 3, Priority: 1}, true) +} + +// TestTrackEntry_FinalObjects pins the §2.4.2 conditions that need earlier +// Objects: a Subgroup's Publisher Priority changing (1), an Object past a +// Subgroup's end (2) or two different ends (3), and an Object past the Group's +// (4) or the Track's (5) end, detected in either order. An end is the first +// missing ID: a status at M ends at M (§11.2.1.1), a FIN or END_OF_GROUP bit +// after N at N+1 (§11.4.2, §11.3.1), so the two agree when M = N+1 (§9.1). +func TestTrackEntry_FinalObjects(t *testing.T) { + t.Parallel() + type step func(*registry.TrackEntry) error + // claim does what the relay does: a duplicate, once the §9.1 check + // passed, is recorded too. + claim := func(o registry.ObjectInfo) step { + return func(e *registry.TrackEntry) error { + fresh, err := e.ClaimDelivered(o) + if err == nil && !fresh { + err = e.RecordDuplicate(o) + } + return err + } + } + sub := func(group, object, subgroup uint64) step { + return claim(registry.ObjectInfo{Group: group, Object: object, Subgroup: subgroup}) + } + prio := func(object, subgroup uint64, p uint8) step { + return claim(registry.ObjectInfo{Group: 1, Object: object, Subgroup: subgroup, Priority: p}) + } + status := func(group, object, s uint64) step { + return claim(registry.ObjectInfo{Group: group, Object: object, Status: s}) + } + statusIn := func(object, subgroup, s uint64) step { + return claim(registry.ObjectInfo{Group: 1, Object: object, Subgroup: subgroup, Status: s}) + } + datagram := func(object uint64, endOfGroup bool) step { + return claim(registry.ObjectInfo{Group: 1, Object: object, Datagram: true, EndOfGroup: endOfGroup}) + } + fin := func(subgroup, last uint64, endOfGroup bool) step { + return func(e *registry.TrackEntry) error { + return e.SubgroupEnded(registry.ObjectInfo{Group: 1, Object: last, Subgroup: subgroup}, endOfGroup) + } + } + finOnStatus := func(subgroup, last uint64) step { + return func(e *registry.TrackEntry) error { + return e.SubgroupEnded(registry.ObjectInfo{ + Group: 1, Object: last, Subgroup: subgroup, Status: message.ObjectStatusEndOfGroup, + }, false) + } + } + eog, eot := message.ObjectStatusEndOfGroup, message.ObjectStatusEndOfTrack + for _, tc := range []struct { + name string + steps []step // all but the last are well-formed + malformed bool // the last makes the track malformed + }{ + // 1: Publisher Priority within a Subgroup + {"same priority in a Subgroup", []step{prio(0, 0, 1), prio(1, 0, 1)}, false}, + {"priority changes in a Subgroup", []step{prio(0, 0, 1), prio(1, 0, 2)}, true}, + {"other priority in another Subgroup", []step{prio(0, 0, 1), prio(1, 1, 2)}, false}, + {"datagrams have no Subgroup", []step{ + claim(registry.ObjectInfo{Group: 1, Object: 0, Datagram: true, Priority: 1}), + claim(registry.ObjectInfo{Group: 1, Object: 1, Datagram: true, Priority: 2}), + }, false}, + // 2, 3: the final Object of a Subgroup is the last before a FIN + {"Object past a Subgroup's FIN", []step{sub(1, 0, 0), sub(1, 1, 0), fin(0, 1, false), sub(1, 2, 0)}, true}, + {"FIN below a received Object", []step{sub(1, 0, 0), sub(1, 2, 0), fin(0, 1, false)}, true}, + {"another Subgroup after a FIN", []step{sub(1, 0, 0), sub(1, 1, 0), fin(0, 1, false), sub(1, 5, 1)}, false}, + {"two FINs, same final", []step{sub(1, 0, 0), sub(1, 1, 0), fin(0, 1, false), fin(0, 1, false)}, false}, + {"two FINs, different finals", []step{sub(1, 0, 0), sub(1, 1, 0), fin(0, 1, false), fin(0, 0, false)}, true}, + // 4: the final Object of a Group + {"Object past END_OF_GROUP", []step{status(1, 2, eog), sub(1, 3, 1)}, true}, + {"Object below END_OF_GROUP", []step{status(1, 5, eog), sub(1, 3, 1)}, false}, + {"END_OF_GROUP below a received Object", []step{sub(1, 3, 1), status(1, 2, eog)}, true}, + {"next Group after END_OF_GROUP", []step{status(1, 2, eog), sub(2, 5, 0)}, false}, + {"Object past a datagram's END_OF_GROUP", []step{datagram(2, true), datagram(3, false)}, true}, + {"Object past an END_OF_GROUP Subgroup's FIN", []step{sub(1, 0, 0), sub(1, 1, 0), fin(0, 1, true), sub(1, 2, 1)}, true}, + {"Object past a plain Subgroup's FIN", []step{sub(1, 0, 0), sub(1, 1, 0), fin(0, 1, false), sub(1, 2, 1)}, false}, + // 5: the final Object of the Track + {"Group past END_OF_TRACK", []step{status(1, 2, eot), sub(2, 0, 0)}, true}, + {"Object past END_OF_TRACK in its Group", []step{status(1, 2, eot), sub(1, 3, 1)}, true}, + {"Object before END_OF_TRACK", []step{status(1, 2, eot), sub(1, 1, 1)}, false}, + {"END_OF_TRACK below a received Object", []step{sub(2, 0, 0), status(1, 2, eot)}, true}, + {"a copy of END_OF_TRACK", []step{status(1, 2, eot), status(1, 2, eot)}, false}, + + // A status at M and a FIN or bit after M-1 are the same end. + {"END_OF_GROUP status after an END_OF_GROUP FIN", []step{ + sub(1, 0, 0), sub(1, 1, 0), fin(0, 1, true), status(1, 2, eog), + }, false}, + {"END_OF_GROUP FIN after an END_OF_GROUP status", []step{ + status(1, 2, eog), sub(1, 0, 0), sub(1, 1, 0), fin(0, 1, true), + }, false}, + {"END_OF_GROUP status one past a datagram's END_OF_GROUP", []step{datagram(1, true), status(1, 2, eog)}, false}, + {"END_OF_GROUP status two past a datagram's END_OF_GROUP", []step{datagram(1, true), status(1, 3, eog)}, true}, + {"END_OF_GROUP FIN before an END_OF_TRACK", []step{ + status(1, 2, eot), sub(1, 0, 1), sub(1, 1, 1), fin(1, 1, true), + }, false}, + {"a Subgroup ending on a status, and after the Object before it", []step{ + sub(1, 0, 0), sub(1, 1, 0), fin(0, 1, false), finOnStatus(0, 2), + }, false}, + // A status end at M and a FIN end at M+1 are Object M going from + // existing to not existing (§9.1). + {"a Subgroup ending on a status, and after the Object at it", []step{ + sub(1, 0, 0), sub(1, 1, 0), fin(0, 1, false), finOnStatus(0, 1), + }, false}, + {"a Subgroup ending after an Object, and on a status at it", []step{ + sub(1, 0, 0), sub(1, 1, 0), statusIn(1, 0, eog), finOnStatus(0, 1), fin(0, 1, false), + }, false}, + {"an END_OF_GROUP FIN after an Object, then END_OF_GROUP at it", []step{ + sub(1, 0, 0), sub(1, 1, 0), fin(0, 1, true), statusIn(1, 0, eog), + }, false}, + {"an Object past both", []step{ + sub(1, 0, 0), sub(1, 1, 0), fin(0, 1, true), statusIn(1, 0, eog), sub(1, 2, 1), + }, true}, + {"a Subgroup ending two apart", []step{ + sub(1, 0, 0), sub(1, 1, 0), fin(0, 1, false), finOnStatus(0, 0), + }, true}, + // A Normal Object at a status's own ID is the late Object of §2.1. + {"Object at END_OF_GROUP's ID", []step{status(1, 2, eog), sub(1, 2, 1)}, false}, + {"Object at END_OF_TRACK's ID", []step{status(1, 2, eot), sub(1, 2, 1)}, false}, + {"Object at the ID after an END_OF_GROUP FIN", []step{ + sub(1, 0, 0), sub(1, 1, 0), fin(0, 1, true), sub(1, 2, 1), + }, true}, + {"Object, then END_OF_GROUP at its ID", []step{sub(1, 2, 1), statusIn(2, 1, eog)}, false}, + + // A status arriving as a duplicate of a Normal Object still ends it. + {"Object past an END_OF_GROUP that was a duplicate", []step{ + sub(1, 2, 1), statusIn(2, 1, eog), sub(1, 3, 2), + }, true}, + {"Object past an END_OF_TRACK that was a duplicate", []step{ + sub(1, 2, 1), statusIn(2, 1, eot), sub(2, 0, 0), + }, true}, + // A FIN or bit end wins over a status at the same ID, in every order. + {"Object and END_OF_GROUP at 2, then a FIN after 1", []step{ + sub(1, 2, 1), statusIn(2, 1, eog), sub(1, 0, 0), sub(1, 1, 0), fin(0, 1, true), + }, true}, + {"a FIN after 1 and END_OF_GROUP at 2, then an Object at 2", []step{ + sub(1, 0, 0), sub(1, 1, 0), fin(0, 1, true), statusIn(2, 1, eog), sub(1, 2, 1), + }, true}, + {"END_OF_GROUP at 2 and an Object there, then a FIN after 1", []step{ + statusIn(2, 1, eog), sub(1, 2, 1), sub(1, 0, 0), sub(1, 1, 0), fin(0, 1, true), + }, true}, + // Status Objects past an end, and END_OF_TRACK ending its Group. + {"END_OF_TRACK past an END_OF_GROUP", []step{status(1, 3, eog), status(1, 5, eot)}, true}, + {"END_OF_GROUP past an END_OF_TRACK", []step{status(1, 5, eot), status(1, 7, eog)}, true}, + {"END_OF_GROUP in a Group past END_OF_TRACK", []step{status(1, 5, eot), status(2, 0, eog)}, true}, + {"END_OF_TRACK past an END_OF_GROUP FIN", []step{ + sub(1, 0, 0), sub(1, 1, 0), fin(0, 1, true), status(1, 5, eot), + }, true}, + {"END_OF_GROUP past a Subgroup's FIN", []step{ + sub(1, 0, 0), sub(1, 1, 0), fin(0, 1, false), statusIn(3, 0, eog), + }, true}, + {"END_OF_TRACK below an END_OF_GROUP in a later Group", []step{status(5, 0, eog), status(3, 2, eot)}, true}, + {"a Subgroup's FIN below an END_OF_GROUP in it", []step{ + sub(1, 1, 0), statusIn(5, 0, eog), fin(0, 1, false), + }, true}, + // A status Object at a hard end, then a status end one below it. + {"END_OF_GROUP at an END_OF_GROUP FIN's end, then one below", []step{ + sub(1, 2, 0), fin(0, 2, true), statusIn(3, 1, eog), statusIn(2, 2, eog), + }, true}, + {"END_OF_GROUP at a datagram's END_OF_GROUP end, then one below", []step{ + datagram(2, true), status(1, 3, eog), statusIn(2, 2, eog), + }, true}, + // A Subgroup whose every Object was dropped has no priority yet. + {"FIN of a Subgroup with nothing recorded, then its Object", []step{ + prio(0, 0, 5), + func(e *registry.TrackEntry) error { + return e.SubgroupEnded(registry.ObjectInfo{Group: 1, Object: 4, Subgroup: 7, Priority: 5}, false) + }, + prio(1, 7, 5), + }, false}, + {"END_OF_TRACK at an END_OF_GROUP FIN's end", []step{ + sub(1, 0, 0), sub(1, 1, 0), fin(0, 1, true), status(1, 2, eot), + }, false}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + e := newTestEntry("finals") + last := len(tc.steps) - 1 + for i, s := range tc.steps { + err := s(e) + if i < last && err != nil { + t.Fatalf("step %d: %v", i, err) + } + if i == last && (err != nil) != tc.malformed { + t.Fatalf("last step: err = %v, want malformed = %v", err, tc.malformed) + } + if err != nil && !errors.Is(err, session.ErrMalformedTrack) { + t.Fatalf("err = %v, want it to wrap session.ErrMalformedTrack", err) + } + } + }) + } +} + +// TestTrackEntry_RecordDuplicateChecksAgain: a duplicate's end is checked +// again when recorded, against what was claimed after its ClaimDelivered. +func TestTrackEntry_RecordDuplicateChecksAgain(t *testing.T) { + t.Parallel() + e := newTestEntry("race") + mustClaim(t, e, registry.ObjectInfo{Group: 1, Object: 2}, true) + eog := registry.ObjectInfo{Group: 1, Object: 2, Status: message.ObjectStatusEndOfGroup} + mustClaim(t, e, eog, false) // a duplicate; the caller's §9.1 check runs now + mustClaim(t, e, registry.ObjectInfo{Group: 1, Object: 5}, true) // meanwhile, another upstream + if err := e.RecordDuplicate(eog); !errors.Is(err, session.ErrMalformedTrack) { + t.Fatalf("RecordDuplicate = %v, want the Group ending below Object 5 to be malformed", err) + } +} + +// TestTrackEntry_LateEndPurgesCache: an end placed below Normal Objects +// already received makes them past it, and Object(s) triggering Malformed +// Track status MUST NOT be cached (§2.4.2). +func TestTrackEntry_LateEndPurgesCache(t *testing.T) { + t.Parallel() + e := newTestEntry("purge") + cacheAndClaim(t, e, registry.ObjectInfo{Group: 1, Object: 0}, + registry.ObjectInfo{Group: 1, Object: 1}, registry.ObjectInfo{Group: 1, Object: 2}) + if err := e.SubgroupEnded(registry.ObjectInfo{Group: 1, Object: 1}, false); err == nil { + t.Fatal("a FIN after 1 with Object 2 received is not malformed") + } + if _, ok := e.Cache.Get(1, 2); ok { + t.Error("Object 2, past the Subgroup's end, is still cached") + } + if _, ok := e.Cache.Get(1, 1); !ok { + t.Error("Object 1 was removed from the cache") + } + + // Two conflicting ends: the Objects past the lower one go too. + e = newTestEntry("purge-conflict") + cacheAndClaim(t, e, registry.ObjectInfo{Group: 1, Object: 0}, registry.ObjectInfo{Group: 1, Object: 1}) + if err := e.SubgroupEnded(registry.ObjectInfo{Group: 1, Object: 1}, false); err != nil { + t.Fatalf("a FIN after 1: %v", err) + } + if err := e.SubgroupEnded(registry.ObjectInfo{Group: 1, Object: 0}, false); err == nil { + t.Fatal("a second FIN, after 0, is not malformed") + } + if _, ok := e.Cache.Get(1, 1); ok { + t.Error("Object 1, past the lower end, is still cached") + } + + // END_OF_TRACK: Objects past it, status Objects included, go too. + eot, eog := message.ObjectStatusEndOfTrack, message.ObjectStatusEndOfGroup + for _, tc := range []struct { + name string + cached []registry.ObjectInfo + late registry.ObjectInfo + gone []message.Location + kept []message.Location + }{ + { + "a second END_OF_TRACK", + []registry.ObjectInfo{{Group: 1, Object: 0}, {Group: 1, Object: 3, Status: eot}}, + registry.ObjectInfo{Group: 1, Object: 1, Status: eot}, + []message.Location{{Group: 1, Object: 3}}, + []message.Location{{Group: 1, Object: 0}}, + }, + { + "END_OF_GROUP in a later Group", + []registry.ObjectInfo{{Group: 2, Object: 0, Status: eog}}, + registry.ObjectInfo{Group: 1, Object: 0, Status: eot}, + []message.Location{{Group: 2, Object: 0}}, + nil, + }, + { + "END_OF_GROUP in its Group, and a later Group", + []registry.ObjectInfo{ + {Group: 1, Object: 0}, {Group: 1, Object: 5, Subgroup: 1, Status: eog}, {Group: 2, Object: 1}, + }, + registry.ObjectInfo{Group: 1, Object: 2, Subgroup: 2, Status: eot}, + []message.Location{{Group: 1, Object: 5}, {Group: 2, Object: 1}}, + []message.Location{{Group: 1, Object: 0}}, + }, + } { + e := newTestEntry("purge-eot") + cacheAndClaim(t, e, tc.cached...) + if _, err := e.ClaimDelivered(tc.late); err == nil { + t.Fatalf("%s: END_OF_TRACK below an Object received is not malformed", tc.name) + } + for _, l := range tc.gone { + if _, ok := e.Cache.Get(l.Group, l.Object); ok { + t.Errorf("%s: Object %d of Group %d, past END_OF_TRACK, is still cached", tc.name, l.Object, l.Group) + } + } + for _, l := range tc.kept { + if _, ok := e.Cache.Get(l.Group, l.Object); !ok { + t.Errorf("%s: Object %d of Group %d, before END_OF_TRACK, was removed", tc.name, l.Object, l.Group) + } + } + } +} diff --git a/pkg/relay/internal/registry/track_test.go b/pkg/relay/internal/registry/track_test.go index 4a49c956..008abab7 100644 --- a/pkg/relay/internal/registry/track_test.go +++ b/pkg/relay/internal/registry/track_test.go @@ -1,7 +1,6 @@ package registry_test import ( - "errors" "sync" "sync/atomic" "testing" @@ -10,501 +9,12 @@ 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/cache" "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")}, - Name: []byte(name), - } -} - -// TestTrackEntry_ClaimDelivered pins the §2.1 dedup ledger: the first claim of a -// {GroupID, ObjectID} wins, a repeat loses, distinct objects/groups are -// independent, and a group that has aged out of the window is treated as already -// delivered. -func TestTrackEntry_ClaimDelivered(t *testing.T) { - t.Parallel() - - r := registry.NewTrackRegistry() - e := r.GetOrCreate(newTestTrackName("dedup")) - claim := func(group, object uint64) bool { - t.Helper() - fresh, err := e.ClaimDelivered(registry.ObjectInfo{Group: group, Object: object}) - if err != nil { - t.Fatalf("ClaimDelivered(%d, %d): %v", group, object, err) - } - return fresh - } - - // First sighting wins; an exact repeat loses. - if !claim(0, 5) { - t.Fatal("first ClaimDelivered(0,5) should win") - } - if claim(0, 5) { - t.Fatal("repeat ClaimDelivered(0,5) should lose") - } - // A gap-fill in the same group (object 5 already seen, 2 not) is independent. - if !claim(0, 2) { - t.Fatal("ClaimDelivered(0,2) should win — distinct object in a seen group") - } - // A different group is independent. - if !claim(1, 5) { - t.Fatal("ClaimDelivered(1,5) should win — distinct group") - } - - // Advance the group far enough that group 0 ages out of the window; a late - // straggler from group 0 must then be treated as already delivered. - if !claim(1000, 0) { - t.Fatal("ClaimDelivered(1000,0) should win") - } - if claim(0, 9) { - t.Fatal("ClaimDelivered(0,9) should lose — group 0 has aged out of the dedup window") - } - // The current group still dedups normally after the window advanced. - if !claim(1000, 1) { - t.Fatal("ClaimDelivered(1000,1) should win in the current group") - } - if claim(1000, 1) { - t.Fatal("repeat ClaimDelivered(1000,1) should lose") - } -} - -// TestTrackEntry_ClaimDeliveredGapProperties pins how the ledger treats the -// Prior Group and Object ID Gaps (§12.8, §12.9) of earlier Objects. An Object -// inside an announced gap is known not to exist (§2.1) and dropped (§9.1); a gap -// covering a received Object is accepted (§2.1); two Prior Group ID Gap values -// in one Group make the track malformed (§12.8). -func TestTrackEntry_ClaimDeliveredGapProperties(t *testing.T) { - t.Parallel() - type claim struct { - group, object uint64 - gaps message.PriorGaps - } - const ( - forwarded = iota - dropped - malformed - ) - var none message.PriorGaps - objectGap := func(n uint64) message.PriorGaps { return message.PriorGaps{Object: n, HasObject: true} } - groupGap := func(n uint64) message.PriorGaps { return message.PriorGaps{Group: n, HasGroup: true} } - for _, tc := range []struct { - name string - claims []claim // all but the last are forwarded - want int // the last one's outcome - }{ - // §12.9 - {"object gap over missing IDs", []claim{{1, 0, none}, {1, 3, objectGap(2)}}, forwarded}, - {"object gap covering a received Object", []claim{{1, 1, none}, {1, 3, objectGap(2)}}, forwarded}, - {"Object inside an announced object gap", []claim{{1, 3, objectGap(2)}, {1, 2, none}}, dropped}, - {"Object below an announced object gap", []claim{{1, 3, objectGap(2)}, {1, 0, none}}, forwarded}, - {"same ID in another Group", []claim{{1, 3, objectGap(2)}, {2, 2, none}}, forwarded}, - {"a copy with the same object gap", []claim{{1, 3, objectGap(2)}, {1, 3, objectGap(2)}}, dropped}, - // §12.8 - {"group gap over missing Groups", []claim{{1, 0, none}, {4, 0, groupGap(2)}}, forwarded}, - {"group gap covering a received Group", []claim{{2, 5, none}, {4, 0, groupGap(2)}}, forwarded}, - {"Group inside an announced group gap", []claim{{4, 0, groupGap(2)}, {3, 0, none}}, dropped}, - {"Group covered after it arrived", []claim{{2, 5, none}, {4, 0, groupGap(2)}, {2, 6, none}}, dropped}, - {"same group gap twice in a Group", []claim{{4, 0, groupGap(2)}, {4, 1, groupGap(2)}}, forwarded}, - {"different group gaps in a Group", []claim{{4, 0, groupGap(2)}, {4, 1, groupGap(1)}}, malformed}, - {"group gap on one Object of a Group only", []claim{{4, 0, groupGap(2)}, {4, 1, none}}, forwarded}, - } { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - e := registry.NewTrackRegistry().GetOrCreate(newTestTrackName("gaps")) - last := len(tc.claims) - 1 - for i, c := range tc.claims { - fresh, err := e.ClaimDelivered(registry.ObjectInfo{Group: c.group, Object: c.object, Gaps: c.gaps}) - got := forwarded - switch { - case err != nil: - got = malformed - if !errors.Is(err, session.ErrMalformedTrack) { - t.Fatalf("claim %d %+v: %v, want it to wrap session.ErrMalformedTrack", i, c, err) - } - case !fresh: - got = dropped - } - want := forwarded - if i == last { - want = tc.want - } - if got != want { - t.Fatalf("claim %d %+v: outcome %d (err %v), want %d", i, c, got, err, want) - } - } - }) - } -} - -// TestTrackEntry_ClaimDeliveredMalformedLeavesNoTrace: a malformed claim -// records nothing: neither its Object nor its Prior Group ID Gap. -func TestTrackEntry_ClaimDeliveredMalformedLeavesNoTrace(t *testing.T) { - t.Parallel() - e := registry.NewTrackRegistry().GetOrCreate(newTestTrackName("gaps")) - mustClaim := func(group, object uint64, gaps message.PriorGaps, wantFresh bool) { - t.Helper() - fresh, err := e.ClaimDelivered(registry.ObjectInfo{Group: group, Object: object, Gaps: gaps}) - if err != nil || fresh != wantFresh { - t.Fatalf("ClaimDelivered(%d, %d, %+v) = (%v, %v), want (%v, nil)", - group, object, gaps, fresh, err, wantFresh) - } - } - mustClaim(9, 0, message.PriorGaps{Group: 2, HasGroup: true}, true) // Groups 7-8 absent - if _, err := e.ClaimDelivered( - registry.ObjectInfo{Group: 9, Object: 1, Gaps: message.PriorGaps{Group: 3, HasGroup: true}}, - ); err == nil { - t.Fatal("a second Prior Group ID Gap value in Group 9 is not malformed") - } - mustClaim(9, 1, message.PriorGaps{}, true) // Object 1 was not recorded - mustClaim(6, 0, message.PriorGaps{}, true) // nor the gap of 3 (Groups 6-8) - mustClaim(8, 0, message.PriorGaps{}, false) - - // Nor a Subgroup's Publisher Priority. - if _, err := e.ClaimDelivered(registry.ObjectInfo{Group: 9, Object: 5, Priority: 7}); err == nil { - t.Fatal("another Publisher Priority in Subgroup 0 of Group 9 is not malformed") - } - if fresh, err := e.ClaimDelivered(registry.ObjectInfo{Group: 9, Object: 5}); err != nil || !fresh { - t.Fatalf("ClaimDelivered after the malformed one = (%v, %v), want (true, nil)", fresh, err) - } - - // A duplicate records no Subgroup state: the §9.1 check may still reject - // it (the caller's), so Subgroup 3 keeps no priority from it. - if fresh, err := e.ClaimDelivered( - registry.ObjectInfo{Group: 9, Object: 5, Subgroup: 3, Priority: 9}, - ); err != nil || - fresh { - t.Fatalf("a duplicate = (%v, %v), want (false, nil)", fresh, err) - } - if _, err := e.ClaimDelivered(registry.ObjectInfo{Group: 9, Object: 6, Subgroup: 3, Priority: 1}); err != nil { - t.Fatalf("Subgroup 3 kept the duplicate's priority: %v", err) - } -} - -// TestTrackEntry_FinalObjects pins the §2.4.2 conditions that need earlier -// Objects: a Subgroup's Publisher Priority changing (1), an Object past a -// Subgroup's end (2) or two different ends (3), and an Object past the Group's -// (4) or the Track's (5) end, detected in either order. An end is the first -// missing ID: a status at M ends at M (§11.2.1.1), a FIN or END_OF_GROUP bit -// after N at N+1 (§11.4.2, §11.3.1), so the two agree when M = N+1 (§9.1). -func TestTrackEntry_FinalObjects(t *testing.T) { - t.Parallel() - type step func(*registry.TrackEntry) error - // claim does what the relay does: a duplicate, once the §9.1 check - // passed, is recorded too. - claim := func(o registry.ObjectInfo) step { - return func(e *registry.TrackEntry) error { - fresh, err := e.ClaimDelivered(o) - if err == nil && !fresh { - err = e.RecordDuplicate(o) - } - return err - } - } - sub := func(group, object, subgroup uint64) step { - return claim(registry.ObjectInfo{Group: group, Object: object, Subgroup: subgroup}) - } - prio := func(object, subgroup uint64, p uint8) step { - return claim(registry.ObjectInfo{Group: 1, Object: object, Subgroup: subgroup, Priority: p}) - } - status := func(group, object, s uint64) step { - return claim(registry.ObjectInfo{Group: group, Object: object, Status: s}) - } - statusIn := func(object, subgroup, s uint64) step { - return claim(registry.ObjectInfo{Group: 1, Object: object, Subgroup: subgroup, Status: s}) - } - fin := func(subgroup, last uint64, endOfGroup bool) step { - return func(e *registry.TrackEntry) error { - return e.SubgroupEnded(registry.ObjectInfo{Group: 1, Object: last, Subgroup: subgroup}, endOfGroup) - } - } - finOnStatus := func(subgroup, last uint64) step { - return func(e *registry.TrackEntry) error { - return e.SubgroupEnded(registry.ObjectInfo{ - Group: 1, Object: last, Subgroup: subgroup, Status: message.ObjectStatusEndOfGroup, - }, false) - } - } - eog, eot := message.ObjectStatusEndOfGroup, message.ObjectStatusEndOfTrack - for _, tc := range []struct { - name string - steps []step // all but the last are well-formed - malformed bool // the last makes the track malformed - }{ - // 1: Publisher Priority within a Subgroup - {"same priority in a Subgroup", []step{prio(0, 0, 1), prio(1, 0, 1)}, false}, - {"priority changes in a Subgroup", []step{prio(0, 0, 1), prio(1, 0, 2)}, true}, - {"other priority in another Subgroup", []step{prio(0, 0, 1), prio(1, 1, 2)}, false}, - {"datagrams have no Subgroup", []step{ - claim(registry.ObjectInfo{Group: 1, Object: 0, Datagram: true, Priority: 1}), - claim(registry.ObjectInfo{Group: 1, Object: 1, Datagram: true, Priority: 2}), - }, false}, - // 2, 3: the final Object of a Subgroup is the last before a FIN - {"Object past a Subgroup's FIN", []step{sub(1, 0, 0), sub(1, 1, 0), fin(0, 1, false), sub(1, 2, 0)}, true}, - {"FIN below a received Object", []step{sub(1, 0, 0), sub(1, 2, 0), fin(0, 1, false)}, true}, - {"another Subgroup after a FIN", []step{sub(1, 0, 0), sub(1, 1, 0), fin(0, 1, false), sub(1, 5, 1)}, false}, - {"two FINs, same final", []step{sub(1, 0, 0), sub(1, 1, 0), fin(0, 1, false), fin(0, 1, false)}, false}, - {"two FINs, different finals", []step{sub(1, 0, 0), sub(1, 1, 0), fin(0, 1, false), fin(0, 0, false)}, true}, - // 4: the final Object of a Group - {"Object past END_OF_GROUP", []step{status(1, 2, eog), sub(1, 3, 1)}, true}, - {"Object below END_OF_GROUP", []step{status(1, 5, eog), sub(1, 3, 1)}, false}, - {"END_OF_GROUP below a received Object", []step{sub(1, 3, 1), status(1, 2, eog)}, true}, - {"next Group after END_OF_GROUP", []step{status(1, 2, eog), sub(2, 5, 0)}, false}, - {"Object past a datagram's END_OF_GROUP", []step{ - claim(registry.ObjectInfo{Group: 1, Object: 2, Datagram: true, EndOfGroup: true}), - claim(registry.ObjectInfo{Group: 1, Object: 3, Datagram: true}), - }, true}, - {"Object past an END_OF_GROUP Subgroup's FIN", []step{sub(1, 0, 0), sub(1, 1, 0), fin(0, 1, true), sub(1, 2, 1)}, true}, - {"Object past a plain Subgroup's FIN", []step{sub(1, 0, 0), sub(1, 1, 0), fin(0, 1, false), sub(1, 2, 1)}, false}, - // 5: the final Object of the Track - {"Group past END_OF_TRACK", []step{status(1, 2, eot), sub(2, 0, 0)}, true}, - {"Object past END_OF_TRACK in its Group", []step{status(1, 2, eot), sub(1, 3, 1)}, true}, - {"Object before END_OF_TRACK", []step{status(1, 2, eot), sub(1, 1, 1)}, false}, - {"END_OF_TRACK below a received Object", []step{sub(2, 0, 0), status(1, 2, eot)}, true}, - {"a copy of END_OF_TRACK", []step{status(1, 2, eot), status(1, 2, eot)}, false}, - - // A status at M and a FIN or bit after M-1 are the same end. - {"END_OF_GROUP status after an END_OF_GROUP FIN", []step{ - sub(1, 0, 0), sub(1, 1, 0), fin(0, 1, true), status(1, 2, eog), - }, false}, - {"END_OF_GROUP FIN after an END_OF_GROUP status", []step{ - status(1, 2, eog), sub(1, 0, 0), sub(1, 1, 0), fin(0, 1, true), - }, false}, - {"END_OF_GROUP status one past a datagram's END_OF_GROUP", []step{ - claim(registry.ObjectInfo{Group: 1, Object: 1, Datagram: true, EndOfGroup: true}), - status(1, 2, eog), - }, false}, - {"END_OF_GROUP status two past a datagram's END_OF_GROUP", []step{ - claim(registry.ObjectInfo{Group: 1, Object: 1, Datagram: true, EndOfGroup: true}), - status(1, 3, eog), - }, true}, - {"END_OF_GROUP FIN before an END_OF_TRACK", []step{ - status(1, 2, eot), sub(1, 0, 1), sub(1, 1, 1), fin(1, 1, true), - }, false}, - {"a Subgroup ending on a status, and after the Object before it", []step{ - sub(1, 0, 0), sub(1, 1, 0), fin(0, 1, false), finOnStatus(0, 2), - }, false}, - // A status end at M and a FIN end at M+1 are Object M going from - // existing to not existing (§9.1). - {"a Subgroup ending on a status, and after the Object at it", []step{ - sub(1, 0, 0), sub(1, 1, 0), fin(0, 1, false), finOnStatus(0, 1), - }, false}, - {"a Subgroup ending after an Object, and on a status at it", []step{ - sub(1, 0, 0), sub(1, 1, 0), statusIn(1, 0, eog), finOnStatus(0, 1), fin(0, 1, false), - }, false}, - {"an END_OF_GROUP FIN after an Object, then END_OF_GROUP at it", []step{ - sub(1, 0, 0), sub(1, 1, 0), fin(0, 1, true), statusIn(1, 0, eog), - }, false}, - {"an Object past both", []step{ - sub(1, 0, 0), sub(1, 1, 0), fin(0, 1, true), statusIn(1, 0, eog), sub(1, 2, 1), - }, true}, - {"a Subgroup ending two apart", []step{ - sub(1, 0, 0), sub(1, 1, 0), fin(0, 1, false), finOnStatus(0, 0), - }, true}, - // A Normal Object at a status's own ID is the late Object of §2.1. - {"Object at END_OF_GROUP's ID", []step{status(1, 2, eog), sub(1, 2, 1)}, false}, - {"Object at END_OF_TRACK's ID", []step{status(1, 2, eot), sub(1, 2, 1)}, false}, - {"Object at the ID after an END_OF_GROUP FIN", []step{ - sub(1, 0, 0), sub(1, 1, 0), fin(0, 1, true), sub(1, 2, 1), - }, true}, - {"Object, then END_OF_GROUP at its ID", []step{sub(1, 2, 1), statusIn(2, 1, eog)}, false}, - - // A status arriving as a duplicate of a Normal Object still ends it. - {"Object past an END_OF_GROUP that was a duplicate", []step{ - sub(1, 2, 1), statusIn(2, 1, eog), sub(1, 3, 2), - }, true}, - {"Object past an END_OF_TRACK that was a duplicate", []step{ - sub(1, 2, 1), statusIn(2, 1, eot), sub(2, 0, 0), - }, true}, - // A FIN or bit end wins over a status at the same ID, in every order. - {"Object and END_OF_GROUP at 2, then a FIN after 1", []step{ - sub(1, 2, 1), statusIn(2, 1, eog), sub(1, 0, 0), sub(1, 1, 0), fin(0, 1, true), - }, true}, - {"a FIN after 1 and END_OF_GROUP at 2, then an Object at 2", []step{ - sub(1, 0, 0), sub(1, 1, 0), fin(0, 1, true), statusIn(2, 1, eog), sub(1, 2, 1), - }, true}, - {"END_OF_GROUP at 2 and an Object there, then a FIN after 1", []step{ - statusIn(2, 1, eog), sub(1, 2, 1), sub(1, 0, 0), sub(1, 1, 0), fin(0, 1, true), - }, true}, - // Status Objects past an end, and END_OF_TRACK ending its Group. - {"END_OF_TRACK past an END_OF_GROUP", []step{status(1, 3, eog), status(1, 5, eot)}, true}, - {"END_OF_GROUP past an END_OF_TRACK", []step{status(1, 5, eot), status(1, 7, eog)}, true}, - {"END_OF_GROUP in a Group past END_OF_TRACK", []step{status(1, 5, eot), status(2, 0, eog)}, true}, - {"END_OF_TRACK past an END_OF_GROUP FIN", []step{ - sub(1, 0, 0), sub(1, 1, 0), fin(0, 1, true), status(1, 5, eot), - }, true}, - {"END_OF_GROUP past a Subgroup's FIN", []step{ - sub(1, 0, 0), sub(1, 1, 0), fin(0, 1, false), statusIn(3, 0, eog), - }, true}, - {"END_OF_TRACK below an END_OF_GROUP in a later Group", []step{status(5, 0, eog), status(3, 2, eot)}, true}, - {"a Subgroup's FIN below an END_OF_GROUP in it", []step{ - sub(1, 1, 0), statusIn(5, 0, eog), fin(0, 1, false), - }, true}, - // A status Object at a hard end, then a status end one below it. - {"END_OF_GROUP at an END_OF_GROUP FIN's end, then one below", []step{ - sub(1, 2, 0), fin(0, 2, true), statusIn(3, 1, eog), statusIn(2, 2, eog), - }, true}, - {"END_OF_GROUP at a datagram's END_OF_GROUP end, then one below", []step{ - claim(registry.ObjectInfo{Group: 1, Object: 2, Datagram: true, EndOfGroup: true}), - status(1, 3, eog), statusIn(2, 2, eog), - }, true}, - // A Subgroup whose every Object was dropped has no priority yet. - {"FIN of a Subgroup with nothing recorded, then its Object", []step{ - prio(0, 0, 5), - func(e *registry.TrackEntry) error { - return e.SubgroupEnded(registry.ObjectInfo{Group: 1, Object: 4, Subgroup: 7, Priority: 5}, false) - }, - prio(1, 7, 5), - }, false}, - {"END_OF_TRACK at an END_OF_GROUP FIN's end", []step{ - sub(1, 0, 0), sub(1, 1, 0), fin(0, 1, true), status(1, 2, eot), - }, false}, - } { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - e := registry.NewTrackRegistry().GetOrCreate(newTestTrackName("finals")) - last := len(tc.steps) - 1 - for i, s := range tc.steps { - err := s(e) - if i < last && err != nil { - t.Fatalf("step %d: %v", i, err) - } - if i == last && (err != nil) != tc.malformed { - t.Fatalf("last step: err = %v, want malformed = %v", err, tc.malformed) - } - if err != nil && !errors.Is(err, session.ErrMalformedTrack) { - t.Fatalf("err = %v, want it to wrap session.ErrMalformedTrack", err) - } - } - }) - } -} - -// TestTrackEntry_RecordDuplicateChecksAgain: a duplicate's end is checked -// again when recorded, against what was claimed after its ClaimDelivered. -func TestTrackEntry_RecordDuplicateChecksAgain(t *testing.T) { - t.Parallel() - e := registry.NewTrackRegistry().GetOrCreate(newTestTrackName("race")) - claim := func(o registry.ObjectInfo, wantFresh bool) { - t.Helper() - if fresh, err := e.ClaimDelivered(o); err != nil || fresh != wantFresh { - t.Fatalf("ClaimDelivered(%+v) = (%v, %v), want (%v, nil)", o, fresh, err, wantFresh) - } - } - claim(registry.ObjectInfo{Group: 1, Object: 2}, true) - eog := registry.ObjectInfo{Group: 1, Object: 2, Status: message.ObjectStatusEndOfGroup} - claim(eog, false) // a duplicate; the caller's §9.1 check runs now - claim(registry.ObjectInfo{Group: 1, Object: 5}, true) // meanwhile, another upstream - if err := e.RecordDuplicate(eog); !errors.Is(err, session.ErrMalformedTrack) { - t.Fatalf("RecordDuplicate = %v, want the Group ending below Object 5 to be malformed", err) - } -} - -// TestTrackEntry_LateEndPurgesCache: an end placed below Normal Objects -// already received makes them past it, and Object(s) triggering Malformed -// Track status MUST NOT be cached (§2.4.2). -func TestTrackEntry_LateEndPurgesCache(t *testing.T) { - t.Parallel() - e := registry.NewTrackRegistry().GetOrCreate(newTestTrackName("purge")) - for _, id := range []uint64{0, 1, 2} { - e.Cache.Put(&cache.CachedObject{GroupID: 1, ObjectID: id, Payload: []byte("x")}) - if _, err := e.ClaimDelivered(registry.ObjectInfo{Group: 1, Object: id}); err != nil { - t.Fatalf("ClaimDelivered %d: %v", id, err) - } - } - if err := e.SubgroupEnded(registry.ObjectInfo{Group: 1, Object: 1}, false); err == nil { - t.Fatal("a FIN after 1 with Object 2 received is not malformed") - } - if _, ok := e.Cache.Get(1, 2); ok { - t.Error("Object 2, past the Subgroup's end, is still cached") - } - if _, ok := e.Cache.Get(1, 1); !ok { - t.Error("Object 1 was removed from the cache") - } - - // Two conflicting ends: the Objects past the lower one go too. - e = registry.NewTrackRegistry().GetOrCreate(newTestTrackName("purge-conflict")) - for _, id := range []uint64{0, 1} { - e.Cache.Put(&cache.CachedObject{GroupID: 1, ObjectID: id, Payload: []byte("x")}) - if _, err := e.ClaimDelivered(registry.ObjectInfo{Group: 1, Object: id}); err != nil { - t.Fatalf("ClaimDelivered %d: %v", id, err) - } - } - if err := e.SubgroupEnded(registry.ObjectInfo{Group: 1, Object: 1}, false); err != nil { - t.Fatalf("a FIN after 1: %v", err) - } - if err := e.SubgroupEnded(registry.ObjectInfo{Group: 1, Object: 0}, false); err == nil { - t.Fatal("a second FIN, after 0, is not malformed") - } - if _, ok := e.Cache.Get(1, 1); ok { - t.Error("Object 1, past the lower end, is still cached") - } - - // END_OF_TRACK: Objects past it, status Objects included, go too. - eot, eog := message.ObjectStatusEndOfTrack, message.ObjectStatusEndOfGroup - for _, tc := range []struct { - name string - cached []registry.ObjectInfo - late registry.ObjectInfo - gone []message.Location - kept []message.Location - }{ - { - "a second END_OF_TRACK", - []registry.ObjectInfo{{Group: 1, Object: 0}, {Group: 1, Object: 3, Status: eot}}, - registry.ObjectInfo{Group: 1, Object: 1, Status: eot}, - []message.Location{{Group: 1, Object: 3}}, - []message.Location{{Group: 1, Object: 0}}, - }, - { - "END_OF_GROUP in a later Group", - []registry.ObjectInfo{{Group: 2, Object: 0, Status: eog}}, - registry.ObjectInfo{Group: 1, Object: 0, Status: eot}, - []message.Location{{Group: 2, Object: 0}}, - nil, - }, - { - "END_OF_GROUP in its Group, and a later Group", - []registry.ObjectInfo{ - {Group: 1, Object: 0}, {Group: 1, Object: 5, Subgroup: 1, Status: eog}, {Group: 2, Object: 1}, - }, - registry.ObjectInfo{Group: 1, Object: 2, Subgroup: 2, Status: eot}, - []message.Location{{Group: 1, Object: 5}, {Group: 2, Object: 1}}, - []message.Location{{Group: 1, Object: 0}}, - }, - } { - e := registry.NewTrackRegistry().GetOrCreate(newTestTrackName("purge-eot")) - for _, o := range tc.cached { - e.Cache.Put( - &cache.CachedObject{GroupID: o.Group, ObjectID: o.Object, SubgroupID: o.Subgroup, Status: o.Status}, - ) - if _, err := e.ClaimDelivered(o); err != nil { - t.Fatalf("%s: ClaimDelivered %+v: %v", tc.name, o, err) - } - } - if _, err := e.ClaimDelivered(tc.late); err == nil { - t.Fatalf("%s: END_OF_TRACK below an Object received is not malformed", tc.name) - } - for _, l := range tc.gone { - if _, ok := e.Cache.Get(l.Group, l.Object); ok { - t.Errorf("%s: Object %d of Group %d, past END_OF_TRACK, is still cached", tc.name, l.Object, l.Group) - } - } - for _, l := range tc.kept { - if _, ok := e.Cache.Get(l.Group, l.Object); !ok { - t.Errorf("%s: Object %d of Group %d, before END_OF_TRACK, was removed", tc.name, l.Object, l.Group) - } - } - } -} - -// TestTrackRegistry_GetMissingReturnsFalse confirms the unknown-key path of -// Get is a clean miss rather than a zero entry. +// TestTrackRegistry_GetMissingReturnsFalse: Get of an unknown key is a clean +// miss, not a zero entry. func TestTrackRegistry_GetMissingReturnsFalse(t *testing.T) { t.Parallel() r := registry.NewTrackRegistry() @@ -516,8 +26,8 @@ func TestTrackRegistry_GetMissingReturnsFalse(t *testing.T) { } } -// TestTrackRegistry_GetOrCreateIsIdempotent verifies that two calls with the -// same name return the same entry pointer — the whole point of the registry. +// TestTrackRegistry_GetOrCreateIsIdempotent: two calls with the same name +// return the same entry pointer. func TestTrackRegistry_GetOrCreateIsIdempotent(t *testing.T) { t.Parallel() r := registry.NewTrackRegistry() @@ -535,10 +45,8 @@ func TestTrackRegistry_GetOrCreateIsIdempotent(t *testing.T) { } } -// TestTrackRegistry_AddUpstreamFirstSignal verifies the becameNonEmpty -// boolean fires exactly on the first upstream and not on subsequent -// ones. The Discovery Store hooks publish onto this signal, so the -// contract is pinned here. +// TestTrackRegistry_AddUpstreamFirstSignal: becameNonEmpty fires on the first +// upstream only. The Discovery Store hooks publish onto this signal. func TestTrackRegistry_AddUpstreamFirstSignal(t *testing.T) { t.Parallel() r := registry.NewTrackRegistry() @@ -562,10 +70,8 @@ func TestTrackRegistry_AddUpstreamFirstSignal(t *testing.T) { } } -// TestTrackRegistry_RemoveUpstreamEmptyTransitions exercises the -// upstreamEmpty / entryDeleted signals across the full lifecycle: two -// upstreams added, removed one at a time, with no downstream — the second -// removal must delete the entry from the registry. +// TestTrackRegistry_RemoveUpstreamEmptyTransitions: with no downstream, +// removing the last of two upstreams reports empty and deletes the entry. func TestTrackRegistry_RemoveUpstreamEmptyTransitions(t *testing.T) { t.Parallel() r := registry.NewTrackRegistry() @@ -590,10 +96,9 @@ func TestTrackRegistry_RemoveUpstreamEmptyTransitions(t *testing.T) { } } -// TestTrackRegistry_EntryRetainedWhileDownstreamRemains verifies the cleanup -// rule: removing the last upstream must NOT delete the entry while -// downstream subscribers are still present. Conversely the *entry* must -// signal upstreamEmpty so the Discovery store can unpublish. +// TestTrackRegistry_EntryRetainedWhileDownstreamRemains: removing the last +// upstream keeps the entry while a downstream remains, but still reports +// upstreamEmpty so the Discovery Store can unpublish. func TestTrackRegistry_EntryRetainedWhileDownstreamRemains(t *testing.T) { t.Parallel() r := registry.NewTrackRegistry() @@ -613,7 +118,6 @@ func TestTrackRegistry_EntryRetainedWhileDownstreamRemains(t *testing.T) { t.Fatal("entry no longer reachable via Get") } - // Now drop the downstream — entry should disappear. removed, empty, deleted = r.RemoveDownstream(name, 100) if !removed || !empty || !deleted { t.Fatalf("RemoveDownstream: removed=%v empty=%v deleted=%v, want true,true,true", @@ -624,9 +128,8 @@ func TestTrackRegistry_EntryRetainedWhileDownstreamRemains(t *testing.T) { } } -// TestTrackRegistry_RemoveUnknownIsNoop guards against the two "miss" paths: -// removing a sub from a track that doesn't exist, and removing a sub ID that -// isn't on a known track. Neither should mutate the registry. +// TestTrackRegistry_RemoveUnknownIsNoop: removing from an unknown track, or an +// unknown sub ID from a known one, leaves the registry untouched. func TestTrackRegistry_RemoveUnknownIsNoop(t *testing.T) { t.Parallel() r := registry.NewTrackRegistry() @@ -647,9 +150,8 @@ func TestTrackRegistry_RemoveUnknownIsNoop(t *testing.T) { } } -// TestTrackRegistry_UpdateLargestMonotonic verifies the §10.2.17 rule: the -// watermark only ever advances, and the bool return reports whether an -// advance happened. +// TestTrackRegistry_UpdateLargestMonotonic pins the §10.2.17 rule: the +// watermark only advances, and UpdateLargest reports whether it did. func TestTrackRegistry_UpdateLargestMonotonic(t *testing.T) { t.Parallel() r := registry.NewTrackRegistry() @@ -682,9 +184,9 @@ func TestTrackRegistry_UpdateLargestMonotonic(t *testing.T) { } } -// TestTrackRegistry_CopySnapshotsAreIndependent ensures the Copy* helpers -// return slices that callers may iterate without holding the entry lock and -// that mutations to the entry don't affect already-handed-out snapshots. +// TestTrackRegistry_CopySnapshotsAreIndependent: a Copy* snapshot is not +// affected by later mutations of the entry, so callers may iterate it without +// holding the entry lock. func TestTrackRegistry_CopySnapshotsAreIndependent(t *testing.T) { t.Parallel() r := registry.NewTrackRegistry() @@ -698,18 +200,16 @@ func TestTrackRegistry_CopySnapshotsAreIndependent(t *testing.T) { t.Fatalf("snapshot len = %d", len(snap)) } - // Mutate the entry, snapshot must remain unchanged. r.RemoveDownstream(name, 1) if len(snap) != 2 { t.Fatalf("snapshot mutated after RemoveDownstream: len = %d", len(snap)) } } -// TestTrackRegistry_ConcurrentAddRemove is a soak test: many goroutines -// hammer the same and adjacent keys with adds and removes. The invariant is -// "no panic, no negative count, registry empties out after all goroutines -// finish." This stresses the per-entry mutex, the registry mutex, and the -// TOCTOU re-check in tryDeleteIfEmpty. +// TestTrackRegistry_ConcurrentAddRemove is a soak test over two keys: no +// panic, no missed remove, and the registry empties out. It stresses the +// per-entry mutex, the registry mutex, and the TOCTOU re-check in +// tryDeleteIfEmpty. func TestTrackRegistry_ConcurrentAddRemove(t *testing.T) { t.Parallel() r := registry.NewTrackRegistry() @@ -744,9 +244,8 @@ func TestTrackRegistry_ConcurrentAddRemove(t *testing.T) { } } -// TestTrackRegistry_GetOrCreateRace probes the read-fast-path → write-slow-path -// transition for two goroutines both creating the same key. Both must end up -// with the same pointer and the registry must hold exactly one entry. +// TestTrackRegistry_GetOrCreateRace: two goroutines creating the same key +// through the read-fast-path → write-slow-path transition get the same entry. func TestTrackRegistry_GetOrCreateRace(t *testing.T) { t.Parallel() r := registry.NewTrackRegistry() @@ -776,16 +275,10 @@ func TestTrackRegistry_GetOrCreateRace(t *testing.T) { } } -// TestTrackRegistry_RemoveAfterResurrectionKeepsEntry exercises the TOCTOU -// guard in tryDeleteIfEmpty: between releasing the entry lock and acquiring -// the registry lock, a new downstream is added. The entry must NOT be -// deleted in that case. -// -// We simulate the race deterministically by removing the upstream, then -// observing the entry is still present and adding the resurrecting -// downstream before the deletion racing path could fire. (A pure race test -// would be flaky; the contract — "if the entry is non-empty at the moment -// we hold both locks, it survives" — is what we actually need to prove.) +// TestTrackRegistry_RemoveAfterResurrectionKeepsEntry pins the contract behind +// the TOCTOU guard in tryDeleteIfEmpty: an entry that is non-empty when both +// locks are held survives. It is exercised deterministically, since a real +// race test would be flaky. func TestTrackRegistry_RemoveAfterResurrectionKeepsEntry(t *testing.T) { t.Parallel() r := registry.NewTrackRegistry() @@ -794,13 +287,11 @@ func TestTrackRegistry_RemoveAfterResurrectionKeepsEntry(t *testing.T) { r.AddUpstream(name, ®istry.UpstreamSub{ID: 1}) r.AddDownstream(name, ®istry.DownstreamSub{ID: 100}) - // Remove the downstream — upstream is still present, entry survives. _, _, deleted := r.RemoveDownstream(name, 100) if deleted { t.Fatal("entry deleted while upstream remained") } - // Now remove the upstream — both slices empty, entry must vanish. _, _, deleted = r.RemoveUpstream(name, 1) if !deleted { t.Fatal("entry not deleted when both slices empty") @@ -810,10 +301,9 @@ func TestTrackRegistry_RemoveAfterResurrectionKeepsEntry(t *testing.T) { } } -// TestTrackRegistry_RemoveSession_EvictsAllEntriesForSession verifies the -// bulk-cleanup path: a session dies, every UpstreamSub and DownstreamSub -// belonging to that session is removed across every track. -// Tracks whose slices both become empty are dropped from the registry too. +// TestTrackRegistry_RemoveSession_EvictsAllEntriesForSession: every +// UpstreamSub and DownstreamSub of a dead session is removed across all +// tracks, and tracks left with both slices empty are dropped. func TestTrackRegistry_RemoveSession_EvictsAllEntriesForSession(t *testing.T) { t.Parallel() r := registry.NewTrackRegistry() @@ -824,10 +314,9 @@ func TestTrackRegistry_RemoveSession_EvictsAllEntriesForSession(t *testing.T) { nameVideo := newTestTrackName("video") nameAudio := newTestTrackName("audio") - // video: A is publisher, B is subscriber. + // video: A publishes, B subscribes. audio: A does both. r.AddUpstream(nameVideo, ®istry.UpstreamSub{ID: 1, Session: sessA}) r.AddDownstream(nameVideo, ®istry.DownstreamSub{ID: 100, Session: sessB}) - // audio: A is the only participant (both pub and sub). r.AddUpstream(nameAudio, ®istry.UpstreamSub{ID: 2, Session: sessA}) r.AddDownstream(nameAudio, ®istry.DownstreamSub{ID: 101, Session: sessA}) @@ -836,7 +325,6 @@ func TestTrackRegistry_RemoveSession_EvictsAllEntriesForSession(t *testing.T) { t.Fatalf("RemoveSession(A) = (%d, %d), want (2, 1)", upRemoved, downRemoved) } - // video track: A's upstream gone, B's downstream remains → entry kept. if entry, ok := r.Get(nameVideo.Key()); !ok { t.Fatal("video entry deleted while sessB's downstream remained") } else { @@ -848,15 +336,13 @@ func TestTrackRegistry_RemoveSession_EvictsAllEntriesForSession(t *testing.T) { } } - // audio track: both A's sub kinds gone → entry dropped. if _, ok := r.Get(nameAudio.Key()); ok { t.Fatal("audio entry not deleted after both slices emptied") } } -// TestTrackRegistry_RemoveSession_NoOpForUnknownSession pins the -// no-registration-for-this-session case: RemoveSession is safe to call for -// any session, even one with nothing on file. +// TestTrackRegistry_RemoveSession_NoOpForUnknownSession: RemoveSession is safe +// for a session with nothing registered. func TestTrackRegistry_RemoveSession_NoOpForUnknownSession(t *testing.T) { t.Parallel() r := registry.NewTrackRegistry() @@ -874,22 +360,14 @@ func TestTrackRegistry_RemoveSession_NoOpForUnknownSession(t *testing.T) { } } -// TestTrackRegistry_CacheTTLPolicy_OverridesDefault wires the new -// per-track TTL hook end-to-end: a policy that returns CacheTTLInfinite -// for one Name keeps that track's cached object retrievable across a -// wait that exceeds the registry's default TTL, while a sibling track -// (whose policy return falls through to the default) drops its object -// under the same wait. +// TestTrackRegistry_CacheTTLPolicy_OverridesDefault: a per-track policy +// returning CacheTTLInfinite keeps that track's object past the registry's +// default TTL, while a sibling track the policy defers on expires under it. // -// The default TTL is shrunk to a single millisecond via WithCacheConfig -// so the test stays fast and deterministic — TTL is applied at read -// time inside the ring-buffer cache, so no goroutine timing is -// involved. The test is NOT marked t.Parallel(): a sleep inside a -// parallel test starves other parallel tests in this package that -// rely on tight scheduling (FETCH integration tests in particular), -// turning a real green run into a 30 s wait. Running serially keeps -// the wall clock cost under 20 ms while leaving the parallel pool -// free. +// TTL is applied at read time, so a 1ms default keeps this deterministic. Not +// t.Parallel(): a sleep inside a parallel test starves the timing-sensitive +// parallel tests in this package (FETCH integration especially) and turns a +// green run into a 30s wait; serially it costs under 20ms. func TestTrackRegistry_CacheTTLPolicy_OverridesDefault(t *testing.T) { const ( defaultTTL = time.Millisecond @@ -911,16 +389,12 @@ func TestTrackRegistry_CacheTTLPolicy_OverridesDefault(t *testing.T) { registry.WithCacheTTLPolicy(policy), ) - // Materialise both entries. catEntry := r.GetOrCreate(catalog) othEntry := r.GetOrCreate(other) - // Put one object per entry. ReceivedAt is left as time.Now() by - // Put's defaulting; we verify retention by waiting past defaultTTL. catEntry.Cache.Put(&cache.CachedObject{GroupID: 0, ObjectID: 0, Payload: []byte("catalog-payload")}) othEntry.Cache.Put(&cache.CachedObject{GroupID: 0, ObjectID: 0, Payload: []byte("video-payload")}) - // Sanity: both are visible immediately. if _, ok := catEntry.Cache.Get(0, 0); !ok { t.Fatal("catalog Get returned ok=false immediately after Put") } @@ -938,13 +412,9 @@ func TestTrackRegistry_CacheTTLPolicy_OverridesDefault(t *testing.T) { } } -// TestTrackRegistry_CacheTTLPolicy_NilFallback verifies that a nil -// policy is the same as not installing one — every track uses the -// registry default. Pinned because the resolve helper has an early -// return for nil that is easy to break. -// -// Serial (see TestTrackRegistry_CacheTTLPolicy_OverridesDefault for -// the rationale). +// TestTrackRegistry_CacheTTLPolicy_NilFallback: a nil policy is the same as +// none — the resolve helper's early return for nil is easy to break. Serial, +// for the reason on TestTrackRegistry_CacheTTLPolicy_OverridesDefault. func TestTrackRegistry_CacheTTLPolicy_NilFallback(t *testing.T) { const defaultTTL = time.Millisecond @@ -961,14 +431,10 @@ func TestTrackRegistry_CacheTTLPolicy_NilFallback(t *testing.T) { } } -// TestTrackRegistry_CacheTTLPolicy_ZeroReturnMeansDefault pins the -// "policy returned 0 → use the registry default" branch. Policy -// authors should be able to encode "I don't care about this track" -// by returning the zero value, without having to know what the -// configured default is. -// -// Serial (see TestTrackRegistry_CacheTTLPolicy_OverridesDefault for -// the rationale). +// TestTrackRegistry_CacheTTLPolicy_ZeroReturnMeansDefault: a policy returning +// 0 uses the registry default, so a policy can decline a track without knowing +// the configured default. Serial, for the reason on +// TestTrackRegistry_CacheTTLPolicy_OverridesDefault. func TestTrackRegistry_CacheTTLPolicy_ZeroReturnMeansDefault(t *testing.T) { const defaultTTL = time.Millisecond From 2f6784d786012f817cb278f4b6c5c83887853e73 Mon Sep 17 00:00:00 2001 From: Vsevolod Strukchinsky Date: Sat, 26 Sep 2026 15:39:07 +0500 Subject: [PATCH 2/5] test(session): split datastream_object_test by topic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split the 1020-line datastream_object_test.go into datastream_subgroup_test.go (subgroup object encode/decode), datastream_subgroup_delta_test.go (WriteObjectAt / §11.4.2 Object ID deltas and SubgroupID modes) and datastream_fetch_test.go (fetch objects and the §11.4.4 decoder). The open/write/close/accept writer goroutine repeated in every test is now sendSubgroup + writeObjects in helpers_test.go (used by both subgroup files) and sendFetch in the fetch file (its only user). Comments trimmed to contract/citation/why; every § citation is kept. Table-driven: TestSubgroupObjectWithProperties is now TestSubgroupObjectRoundTrip/with_properties (plain case: .../without_properties), and TestIncomingFetchStream_ReadDecoded_ {Ascending,Descending,EndOfRange} are now subtests of TestIncomingFetchStream_ReadDecoded; the MarkerFirst violation subtests share one loop under their existing names. Merged cases now assert every decoded field plus trailing EOF, where some checked a subset. Test set otherwise unchanged: top-level 248 -> 245 and run names 412 -> 414 (subtests 167 -> 172), the difference being exactly the renames above. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/moqt/session/datastream_fetch_test.go | 401 +++++++ pkg/moqt/session/datastream_object_test.go | 1020 ----------------- .../session/datastream_subgroup_delta_test.go | 157 +++ pkg/moqt/session/datastream_subgroup_test.go | 107 ++ pkg/moqt/session/helpers_test.go | 45 + 5 files changed, 710 insertions(+), 1020 deletions(-) create mode 100644 pkg/moqt/session/datastream_fetch_test.go delete mode 100644 pkg/moqt/session/datastream_object_test.go create mode 100644 pkg/moqt/session/datastream_subgroup_delta_test.go create mode 100644 pkg/moqt/session/datastream_subgroup_test.go diff --git a/pkg/moqt/session/datastream_fetch_test.go b/pkg/moqt/session/datastream_fetch_test.go new file mode 100644 index 00000000..e0da1ccc --- /dev/null +++ b/pkg/moqt/session/datastream_fetch_test.go @@ -0,0 +1,401 @@ +package session_test + +import ( + "errors" + "fmt" + "io" + "testing" + + "github.com/floatdrop/moq-go/pkg/moqt/message" + "github.com/floatdrop/moq-go/pkg/moqt/session" +) + +// sendFetch writes objs on a new fetch stream (Request ID 0) from from, closes +// it, and returns to's end with the writer's result. The writer runs in a +// goroutine because the test pipe is synchronous. +func sendFetch( + t *testing.T, + from, to *session.Session, + objs ...*message.FetchObject, +) (*session.IncomingFetchStream, <-chan error) { + t.Helper() + writeErr := make(chan error, 1) + go func() { + out, err := from.OpenFetchStream(message.FetchHeader{RequestID: 0}) + if err != nil { + writeErr <- err + return + } + for _, o := range objs { + if err := out.WriteObject(o); err != nil { + writeErr <- err + return + } + } + writeErr <- out.Close() + }() + ds, err := to.AcceptDataStream(t.Context()) + if err != nil { + t.Fatalf("AcceptDataStream: %v", err) + } + in, ok := ds.(*session.IncomingFetchStream) + if !ok { + t.Fatalf("AcceptDataStream returned %T, want *session.IncomingFetchStream", ds) + } + return in, writeErr +} + +// TestFetchObjectRoundTrip: a FetchObject written with WriteObject reads back +// unchanged via AcceptDataStream + ReadObject. +func TestFetchObjectRoundTrip(t *testing.T) { + cli, srv := openPair(t) + obj := &message.FetchObject{ + SerializationFlags: message.FetchFlagGroupIDDelta | message.FetchFlagObjectIDDelta | + message.FetchFlagPriority, + GroupIDDelta: 3, + ObjectIDDelta: 1, + ObjectPayload: []byte("fetch-payload"), + } + in, writeErr := sendFetch(t, cli, srv, obj) + + if in.Header.RequestID != 0 { + t.Errorf("RequestID: got %d, want 0", in.Header.RequestID) + } + got, err := in.ReadObject() + if err != nil { + t.Fatalf("ReadObject: %v", err) + } + if string(got.ObjectPayload) != string(obj.ObjectPayload) { + t.Errorf("payload: got %q, want %q", got.ObjectPayload, obj.ObjectPayload) + } + if got.GroupIDDelta != obj.GroupIDDelta { + t.Errorf("GroupIDDelta: got %d, want %d", got.GroupIDDelta, obj.GroupIDDelta) + } + if err := <-writeErr; err != nil { + t.Errorf("writer: %v", err) + } +} + +// TestIncomingFetchStream_ReadDecoded exercises the session-layer §11.4.4 +// delta decoder: first-object absolute, same-group and cross-group transitions +// in both group orders, the subgroup modes, and End of Range markers. +func TestIncomingFetchStream_ReadDecoded(t *testing.T) { + tests := []struct { + name string + order message.GroupOrder + written []*message.FetchObject + want []session.DecodedFetchObject + }{ + { + // Mirrors what the relay's streamFetchObjects writes for an + // ascending FETCH response. §11.4.4.1: 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 an absent Object + // ID Delta means "the prior Object's ID plus one, regardless of + // which group it belongs to". + name: "Ascending", + written: []*message.FetchObject{ + { + SerializationFlags: message.FetchFlagGroupIDDelta | + message.FetchFlagObjectIDDelta | + uint64(message.FetchSubgroupIDExplicit) | + message.FetchFlagPriority, + GroupIDDelta: 5, + ObjectIDDelta: 2, + SubgroupID: 10, + PublisherPriority: 7, + ObjectPayload: []byte("o1"), + }, + { + // Same group, delta absent → 2+1; inherits subgroup (Prior) + // and priority (no flag). + SerializationFlags: uint64(message.FetchSubgroupIDPrior), + ObjectPayload: []byte("o2"), + }, + { + // Same group, ObjectIDDelta=3 → 3+3, Sub=Prior+1. + SerializationFlags: message.FetchFlagObjectIDDelta | + uint64(message.FetchSubgroupIDPriorPlusOne), + ObjectIDDelta: 3, + ObjectPayload: []byte("o3"), + }, + { + // Cross-group; ascending → newG = prevG + delta + 1 = 5+2+1 = 8. + SerializationFlags: message.FetchFlagGroupIDDelta | + message.FetchFlagObjectIDDelta | + uint64(message.FetchSubgroupIDExplicit) | + message.FetchFlagPriority, + GroupIDDelta: 2, + ObjectIDDelta: 0, + SubgroupID: 20, + PublisherPriority: 9, + ObjectPayload: []byte("o4"), + }, + { + // Cross-group (+1) with the Object ID Delta omitted: prior ID + 1. + SerializationFlags: message.FetchFlagGroupIDDelta | + uint64(message.FetchSubgroupIDPrior), + GroupIDDelta: 0, + ObjectPayload: []byte("o5"), + }, + }, + want: []session.DecodedFetchObject{ + {GroupID: 5, ObjectID: 2, SubgroupID: 10, PublisherPriority: 7, Payload: []byte("o1")}, + {GroupID: 5, ObjectID: 3, SubgroupID: 10, PublisherPriority: 7, Payload: []byte("o2")}, + {GroupID: 5, ObjectID: 6, SubgroupID: 11, PublisherPriority: 7, Payload: []byte("o3")}, + {GroupID: 8, ObjectID: 0, SubgroupID: 20, PublisherPriority: 9, Payload: []byte("o4")}, + {GroupID: 9, ObjectID: 1, SubgroupID: 20, PublisherPriority: 9, Payload: []byte("o5")}, + }, + }, + { + // The caller signals direction via IncomingFetchStream.GroupOrder. + name: "Descending", + order: message.GroupOrderDescending, + written: []*message.FetchObject{ + { + SerializationFlags: message.FetchFlagGroupIDDelta | + message.FetchFlagObjectIDDelta | + message.FetchFlagPriority | + uint64(message.FetchSubgroupIDExplicit), + GroupIDDelta: 10, + ObjectIDDelta: 0, + SubgroupID: 0, + ObjectPayload: []byte("g10"), + }, + { + // Cross-group descending: prevG - delta - 1 = 10 - 1 - 1 = 8. + SerializationFlags: message.FetchFlagGroupIDDelta | + message.FetchFlagObjectIDDelta | + uint64(message.FetchSubgroupIDExplicit), + GroupIDDelta: 1, + ObjectIDDelta: 0, + SubgroupID: 0, + ObjectPayload: []byte("g8"), + }, + }, + want: []session.DecodedFetchObject{ + {GroupID: 10, ObjectID: 0, Payload: []byte("g10")}, + {GroupID: 8, ObjectID: 0, Payload: []byte("g8")}, + }, + }, + { + // End of Range markers surface as EndOfNonExistentRange and become + // the prior Group and Object ID; the prior Subgroup ID and Priority + // stay the last Object's (§11.4.4.2). + name: "EndOfRange", + written: []*message.FetchObject{ + { + SerializationFlags: message.FetchFlagGroupIDDelta | + message.FetchFlagObjectIDDelta | + message.FetchFlagPriority | + uint64(message.FetchSubgroupIDExplicit), + GroupIDDelta: 5, + ObjectIDDelta: 0, + SubgroupID: 9, + PublisherPriority: 42, + ObjectPayload: []byte("real"), + }, + // Marker carrying absolute {7, 3}. + { + SerializationFlags: message.FetchEndOfNonExistentRange, + GroupIDDelta: 7, + ObjectIDDelta: 3, + }, + // No deltas: per §11.4.4.2 the prior Group/Object IDs are the + // MARKER's, so it decodes as {7, 4}; its Subgroup (mode Prior) and + // Priority (flag absent) come from the last ACTUAL object. + { + SerializationFlags: uint64(message.FetchSubgroupIDPrior), + ObjectPayload: []byte("real2"), + }, + }, + want: []session.DecodedFetchObject{ + {GroupID: 5, ObjectID: 0, SubgroupID: 9, PublisherPriority: 42, Payload: []byte("real")}, + {GroupID: 7, ObjectID: 3, EndOfNonExistentRange: true}, + // The marker IS the prior (§11.4.4.2). + {GroupID: 7, ObjectID: 4, SubgroupID: 9, PublisherPriority: 42, Payload: []byte("real2")}, + }, + }, + } + + view := func(d *session.DecodedFetchObject) string { + return fmt.Sprintf("{G=%d O=%d Sub=%d Pri=%d payload=%q endOfNonExistentRange=%t}", + d.GroupID, d.ObjectID, d.SubgroupID, d.PublisherPriority, d.Payload, d.EndOfNonExistentRange) + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cli, srv := openPair(t) + in, writeErr := sendFetch(t, cli, srv, tc.written...) + in.GroupOrder = tc.order + + for i := range tc.want { + got, err := in.ReadDecoded() + if err != nil { + t.Fatalf("ReadDecoded #%d: %v", i, err) + } + if g, w := view(got), view(&tc.want[i]); g != w { + t.Errorf("obj #%d: got %s, want %s", i, g, w) + } + } + if _, err := in.ReadDecoded(); !errors.Is(err, io.EOF) { + t.Errorf("trailing ReadDecoded: got %v, want io.EOF", err) + } + if err := <-writeErr; err != nil { + t.Errorf("writer: %v", err) + } + }) + } +} + +// 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 + first *message.FetchObject + }{ + { + name: "missing group and object id deltas", + first: &message.FetchObject{ + SerializationFlags: uint64(message.FetchSubgroupIDZero), + ObjectPayload: []byte("x"), + }, + }, + { + name: "missing group id delta", + first: &message.FetchObject{ + SerializationFlags: message.FetchFlagObjectIDDelta | + uint64(message.FetchSubgroupIDZero), + ObjectIDDelta: 3, + ObjectPayload: []byte("x"), + }, + }, + { + name: "missing object id delta", + first: &message.FetchObject{ + SerializationFlags: message.FetchFlagGroupIDDelta | + message.FetchFlagPriority | + uint64(message.FetchSubgroupIDZero), + GroupIDDelta: 5, + ObjectPayload: []byte("x"), + }, + }, + { + name: "references prior priority", + first: &message.FetchObject{ + SerializationFlags: message.FetchFlagGroupIDDelta | + message.FetchFlagObjectIDDelta | + uint64(message.FetchSubgroupIDZero), + GroupIDDelta: 5, + ObjectIDDelta: 2, + ObjectPayload: []byte("x"), + }, + }, + { + name: "references prior subgroup", + first: &message.FetchObject{ + SerializationFlags: message.FetchFlagGroupIDDelta | + message.FetchFlagObjectIDDelta | + uint64(message.FetchSubgroupIDPrior), + GroupIDDelta: 5, + ObjectIDDelta: 2, + ObjectPayload: []byte("x"), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cli, srv := openPair(t) + in, writeErr := sendFetch(t, cli, srv, tt.first) + + if _, err := in.ReadDecoded(); err == nil { + t.Errorf("ReadDecoded: expected PROTOCOL_VIOLATION error, got nil") + } + requireClosedProtocolViolation(t, srv) + if err := <-writeErr; err != nil { + t.Errorf("writer: %v", err) + } + }) + } +} + +// TestIncomingFetchStream_ReadDecoded_MarkerFirst pins the §11.4.4.2 rules +// when an End-of-Range marker is the FIRST element on the stream: the marker +// supplies the prior Group/Object IDs, so the following object may omit the +// deltas — but with no prior ACTUAL object it must not reference the prior +// Subgroup ID or Priority. +func TestIncomingFetchStream_ReadDecoded_MarkerFirst(t *testing.T) { + marker := &message.FetchObject{ + SerializationFlags: message.FetchEndOfNonExistentRange, + GroupIDDelta: 3, // absolute Group ID + ObjectIDDelta: 6, // absolute Object ID + } + + t.Run("object after leading marker uses it as prior", func(t *testing.T) { + cli, srv := openPair(t) + in, writeErr := sendFetch(t, cli, srv, marker, &message.FetchObject{ + // No deltas: prior = the marker → {3, 7}. Subgroup and priority + // are spelled out (no prior actual object exists). + SerializationFlags: message.FetchFlagPriority | + uint64(message.FetchSubgroupIDExplicit), + SubgroupID: 2, + PublisherPriority: 5, + ObjectPayload: []byte("after"), + }) + + if m, err := in.ReadDecoded(); err != nil || !m.EndOfNonExistentRange { + t.Fatalf("marker read: %v %+v", err, m) + } + obj, err := in.ReadDecoded() + if err != nil { + t.Fatalf("object after leading marker: %v", err) + } + if obj.GroupID != 3 || obj.ObjectID != 7 { + t.Errorf("decoded {%d,%d}, want {3,7} (marker as prior)", obj.GroupID, obj.ObjectID) + } + if err := <-writeErr; err != nil { + t.Errorf("writer: %v", err) + } + }) + + violations := []struct { + name string + next *message.FetchObject + }{ + { + name: "prior-subgroup mode with no prior object is a violation", + next: &message.FetchObject{ + SerializationFlags: message.FetchFlagPriority | + uint64(message.FetchSubgroupIDPrior), + PublisherPriority: 5, + ObjectPayload: []byte("bad"), + }, + }, + { + name: "absent priority with no prior object is a violation", + next: &message.FetchObject{ + SerializationFlags: uint64(message.FetchSubgroupIDZero), + ObjectPayload: []byte("bad"), + }, + }, + } + for _, tc := range violations { + t.Run(tc.name, func(t *testing.T) { + cli, srv := openPair(t) + in, writeErr := sendFetch(t, cli, srv, marker, tc.next) + + if _, err := in.ReadDecoded(); err != nil { + t.Fatalf("marker read: %v", err) + } + if _, err := in.ReadDecoded(); err == nil { + t.Error("expected a violation, got nil") + } + requireClosedProtocolViolation(t, srv) + if err := <-writeErr; err != nil { + t.Errorf("writer: %v", err) + } + }) + } +} diff --git a/pkg/moqt/session/datastream_object_test.go b/pkg/moqt/session/datastream_object_test.go deleted file mode 100644 index 7ddc97b5..00000000 --- a/pkg/moqt/session/datastream_object_test.go +++ /dev/null @@ -1,1020 +0,0 @@ -package session_test - -import ( - "errors" - "io" - "testing" - - "github.com/floatdrop/moq-go/pkg/moqt/message" - "github.com/floatdrop/moq-go/pkg/moqt/session" -) - -// TestSubgroupObjectReadRejectsInvalidStatus confirms ReadObject validates -// each decoded object: an object with an empty payload and a status that is -// not Normal/EndOfGroup/EndOfTrack is a §11 protocol violation and must be -// rejected on read, not surfaced as a valid object. -func TestSubgroupObjectReadRejectsInvalidStatus(t *testing.T) { - cli, srv := openPair(t) - ctx := t.Context() - - hdr := message.SubgroupHeader{ - TrackAlias: 42, - GroupID: 7, - SubgroupIDMode: message.SubgroupIDImplicitZero, - } - // 0x2 is not a defined Object Status; with an empty payload it must fail. - bad := &message.SubgroupObject{ObjectIDDelta: 0, ObjectStatus: 0x2} - - writeErr := make(chan error, 1) - go func() { - out, err := cli.OpenSubgroup(hdr) - if err != nil { - writeErr <- err - return - } - if err := out.WriteObject(bad); err != nil { - writeErr <- err - return - } - writeErr <- out.Close() - }() - - ds, err := srv.AcceptDataStream(ctx) - if err != nil { - t.Fatalf("AcceptDataStream: %v", err) - } - in, ok := ds.(*session.IncomingSubgroupStream) - if !ok { - t.Fatalf("AcceptDataStream returned %T, want *session.IncomingSubgroupStream", ds) - } - if _, err := in.ReadObject(); err == nil { - t.Fatal("ReadObject must reject an object with an invalid status") - } - <-writeErr -} - -// TestSubgroupObjectRoundTrip opens a SUBGROUP_HEADER uni-stream from the -// client, writes two SubgroupObjects via WriteObject, closes the stream, then -// reads them back on the server via AcceptDataStream + ReadObject. -// -// The in-process pipe is synchronous: Write blocks until Read consumes the -// bytes. We therefore run the writer in a goroutine so the reader (main -// goroutine) can drain concurrently. -func TestSubgroupObjectRoundTrip(t *testing.T) { - cli, srv := openPair(t) - ctx := t.Context() - - hdr := message.SubgroupHeader{ - TrackAlias: 42, - GroupID: 7, - SubgroupID: 0, - SubgroupIDMode: message.SubgroupIDImplicitZero, - Properties: false, - } - - obj1 := &message.SubgroupObject{ObjectIDDelta: 0, Payload: []byte("hello")} - obj2 := &message.SubgroupObject{ObjectIDDelta: 0, Payload: []byte("world")} - - // Run the writer in a goroutine: the in-process pipe is synchronous so - // Write blocks until the reader consumes the bytes. - writeErr := make(chan error, 1) - go func() { - outStream, err := cli.OpenSubgroup(hdr) - if err != nil { - writeErr <- err - return - } - if err := outStream.WriteObject(obj1); err != nil { - writeErr <- err - return - } - if err := outStream.WriteObject(obj2); err != nil { - writeErr <- err - return - } - writeErr <- outStream.Close() - }() - - // Accept and read on the main goroutine. - ds, err := srv.AcceptDataStream(ctx) - if err != nil { - t.Fatalf("AcceptDataStream: %v", err) - } - inStream, ok := ds.(*session.IncomingSubgroupStream) - if !ok { - t.Fatalf("AcceptDataStream returned %T, want *session.IncomingSubgroupStream", ds) - } - - if inStream.Header.TrackAlias != hdr.TrackAlias || inStream.Header.GroupID != hdr.GroupID { - t.Errorf("header mismatch: got %+v, want %+v", inStream.Header, hdr) - } - - for i, want := range []*message.SubgroupObject{obj1, obj2} { - got, err := inStream.ReadObject() - if err != nil { - t.Fatalf("ReadObject(%d): %v", i, err) - } - if string(got.Payload) != string(want.Payload) { - t.Errorf("object %d payload: got %q, want %q", i, got.Payload, want.Payload) - } - if got.ObjectIDDelta != want.ObjectIDDelta { - t.Errorf("object %d delta: got %d, want %d", i, got.ObjectIDDelta, want.ObjectIDDelta) - } - } - - // After the sender closes, ReadObject should return (wrapped) io.EOF. - _, err = inStream.ReadObject() - if !errors.Is(err, io.EOF) { - t.Errorf("ReadObject after close: got %v, want io.EOF", err) - } - - if err := <-writeErr; err != nil { - t.Errorf("writer goroutine: %v", err) - } -} - -// TestSubgroupObjectWithProperties verifies that the Properties flag is -// correctly propagated from the header to ReadObject/WriteObject without the -// caller needing to track it manually. -func TestSubgroupObjectWithProperties(t *testing.T) { - cli, srv := openPair(t) - ctx := t.Context() - - hdr := message.SubgroupHeader{ - TrackAlias: 1, - GroupID: 0, - Properties: true, // objects carry a properties blob - } - obj := &message.SubgroupObject{ - ObjectIDDelta: 0, - // One property: Type 2 (a varint value), value 7. - Properties: []byte{0x02, 0x07}, - Payload: []byte("data"), - } - - writeErr := make(chan error, 1) - go func() { - outStream, err := cli.OpenSubgroup(hdr) - if err != nil { - writeErr <- err - return - } - if err := outStream.WriteObject(obj); err != nil { - writeErr <- err - return - } - writeErr <- outStream.Close() - }() - - ds, err := srv.AcceptDataStream(ctx) - if err != nil { - t.Fatalf("AcceptDataStream: %v", err) - } - inStream, ok := ds.(*session.IncomingSubgroupStream) - if !ok { - t.Fatalf("AcceptDataStream returned %T, want *session.IncomingSubgroupStream", ds) - } - - got, err := inStream.ReadObject() - if err != nil { - t.Fatalf("ReadObject: %v", err) - } - if string(got.Properties) != string(obj.Properties) { - t.Errorf("properties: got %x, want %x", got.Properties, obj.Properties) - } - if string(got.Payload) != string(obj.Payload) { - t.Errorf("payload: got %q, want %q", got.Payload, obj.Payload) - } - - if err := <-writeErr; err != nil { - t.Errorf("writer goroutine: %v", err) - } -} - -// TestFetchObjectRoundTrip opens a FETCH_HEADER uni-stream from the client, -// writes a FetchObject via WriteObject, closes the stream, then reads it back -// on the server via AcceptDataStream + ReadObject. -func TestFetchObjectRoundTrip(t *testing.T) { - cli, srv := openPair(t) - ctx := t.Context() - - hdr := message.FetchHeader{RequestID: 0} - obj := &message.FetchObject{ - SerializationFlags: message.FetchFlagGroupIDDelta | message.FetchFlagObjectIDDelta | - message.FetchFlagPriority, - GroupIDDelta: 3, - ObjectIDDelta: 1, - ObjectPayload: []byte("fetch-payload"), - } - - writeErr := make(chan error, 1) - go func() { - outStream, err := cli.OpenFetchStream(hdr) - if err != nil { - writeErr <- err - return - } - if err := outStream.WriteObject(obj); err != nil { - writeErr <- err - return - } - writeErr <- outStream.Close() - }() - - ds, err := srv.AcceptDataStream(ctx) - if err != nil { - t.Fatalf("AcceptDataStream: %v", err) - } - inStream, ok := ds.(*session.IncomingFetchStream) - if !ok { - t.Fatalf("AcceptDataStream returned %T, want *session.IncomingFetchStream", ds) - } - - if inStream.Header.RequestID != hdr.RequestID { - t.Errorf("RequestID: got %d, want %d", inStream.Header.RequestID, hdr.RequestID) - } - - got, err := inStream.ReadObject() - if err != nil { - t.Fatalf("ReadObject: %v", err) - } - if string(got.ObjectPayload) != string(obj.ObjectPayload) { - t.Errorf("payload: got %q, want %q", got.ObjectPayload, obj.ObjectPayload) - } - if got.GroupIDDelta != obj.GroupIDDelta { - t.Errorf("GroupIDDelta: got %d, want %d", got.GroupIDDelta, obj.GroupIDDelta) - } - - if err := <-writeErr; err != nil { - t.Errorf("writer goroutine: %v", err) - } -} - -// 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() - - // Drain the server side in a goroutine so the test doesn't deadlock. - go func() { - if ds, err := srv.AcceptDataStream(ctx); err == nil { - io.Copy(io.Discard, ds) - } - }() - - // Open a subgroup stream and write a zero-value SubgroupObject (nil payload). - // This exercises the WriteObject path without a type mismatch. - hdr := message.SubgroupHeader{TrackAlias: 1, GroupID: 0} - outStream, err := cli.OpenSubgroup(hdr) - if err != nil { - t.Fatalf("OpenSubgroup: %v", err) - } - defer outStream.Cancel(0) - - // Writing a valid *SubgroupObject must succeed (nil payload is valid wire). - if err := outStream.WriteObject(&message.SubgroupObject{}); err != nil { - t.Errorf("WriteObject(zero SubgroupObject): unexpected error: %v", err) - } -} - -// TestWriteObjectAt verifies that WriteObjectAt is the exact encoding inverse -// of ReadDecoded: absolute Object IDs in become the correct §11.4.2 deltas on -// the wire (first object's delta = absolute ID; later = currentID-prevID-1) and -// read back as the same absolute IDs. -func TestWriteObjectAt(t *testing.T) { - cli, srv := openPair(t) - ctx := t.Context() - - hdr := message.SubgroupHeader{ - TrackAlias: 42, - GroupID: 7, - SubgroupIDMode: message.SubgroupIDImplicitZero, - } - - // Absolute IDs 4, 5, 9 — should serialize as deltas 4, 0, 3. - writeIDs := []uint64{4, 5, 9} - wantDeltas := []uint64{4, 0, 3} - - writeErr := make(chan error, 1) - go func() { - out, err := cli.OpenSubgroup(hdr) - if err != nil { - writeErr <- err - return - } - for i, id := range writeIDs { - if err := out.WriteObjectAt(id, &message.SubgroupObject{ - Payload: []byte{byte('a' + i)}, - }); err != nil { - writeErr <- err - return - } - } - writeErr <- out.Close() - }() - - ds, err := srv.AcceptDataStream(ctx) - if err != nil { - t.Fatalf("AcceptDataStream: %v", err) - } - in, ok := ds.(*session.IncomingSubgroupStream) - if !ok { - t.Fatalf("got %T, want *IncomingSubgroupStream", ds) - } - - // Read the RAW objects to assert the exact wire deltas WriteObjectAt - // produced — the strongest check that the absolute→delta mapping is right. - for i := range writeIDs { - raw, err := in.ReadObject() - if err != nil { - t.Fatalf("ReadObject #%d: %v", i, err) - } - if raw.ObjectIDDelta != wantDeltas[i] { - t.Errorf("obj #%d: ObjectIDDelta got %d, want %d", i, raw.ObjectIDDelta, wantDeltas[i]) - } - if string(raw.Payload) != string(byte('a'+i)) { - t.Errorf("obj #%d: payload got %q, want %q", i, raw.Payload, string(byte('a'+i))) - } - } - if _, err := in.ReadObject(); !errors.Is(err, io.EOF) { - t.Errorf("trailing ReadObject: got %v, want io.EOF", err) - } - if err := <-writeErr; err != nil { - t.Errorf("writer: %v", err) - } -} - -// TestWriteObjectAtRejectsNonIncreasing verifies the strict-increasing guard: -// an Object ID not greater than the previous one is rejected with -// ErrObjectIDNotIncreasing, nothing is written, and the stream stays usable for -// a subsequent in-order write. -func TestWriteObjectAtRejectsNonIncreasing(t *testing.T) { - cli, srv := openPair(t) - ctx := t.Context() - - hdr := message.SubgroupHeader{TrackAlias: 1, GroupID: 0, SubgroupIDMode: message.SubgroupIDImplicitZero} - - writeErr := make(chan error, 1) - go func() { - out, err := cli.OpenSubgroup(hdr) - if err != nil { - writeErr <- err - return - } - if err := out.WriteObjectAt(5, &message.SubgroupObject{Payload: []byte("a")}); err != nil { - writeErr <- err - return - } - // Equal ID — must be rejected without writing. - if err := out.WriteObjectAt( - 5, - &message.SubgroupObject{Payload: []byte("x")}, - ); !errors.Is( - err, - session.ErrObjectIDNotIncreasing, - ) { - writeErr <- err - return - } - // Lower ID — must be rejected too. - if err := out.WriteObjectAt( - 3, - &message.SubgroupObject{Payload: []byte("y")}, - ); !errors.Is( - err, - session.ErrObjectIDNotIncreasing, - ) { - writeErr <- err - return - } - // The stream is still usable: an in-order write succeeds. - if err := out.WriteObjectAt(6, &message.SubgroupObject{Payload: []byte("b")}); err != nil { - writeErr <- err - return - } - writeErr <- out.Close() - }() - - ds, err := srv.AcceptDataStream(ctx) - if err != nil { - t.Fatalf("AcceptDataStream: %v", err) - } - in, ok := ds.(*session.IncomingSubgroupStream) - if !ok { - t.Fatalf("got %T, want *IncomingSubgroupStream", ds) - } - - // Only the two accepted objects (IDs 5 and 6) reach the wire; the rejected - // writes left no trace. - wantIDs := []uint64{5, 6} - wantPayloads := []string{"a", "b"} - for i := range wantIDs { - got, err := in.ReadDecoded() - if err != nil { - t.Fatalf("ReadDecoded #%d: %v", i, err) - } - if got.ObjectID != wantIDs[i] { - t.Errorf("obj #%d: ObjectID got %d, want %d", i, got.ObjectID, wantIDs[i]) - } - if string(got.Payload) != wantPayloads[i] { - t.Errorf("obj #%d: payload got %q, want %q", i, got.Payload, wantPayloads[i]) - } - } - if _, err := in.ReadDecoded(); !errors.Is(err, io.EOF) { - t.Errorf("trailing ReadDecoded: got %v, want io.EOF", err) - } - if err := <-writeErr; err != nil { - t.Errorf("writer: %v", err) - } -} - -// TestIncomingSubgroupStream_ReadDecoded covers absolute ObjectID -// reconstruction (first object's delta is the absolute ID; subsequent -// deltas encode currentID - prevID - 1) and the three §11.4.2 SubgroupID -// modes (ImplicitZero, ImplicitFirstObject, Explicit). -func TestIncomingSubgroupStream_ReadDecoded(t *testing.T) { - cases := []struct { - name string - mode message.SubgroupIDMode - explicitID uint64 // only used for Explicit mode - wantSubID uint64 - }{ - {"ImplicitZero", message.SubgroupIDImplicitZero, 0, 0}, - {"ImplicitFirstObject", message.SubgroupIDImplicitFirstObject, 0, 4 /* first abs ObjectID */}, - {"Explicit", message.SubgroupIDExplicit, 99, 99}, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - cli, srv := openPair(t) - ctx := t.Context() - - hdr := message.SubgroupHeader{ - TrackAlias: 42, - GroupID: 7, - SubgroupIDMode: tc.mode, - SubgroupID: tc.explicitID, - } - - // Three objects with absolute IDs 4, 5, 9 — first is the - // stream's "first object" (delta=4 carries absolute); - // second is consecutive (delta=0); third skips 6/7/8 - // (delta=3 → +4). - written := []*message.SubgroupObject{ - {ObjectIDDelta: 4, Payload: []byte("a")}, - {ObjectIDDelta: 0, Payload: []byte("b")}, - {ObjectIDDelta: 3, Payload: []byte("c")}, - } - - writeErr := make(chan error, 1) - go func() { - outStream, err := cli.OpenSubgroup(hdr) - if err != nil { - writeErr <- err - return - } - for _, o := range written { - if err := outStream.WriteObject(o); err != nil { - writeErr <- err - return - } - } - writeErr <- outStream.Close() - }() - - ds, err := srv.AcceptDataStream(ctx) - if err != nil { - t.Fatalf("AcceptDataStream: %v", err) - } - in, ok := ds.(*session.IncomingSubgroupStream) - if !ok { - t.Fatalf("got %T, want *IncomingSubgroupStream", ds) - } - - wantIDs := []uint64{4, 5, 9} - wantPayloads := []string{"a", "b", "c"} - for i := range wantIDs { - got, err := in.ReadDecoded() - if err != nil { - t.Fatalf("ReadDecoded #%d: %v", i, err) - } - if got.GroupID != 7 { - t.Errorf("obj #%d: GroupID got %d, want 7", i, got.GroupID) - } - if got.ObjectID != wantIDs[i] { - t.Errorf("obj #%d: ObjectID got %d, want %d", i, got.ObjectID, wantIDs[i]) - } - if got.SubgroupID != tc.wantSubID { - t.Errorf("obj #%d: SubgroupID got %d, want %d", i, got.SubgroupID, tc.wantSubID) - } - if string(got.Payload) != wantPayloads[i] { - t.Errorf("obj #%d: payload got %q, want %q", i, got.Payload, wantPayloads[i]) - } - } - - if _, err := in.ReadDecoded(); !errors.Is(err, io.EOF) { - t.Errorf("trailing ReadDecoded: got %v, want io.EOF", err) - } - if err := <-writeErr; err != nil { - t.Errorf("writer: %v", err) - } - }) - } -} - -// TestIncomingFetchStream_ReadDecoded_Ascending exercises the -// session-layer §11.4.4 delta decoder across all three transition kinds -// (first-object absolute, same-group, cross-group) and the four subgroup -// modes. The encoded objects mirror what the relay's -// streamFetchObjects writes for an ascending FETCH response. -func TestIncomingFetchStream_ReadDecoded_Ascending(t *testing.T) { - cli, srv := openPair(t) - ctx := t.Context() - - hdr := message.FetchHeader{RequestID: 0} - - // Write five objects. §11.4.4.1: 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 an absent Object ID Delta means "the prior Object's - // ID plus one, regardless of which group it belongs to". - // {G=5, O=2, Sub=10, Pri=7} first — flags carry absolute IDs - // {G=5, O=3, Sub=10, Pri=7} same group, delta absent → 2+1, inherit subgroup+priority - // {G=5, O=6, Sub=11, Pri=7} same group, ObjectIDDelta=3 → 3+3, Sub=Prior+1 - // {G=8, O=0, Sub=20, Pri=9} cross-group (GroupIDDelta=2 → +3), explicit subgroup, new pri - // {G=9, O=1, Sub=20, Pri=9} cross-group (GroupIDDelta=0 → +1), Object ID Delta absent → 0+1 - written := []*message.FetchObject{ - { - SerializationFlags: message.FetchFlagGroupIDDelta | - message.FetchFlagObjectIDDelta | - uint64(message.FetchSubgroupIDExplicit) | - message.FetchFlagPriority, - GroupIDDelta: 5, - ObjectIDDelta: 2, - SubgroupID: 10, - PublisherPriority: 7, - ObjectPayload: []byte("o1"), - }, - { - // Consecutive object in same group, inherit subgroup - // (Prior) and priority (no flag). - SerializationFlags: uint64(message.FetchSubgroupIDPrior), - ObjectPayload: []byte("o2"), - }, - { - // Same group, ObjectID gap, Sub=Prior+1. - SerializationFlags: message.FetchFlagObjectIDDelta | - uint64(message.FetchSubgroupIDPriorPlusOne), - ObjectIDDelta: 3, - ObjectPayload: []byte("o3"), - }, - { - // Cross-group; ascending → newG = prevG + delta + 1 = 5+2+1 = 8. - SerializationFlags: message.FetchFlagGroupIDDelta | - message.FetchFlagObjectIDDelta | - uint64(message.FetchSubgroupIDExplicit) | - message.FetchFlagPriority, - GroupIDDelta: 2, - ObjectIDDelta: 0, - SubgroupID: 20, - PublisherPriority: 9, - ObjectPayload: []byte("o4"), - }, - { - // Cross-group with the Object ID Delta omitted: prior ID + 1. - SerializationFlags: message.FetchFlagGroupIDDelta | - uint64(message.FetchSubgroupIDPrior), - GroupIDDelta: 0, - ObjectPayload: []byte("o5"), - }, - } - - writeErr := make(chan error, 1) - go func() { - outStream, err := cli.OpenFetchStream(hdr) - if err != nil { - writeErr <- err - return - } - for _, o := range written { - if err := outStream.WriteObject(o); err != nil { - writeErr <- err - return - } - } - writeErr <- outStream.Close() - }() - - ds, err := srv.AcceptDataStream(ctx) - if err != nil { - t.Fatalf("AcceptDataStream: %v", err) - } - in, ok := ds.(*session.IncomingFetchStream) - if !ok { - t.Fatalf("got %T, want *IncomingFetchStream", ds) - } - - want := []session.DecodedFetchObject{ - {GroupID: 5, ObjectID: 2, SubgroupID: 10, PublisherPriority: 7, Payload: []byte("o1")}, - {GroupID: 5, ObjectID: 3, SubgroupID: 10, PublisherPriority: 7, Payload: []byte("o2")}, - {GroupID: 5, ObjectID: 6, SubgroupID: 11, PublisherPriority: 7, Payload: []byte("o3")}, - {GroupID: 8, ObjectID: 0, SubgroupID: 20, PublisherPriority: 9, Payload: []byte("o4")}, - {GroupID: 9, ObjectID: 1, SubgroupID: 20, PublisherPriority: 9, Payload: []byte("o5")}, - } - - for i, w := range want { - got, err := in.ReadDecoded() - if err != nil { - t.Fatalf("ReadDecoded #%d: %v", i, err) - } - if got.GroupID != w.GroupID || got.ObjectID != w.ObjectID { - t.Errorf("obj #%d: location got {%d,%d}, want {%d,%d}", - i, got.GroupID, got.ObjectID, w.GroupID, w.ObjectID) - } - if got.SubgroupID != w.SubgroupID { - t.Errorf("obj #%d: SubgroupID got %d, want %d", i, got.SubgroupID, w.SubgroupID) - } - if got.PublisherPriority != w.PublisherPriority { - t.Errorf("obj #%d: PublisherPriority got %d, want %d", - i, got.PublisherPriority, w.PublisherPriority) - } - if string(got.Payload) != string(w.Payload) { - t.Errorf("obj #%d: payload got %q, want %q", i, got.Payload, w.Payload) - } - } - - if _, err := in.ReadDecoded(); !errors.Is(err, io.EOF) { - t.Errorf("trailing ReadDecoded: got %v, want io.EOF", err) - } - if err := <-writeErr; err != nil { - t.Errorf("writer: %v", err) - } -} - -// TestIncomingFetchStream_ReadDecoded_Descending verifies the descending -// branch of cross-group delta resolution: newGroup = prevGroup - delta - 1. -// The caller signals direction via IncomingFetchStream.GroupOrder. -func TestIncomingFetchStream_ReadDecoded_Descending(t *testing.T) { - cli, srv := openPair(t) - ctx := t.Context() - - hdr := message.FetchHeader{RequestID: 0} - written := []*message.FetchObject{ - { - // First object: abs (G=10, O=0) - SerializationFlags: message.FetchFlagGroupIDDelta | - message.FetchFlagObjectIDDelta | - message.FetchFlagPriority | - uint64(message.FetchSubgroupIDExplicit), - GroupIDDelta: 10, - ObjectIDDelta: 0, - SubgroupID: 0, - ObjectPayload: []byte("g10"), - }, - { - // Cross-group descending: prevG - delta - 1 = 10 - 1 - 1 = 8. - SerializationFlags: message.FetchFlagGroupIDDelta | - message.FetchFlagObjectIDDelta | - uint64(message.FetchSubgroupIDExplicit), - GroupIDDelta: 1, - ObjectIDDelta: 0, - SubgroupID: 0, - ObjectPayload: []byte("g8"), - }, - } - - writeErr := make(chan error, 1) - go func() { - outStream, err := cli.OpenFetchStream(hdr) - if err != nil { - writeErr <- err - return - } - for _, o := range written { - if err := outStream.WriteObject(o); err != nil { - writeErr <- err - return - } - } - writeErr <- outStream.Close() - }() - - ds, err := srv.AcceptDataStream(ctx) - if err != nil { - t.Fatalf("AcceptDataStream: %v", err) - } - in := ds.(*session.IncomingFetchStream) - in.GroupOrder = message.GroupOrderDescending - - o1, err := in.ReadDecoded() - if err != nil { - t.Fatalf("ReadDecoded #1: %v", err) - } - if o1.GroupID != 10 { - t.Errorf("first: GroupID got %d, want 10", o1.GroupID) - } - - o2, err := in.ReadDecoded() - if err != nil { - t.Fatalf("ReadDecoded #2: %v", err) - } - if o2.GroupID != 8 { - t.Errorf("second: GroupID got %d, want 8 (10 - 1 - 1)", o2.GroupID) - } - - if err := <-writeErr; err != nil { - t.Errorf("writer: %v", err) - } -} - -// 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() - - hdr := message.FetchHeader{RequestID: 0} - written := []*message.FetchObject{ - { - SerializationFlags: message.FetchFlagGroupIDDelta | - message.FetchFlagObjectIDDelta | - message.FetchFlagPriority | - uint64(message.FetchSubgroupIDExplicit), - GroupIDDelta: 5, - ObjectIDDelta: 0, - SubgroupID: 9, - PublisherPriority: 42, - ObjectPayload: []byte("real"), - }, - // End-of-non-existent-range marker carrying abs {7, 3}. - { - SerializationFlags: message.FetchEndOfNonExistentRange, - GroupIDDelta: 7, - ObjectIDDelta: 3, - }, - // A real object after the marker with no deltas: per §11.4.4.2 its - // prior Group/Object IDs are the MARKER's values, so it decodes as - // {7, 4}; its prior Subgroup (mode Prior) and Priority (flag - // absent) come from the last ACTUAL object. - { - SerializationFlags: uint64(message.FetchSubgroupIDPrior), - ObjectPayload: []byte("real2"), - }, - } - - writeErr := make(chan error, 1) - go func() { - outStream, err := cli.OpenFetchStream(hdr) - if err != nil { - writeErr <- err - return - } - for _, o := range written { - if err := outStream.WriteObject(o); err != nil { - writeErr <- err - return - } - } - writeErr <- outStream.Close() - }() - - ds, err := srv.AcceptDataStream(ctx) - if err != nil { - t.Fatalf("AcceptDataStream: %v", err) - } - in := ds.(*session.IncomingFetchStream) - - o1, _ := in.ReadDecoded() - if o1.GroupID != 5 || o1.ObjectID != 0 || string(o1.Payload) != "real" { - t.Errorf("first: got {%d,%d} payload=%q, want {5,0} \"real\"", - o1.GroupID, o1.ObjectID, o1.Payload) - } - - o2, _ := in.ReadDecoded() - if !o2.EndOfNonExistentRange { - t.Errorf("second: expected EndOfNonExistentRange") - } - if o2.GroupID != 7 || o2.ObjectID != 3 { - t.Errorf("second: marker carries {%d,%d}, want {7,3}", o2.GroupID, o2.ObjectID) - } - - o3, err := in.ReadDecoded() - if err != nil { - t.Fatalf("third ReadDecoded: %v", err) - } - if o3.GroupID != 7 || o3.ObjectID != 4 || string(o3.Payload) != "real2" { - t.Errorf("third: got {%d,%d} payload=%q, want {7,4} \"real2\" (marker IS the prior, §11.4.4.2)", - o3.GroupID, o3.ObjectID, o3.Payload) - } - if o3.SubgroupID != 9 || o3.PublisherPriority != 42 { - t.Errorf("third: subgroup/priority = %d/%d, want 9/42 (inherited from the last ACTUAL object)", - o3.SubgroupID, o3.PublisherPriority) - } - - if err := <-writeErr; err != nil { - t.Errorf("writer: %v", err) - } -} - -// 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 - first *message.FetchObject - }{ - { - // No delta flags at all: would reference the (non-existent) - // prior object's IDs. - name: "missing group and object id deltas", - first: &message.FetchObject{ - SerializationFlags: uint64(message.FetchSubgroupIDZero), - ObjectPayload: []byte("x"), - }, - }, - { - // Object ID Delta present but Group ID Delta missing. - name: "missing group id delta", - first: &message.FetchObject{ - SerializationFlags: message.FetchFlagObjectIDDelta | - uint64(message.FetchSubgroupIDZero), - ObjectIDDelta: 3, - ObjectPayload: []byte("x"), - }, - }, - { - // Group ID Delta present but Object ID Delta missing. - name: "missing object id delta", - first: &message.FetchObject{ - SerializationFlags: message.FetchFlagGroupIDDelta | - message.FetchFlagPriority | - uint64(message.FetchSubgroupIDZero), - GroupIDDelta: 5, - ObjectPayload: []byte("x"), - }, - }, - { - // Both deltas present, but no Priority: it would be the prior - // object's. - name: "references prior priority", - first: &message.FetchObject{ - SerializationFlags: message.FetchFlagGroupIDDelta | - message.FetchFlagObjectIDDelta | - uint64(message.FetchSubgroupIDZero), - GroupIDDelta: 5, - ObjectIDDelta: 2, - ObjectPayload: []byte("x"), - }, - }, - { - // Both deltas present, but the subgroup mode references the - // prior object's Subgroup ID. - name: "references prior subgroup", - first: &message.FetchObject{ - SerializationFlags: message.FetchFlagGroupIDDelta | - message.FetchFlagObjectIDDelta | - uint64(message.FetchSubgroupIDPrior), - GroupIDDelta: 5, - ObjectIDDelta: 2, - ObjectPayload: []byte("x"), - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cli, srv := openPair(t) - ctx := t.Context() - - hdr := message.FetchHeader{RequestID: 0} - writeErr := make(chan error, 1) - go func() { - outStream, err := cli.OpenFetchStream(hdr) - if err != nil { - writeErr <- err - return - } - if err := outStream.WriteObject(tt.first); err != nil { - writeErr <- err - return - } - writeErr <- outStream.Close() - }() - - ds, err := srv.AcceptDataStream(ctx) - if err != nil { - t.Fatalf("AcceptDataStream: %v", err) - } - in, ok := ds.(*session.IncomingFetchStream) - if !ok { - t.Fatalf("got %T, want *IncomingFetchStream", ds) - } - - if _, err := in.ReadDecoded(); err == nil { - t.Errorf("ReadDecoded: expected PROTOCOL_VIOLATION error, got nil") - } - requireClosedProtocolViolation(t, srv) - if err := <-writeErr; err != nil { - t.Errorf("writer: %v", err) - } - }) - } -} - -// TestIncomingFetchStream_ReadDecoded_MarkerFirst pins the §11.4.4.2 rules -// when an End-of-Range marker is the FIRST element on the stream: the marker -// supplies the prior Group/Object IDs, so the following object may omit the -// deltas — but with no prior ACTUAL object it must not reference the prior -// Subgroup ID or Priority. -func TestIncomingFetchStream_ReadDecoded_MarkerFirst(t *testing.T) { - write := func(t *testing.T, objs []*message.FetchObject) (*session.IncomingFetchStream, *session.Session) { - t.Helper() - cli, srv := openPair(t) - writeErr := make(chan error, 1) - go func() { - outStream, err := cli.OpenFetchStream(message.FetchHeader{RequestID: 0}) - if err != nil { - writeErr <- err - return - } - for _, o := range objs { - if err := outStream.WriteObject(o); err != nil { - writeErr <- err - return - } - } - writeErr <- outStream.Close() - }() - ds, err := srv.AcceptDataStream(t.Context()) - if err != nil { - t.Fatalf("AcceptDataStream: %v", err) - } - t.Cleanup(func() { - if err := <-writeErr; err != nil { - t.Errorf("writer: %v", err) - } - }) - return ds.(*session.IncomingFetchStream), srv - } - - marker := &message.FetchObject{ - SerializationFlags: message.FetchEndOfNonExistentRange, - GroupIDDelta: 3, // absolute Group ID - ObjectIDDelta: 6, // absolute Object ID - } - - t.Run("object after leading marker uses it as prior", func(t *testing.T) { - in, _ := write(t, []*message.FetchObject{marker, { - // No deltas: prior = the marker → {3, 7}. Subgroup and - // priority are spelled out (no prior actual object exists). - SerializationFlags: message.FetchFlagPriority | - uint64(message.FetchSubgroupIDExplicit), - SubgroupID: 2, - PublisherPriority: 5, - ObjectPayload: []byte("after"), - }}) - - if m, err := in.ReadDecoded(); err != nil || !m.EndOfNonExistentRange { - t.Fatalf("marker read: %v %+v", err, m) - } - obj, err := in.ReadDecoded() - if err != nil { - t.Fatalf("object after leading marker: %v", err) - } - if obj.GroupID != 3 || obj.ObjectID != 7 { - t.Errorf("decoded {%d,%d}, want {3,7} (marker as prior)", obj.GroupID, obj.ObjectID) - } - }) - - t.Run("prior-subgroup mode with no prior object is a violation", func(t *testing.T) { - in, srv := write(t, []*message.FetchObject{marker, { - SerializationFlags: message.FetchFlagPriority | - uint64(message.FetchSubgroupIDPrior), - PublisherPriority: 5, - ObjectPayload: []byte("bad"), - }}) - if _, err := in.ReadDecoded(); err != nil { - t.Fatalf("marker read: %v", err) - } - if _, err := in.ReadDecoded(); err == nil { - t.Error("expected prior-subgroup violation, got nil") - } - requireClosedProtocolViolation(t, srv) - }) - - t.Run("absent priority with no prior object is a violation", func(t *testing.T) { - in, srv := write(t, []*message.FetchObject{marker, { - SerializationFlags: uint64(message.FetchSubgroupIDZero), - ObjectPayload: []byte("bad"), - }}) - if _, err := in.ReadDecoded(); err != nil { - t.Fatalf("marker read: %v", err) - } - if _, err := in.ReadDecoded(); err == nil { - t.Error("expected prior-priority violation, got nil") - } - requireClosedProtocolViolation(t, srv) - }) -} diff --git a/pkg/moqt/session/datastream_subgroup_delta_test.go b/pkg/moqt/session/datastream_subgroup_delta_test.go new file mode 100644 index 00000000..8551711f --- /dev/null +++ b/pkg/moqt/session/datastream_subgroup_delta_test.go @@ -0,0 +1,157 @@ +package session_test + +import ( + "errors" + "fmt" + "io" + "testing" + + "github.com/floatdrop/moq-go/pkg/moqt/message" + "github.com/floatdrop/moq-go/pkg/moqt/session" +) + +// TestWriteObjectAt: WriteObjectAt is the exact encoding inverse of +// ReadDecoded — absolute Object IDs become the §11.4.2 deltas on the wire +// (first object's delta = absolute ID; later = currentID-prevID-1). +func TestWriteObjectAt(t *testing.T) { + cli, srv := openPair(t) + hdr := message.SubgroupHeader{TrackAlias: 42, GroupID: 7, SubgroupIDMode: message.SubgroupIDImplicitZero} + + writeIDs := []uint64{4, 5, 9} + wantDeltas := []uint64{4, 0, 3} + + in, writeErr := sendSubgroup(t, cli, srv, hdr, func(out *session.OutgoingSubgroupStream) error { + for i, id := range writeIDs { + if err := out.WriteObjectAt(id, &message.SubgroupObject{Payload: []byte{byte('a' + i)}}); err != nil { + return err + } + } + return nil + }) + + // Read the RAW objects: the exact wire deltas are the strongest check that + // the absolute→delta mapping is right. + for i := range writeIDs { + raw, err := in.ReadObject() + if err != nil { + t.Fatalf("ReadObject #%d: %v", i, err) + } + if raw.ObjectIDDelta != wantDeltas[i] { + t.Errorf("obj #%d: ObjectIDDelta got %d, want %d", i, raw.ObjectIDDelta, wantDeltas[i]) + } + if string(raw.Payload) != string(byte('a'+i)) { + t.Errorf("obj #%d: payload got %q, want %q", i, raw.Payload, string(byte('a'+i))) + } + } + if _, err := in.ReadObject(); !errors.Is(err, io.EOF) { + t.Errorf("trailing ReadObject: got %v, want io.EOF", err) + } + if err := <-writeErr; err != nil { + t.Errorf("writer: %v", err) + } +} + +// TestWriteObjectAtRejectsNonIncreasing: an Object ID not greater than the +// previous one is rejected with ErrObjectIDNotIncreasing, nothing is written, +// and the stream stays usable for a subsequent in-order write. +func TestWriteObjectAtRejectsNonIncreasing(t *testing.T) { + cli, srv := openPair(t) + hdr := message.SubgroupHeader{TrackAlias: 1, GroupID: 0, SubgroupIDMode: message.SubgroupIDImplicitZero} + + in, writeErr := sendSubgroup(t, cli, srv, hdr, func(out *session.OutgoingSubgroupStream) error { + if err := out.WriteObjectAt(5, &message.SubgroupObject{Payload: []byte("a")}); err != nil { + return err + } + for _, id := range []uint64{5, 3} { // equal, then lower + err := out.WriteObjectAt(id, &message.SubgroupObject{Payload: []byte("x")}) + if !errors.Is(err, session.ErrObjectIDNotIncreasing) { + return fmt.Errorf("WriteObjectAt(%d) = %w, want ErrObjectIDNotIncreasing", id, err) + } + } + return out.WriteObjectAt(6, &message.SubgroupObject{Payload: []byte("b")}) + }) + + // Only the two accepted objects reach the wire. + wantIDs := []uint64{5, 6} + wantPayloads := []string{"a", "b"} + for i := range wantIDs { + got, err := in.ReadDecoded() + if err != nil { + t.Fatalf("ReadDecoded #%d: %v", i, err) + } + if got.ObjectID != wantIDs[i] { + t.Errorf("obj #%d: ObjectID got %d, want %d", i, got.ObjectID, wantIDs[i]) + } + if string(got.Payload) != wantPayloads[i] { + t.Errorf("obj #%d: payload got %q, want %q", i, got.Payload, wantPayloads[i]) + } + } + if _, err := in.ReadDecoded(); !errors.Is(err, io.EOF) { + t.Errorf("trailing ReadDecoded: got %v, want io.EOF", err) + } + if err := <-writeErr; err != nil { + t.Errorf("writer: %v", err) + } +} + +// TestIncomingSubgroupStream_ReadDecoded covers absolute ObjectID +// reconstruction (first object's delta is the absolute ID; subsequent deltas +// encode currentID - prevID - 1) and the three §11.4.2 SubgroupID modes. +func TestIncomingSubgroupStream_ReadDecoded(t *testing.T) { + cases := []struct { + name string + mode message.SubgroupIDMode + explicitID uint64 // Explicit mode only + wantSubID uint64 + }{ + {"ImplicitZero", message.SubgroupIDImplicitZero, 0, 0}, + {"ImplicitFirstObject", message.SubgroupIDImplicitFirstObject, 0, 4 /* first abs ObjectID */}, + {"Explicit", message.SubgroupIDExplicit, 99, 99}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cli, srv := openPair(t) + hdr := message.SubgroupHeader{ + TrackAlias: 42, + GroupID: 7, + SubgroupIDMode: tc.mode, + SubgroupID: tc.explicitID, + } + // Absolute IDs 4, 5, 9: the first delta carries the absolute ID, + // the second is consecutive, the third skips 6/7/8. + in, writeErr := sendSubgroup(t, cli, srv, hdr, writeObjects( + &message.SubgroupObject{ObjectIDDelta: 4, Payload: []byte("a")}, + &message.SubgroupObject{ObjectIDDelta: 0, Payload: []byte("b")}, + &message.SubgroupObject{ObjectIDDelta: 3, Payload: []byte("c")}, + )) + + wantIDs := []uint64{4, 5, 9} + wantPayloads := []string{"a", "b", "c"} + for i := range wantIDs { + got, err := in.ReadDecoded() + if err != nil { + t.Fatalf("ReadDecoded #%d: %v", i, err) + } + if got.GroupID != 7 { + t.Errorf("obj #%d: GroupID got %d, want 7", i, got.GroupID) + } + if got.ObjectID != wantIDs[i] { + t.Errorf("obj #%d: ObjectID got %d, want %d", i, got.ObjectID, wantIDs[i]) + } + if got.SubgroupID != tc.wantSubID { + t.Errorf("obj #%d: SubgroupID got %d, want %d", i, got.SubgroupID, tc.wantSubID) + } + if string(got.Payload) != wantPayloads[i] { + t.Errorf("obj #%d: payload got %q, want %q", i, got.Payload, wantPayloads[i]) + } + } + if _, err := in.ReadDecoded(); !errors.Is(err, io.EOF) { + t.Errorf("trailing ReadDecoded: got %v, want io.EOF", err) + } + if err := <-writeErr; err != nil { + t.Errorf("writer: %v", err) + } + }) + } +} diff --git a/pkg/moqt/session/datastream_subgroup_test.go b/pkg/moqt/session/datastream_subgroup_test.go new file mode 100644 index 00000000..c3bbac7d --- /dev/null +++ b/pkg/moqt/session/datastream_subgroup_test.go @@ -0,0 +1,107 @@ +package session_test + +import ( + "errors" + "io" + "testing" + + "github.com/floatdrop/moq-go/pkg/moqt/message" +) + +// TestSubgroupObjectReadRejectsInvalidStatus: ReadObject validates each decoded +// object, so an empty payload with a status that is not +// Normal/EndOfGroup/EndOfTrack is a §11 protocol violation, not a valid object. +func TestSubgroupObjectReadRejectsInvalidStatus(t *testing.T) { + cli, srv := openPair(t) + hdr := message.SubgroupHeader{TrackAlias: 42, GroupID: 7, SubgroupIDMode: message.SubgroupIDImplicitZero} + // 0x2 is not a defined Object Status. + in, writeErr := sendSubgroup(t, cli, srv, hdr, writeObjects(&message.SubgroupObject{ObjectStatus: 0x2})) + + if _, err := in.ReadObject(); err == nil { + t.Fatal("ReadObject must reject an object with an invalid status") + } + // The rejection closes the session under the writer, so its result is not checked. + <-writeErr +} + +// TestSubgroupObjectRoundTrip: objects written with WriteObject read back +// unchanged, the header's Properties flag reaching both ends without the caller +// tracking it, and the closed stream then reads as io.EOF. +func TestSubgroupObjectRoundTrip(t *testing.T) { + tests := []struct { + name string + hdr message.SubgroupHeader + objs []*message.SubgroupObject + }{ + { + name: "without properties", + hdr: message.SubgroupHeader{TrackAlias: 42, GroupID: 7, SubgroupIDMode: message.SubgroupIDImplicitZero}, + objs: []*message.SubgroupObject{ + {ObjectIDDelta: 0, Payload: []byte("hello")}, + {ObjectIDDelta: 0, Payload: []byte("world")}, + }, + }, + { + name: "with properties", + hdr: message.SubgroupHeader{TrackAlias: 1, GroupID: 0, Properties: true}, + // One property: Type 2 (a varint value), value 7. + objs: []*message.SubgroupObject{{Properties: []byte{0x02, 0x07}, Payload: []byte("data")}}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cli, srv := openPair(t) + in, writeErr := sendSubgroup(t, cli, srv, tc.hdr, writeObjects(tc.objs...)) + + if in.Header.TrackAlias != tc.hdr.TrackAlias || in.Header.GroupID != tc.hdr.GroupID { + t.Errorf("header mismatch: got %+v, want %+v", in.Header, tc.hdr) + } + for i, want := range tc.objs { + got, err := in.ReadObject() + if err != nil { + t.Fatalf("ReadObject(%d): %v", i, err) + } + if got.ObjectIDDelta != want.ObjectIDDelta { + t.Errorf("object %d delta: got %d, want %d", i, got.ObjectIDDelta, want.ObjectIDDelta) + } + if string(got.Properties) != string(want.Properties) { + t.Errorf("object %d properties: got %x, want %x", i, got.Properties, want.Properties) + } + if string(got.Payload) != string(want.Payload) { + t.Errorf("object %d payload: got %q, want %q", i, got.Payload, want.Payload) + } + } + if _, err := in.ReadObject(); !errors.Is(err, io.EOF) { + t.Errorf("ReadObject after close: got %v, want io.EOF", err) + } + if err := <-writeErr; err != nil { + t.Errorf("writer: %v", err) + } + }) + } +} + +// 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() + + // Drain the server side so the synchronous pipe doesn't deadlock the write. + go func() { + if ds, err := srv.AcceptDataStream(ctx); err == nil { + io.Copy(io.Discard, ds) + } + }() + + outStream, err := cli.OpenSubgroup(message.SubgroupHeader{TrackAlias: 1, GroupID: 0}) + if err != nil { + t.Fatalf("OpenSubgroup: %v", err) + } + defer outStream.Cancel(0) + + // A nil payload is valid on the wire. + if err := outStream.WriteObject(&message.SubgroupObject{}); err != nil { + t.Errorf("WriteObject(zero SubgroupObject): unexpected error: %v", err) + } +} diff --git a/pkg/moqt/session/helpers_test.go b/pkg/moqt/session/helpers_test.go index e991b23c..2aed7347 100644 --- a/pkg/moqt/session/helpers_test.go +++ b/pkg/moqt/session/helpers_test.go @@ -292,3 +292,48 @@ func drainOneSubgroup(t *testing.T, client *session.Session) { } } } + +// sendSubgroup opens a subgroup stream on from, runs write on it and closes it, and returns to's end with the +// writer's result. The writer runs in a goroutine because the test pipe is synchronous: Write blocks until read. +func sendSubgroup( + t *testing.T, + from, to *session.Session, + hdr message.SubgroupHeader, + write func(*session.OutgoingSubgroupStream) error, +) (*session.IncomingSubgroupStream, <-chan error) { + t.Helper() + writeErr := make(chan error, 1) + go func() { + out, err := from.OpenSubgroup(hdr) + if err != nil { + writeErr <- err + return + } + if err := write(out); err != nil { + writeErr <- err + return + } + writeErr <- out.Close() + }() + ds, err := to.AcceptDataStream(t.Context()) + if err != nil { + t.Fatalf("AcceptDataStream: %v", err) + } + in, ok := ds.(*session.IncomingSubgroupStream) + if !ok { + t.Fatalf("AcceptDataStream returned %T, want *session.IncomingSubgroupStream", ds) + } + return in, writeErr +} + +// writeObjects is a sendSubgroup write func that writes objs in order. +func writeObjects(objs ...*message.SubgroupObject) func(*session.OutgoingSubgroupStream) error { + return func(out *session.OutgoingSubgroupStream) error { + for _, o := range objs { + if err := out.WriteObject(o); err != nil { + return err + } + } + return nil + } +} From dd9a2b7fc99049dcb934d108c90dc88ce8e390f1 Mon Sep 17 00:00:00 2001 From: Vsevolod Strukchinsky Date: Sat, 26 Sep 2026 15:56:55 +0500 Subject: [PATCH 3/5] test(relay): split cross_relay, session_pubsub and handler_fanout tests by topic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split by topic, each file under 600 lines: - cross_relay_test.go: routing and dialing. New files cross_relay_namespace_test.go (Discovery namespace watch) and cross_relay_state_test.go (GOAWAY, LARGEST_OBJECT backfill, PUBLISH_DONE). - session_pubsub_test.go is replaced by session_publish_test.go (inbound and forwarded PUBLISH), session_subscribe_test.go and session_upstream_test.go (on-demand upstream SUBSCRIBE). - handler_fanout_test.go: forwarding and filters. New file handler_fanout_lifecycle_test.go (stalled or unresponsive subscribers, inbound reset). The two TestSubscribe_* tests moved to session_subscribe_test.go. Helpers now shared: - harness_test.go: testRelay, startTestRelay, dialClient, plus new dialerTo, startRelayPair and publishOnRelay. - helpers_test.go: opaqueProps, objEvent/readSubgroups, readUntilEnd, writeSubgroupObjects, publishNS/subscribeNS and the requireNamespace* / requireQuiet assertions, all moved from their test files. Also new: readNextSubgroup/awaitSubgroupRead and readvertise, which replaces three hand-rolled re-advertise tickers. Near-identical tests now share one body: - AbsoluteStart/AbsoluteRange filters: requireFilterForwards - Upstream FORWARD paused/omitted: upstreamForwardFor - the two PublisherDisappears tests - the two forwarded-PUBLISH parameter tests: forwardedCam7 Fixed sleeps became waitRelayLargest waits. Comments were trimmed to contract, citation and reason. Every § citation is kept. TestTrackStatus_ReplyForKnownTrack published TrackProperties []byte("rtp-h265"). That is not a valid Key-Value-Pair encoding and only passed because the bytes happen to parse. It now publishes real Track Properties (one unknown, non-mandatory property of type 0x101, via opaqueProps) and asserts TRACK_STATUS_OK echoes them byte for byte. Tests only, no production code changed. The test and subtest set is unchanged: 332 entries from `go test -list` (327 Test + 5 Benchmark) and 457 PASS lines (327 top-level + 130 subtests), before and after. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/relay/cross_relay_namespace_test.go | 167 +++ pkg/relay/cross_relay_state_test.go | 186 +++ pkg/relay/cross_relay_test.go | 1223 ++---------------- pkg/relay/forward_state_test.go | 24 - pkg/relay/handler_fanout_firstobject_test.go | 26 - pkg/relay/handler_fanout_lifecycle_test.go | 167 +++ pkg/relay/handler_fanout_multipub_test.go | 49 - pkg/relay/handler_fanout_test.go | 911 +------------ pkg/relay/handler_fetch_session_test.go | 13 +- pkg/relay/harness_test.go | 96 ++ pkg/relay/helpers_test.go | 234 ++++ pkg/relay/namespace_state_test.go | 74 +- pkg/relay/session_publish_test.go | 232 ++++ pkg/relay/session_pubsub_test.go | 1076 --------------- pkg/relay/session_subscribe_test.go | 146 +++ pkg/relay/session_upstream_test.go | 316 +++++ 16 files changed, 1726 insertions(+), 3214 deletions(-) create mode 100644 pkg/relay/cross_relay_namespace_test.go create mode 100644 pkg/relay/cross_relay_state_test.go create mode 100644 pkg/relay/handler_fanout_lifecycle_test.go create mode 100644 pkg/relay/session_publish_test.go delete mode 100644 pkg/relay/session_pubsub_test.go create mode 100644 pkg/relay/session_subscribe_test.go create mode 100644 pkg/relay/session_upstream_test.go diff --git a/pkg/relay/cross_relay_namespace_test.go b/pkg/relay/cross_relay_namespace_test.go new file mode 100644 index 00000000..43460378 --- /dev/null +++ b/pkg/relay/cross_relay_namespace_test.go @@ -0,0 +1,167 @@ +package relay_test + +import ( + "fmt" + "sync" + "testing" + + "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" +) + +// Namespaces a remote relay advertises in Discovery, reflected to local +// SUBSCRIBE_NAMESPACE holders by the relay's WatchNamespaces consumer. + +// remoteCam1 is video/cam1 as relay-C advertises it in Discovery. +var remoteCam1 = discovery.NamespaceInfo{Prefix: ns("video", "cam1"), RelayAddr: "relay-C"} + +// TestCrossRelay_WatchNamespacesForward: a namespace advertised by a remote +// relay reaches a local SUBSCRIBE_NAMESPACE holder as a NAMESPACE carrying the +// suffix below its prefix. +func TestCrossRelay_WatchNamespacesForward(t *testing.T) { + t.Parallel() + store := discovery.NewMemoryStore() + defer store.Close() + relayA := startTestRelay(t.Context(), relay.Config{Discovery: store, RelayAddr: "relay-A"}) + + subSess := dialClient(t, relayA) + _, msgs := subscribeNS(t, subSess, "video") + readvertise(t, store, remoteCam1) + requireNamespace(t, msgs, "cam1") + + _ = subSess.Close(0, "done") + relayA.stop(t) +} + +// 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() + store := discovery.NewMemoryStore() + defer store.Close() + relayA := startTestRelay(t.Context(), relay.Config{Discovery: store, RelayAddr: "relay-A"}) + + subSess := dialClient(t, relayA) + _, msgs := subscribeNS(t, subSess, "video") + // The NAMESPACE proves the watch is live, which is what makes the single + // retraction below observable. + stop := readvertise(t, store, remoteCam1) + requireNamespace(t, msgs, "cam1") + stop() + + // UnpublishNamespace on a missing entry is a silent no-op, so this relies + // on the advertisement still being in the store. + if err := store.UnpublishNamespace(t.Context(), remoteCam1.Prefix, remoteCam1.RelayAddr); err != nil { + t.Fatalf("UnpublishNamespace: %v", err) + } + requireNamespaceDone(t, msgs, "cam1") + + _ = subSess.Close(0, "done") + relayA.stop(t) +} + +// 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() + store := discovery.NewMemoryStore() + defer store.Close() + relayA := startTestRelay(t.Context(), relay.Config{Discovery: store, RelayAddr: "relay-A"}) + + // Both holders register on the same prefix before any event is injected, + // so each event is offered to both and only the skip separates them. + nsSess := dialClient(t, relayA) + _, msgs := subscribeNS(t, nsSess, "video") + trSess := dialClient(t, relayA) + trMsgs := streamMessages(t, subscribeTracks(t, trSess, ns("video"))) + + readvertise(t, store, remoteCam1) + requireNamespace(t, msgs, "cam1") + // The event was delivered, so anything on the SUBSCRIBE_TRACKS stream now + // is the skip having been dropped. The stream ending fails too: a dead + // stream was not correctly skipped. + requireQuiet(t, trMsgs, "remote namespace on the SUBSCRIBE_TRACKS stream") + + _ = nsSess.Close(0, "done") + _ = trSess.Close(0, "done") + relayA.stop(t) +} + +// 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() + store := discovery.NewMemoryStore() + defer store.Close() + if err := store.PublishNamespace(t.Context(), remoteCam1); err != nil { + t.Fatalf("seed PublishNamespace: %v", err) + } + relayA := startTestRelay(t.Context(), relay.Config{Discovery: store, RelayAddr: "relay-A"}) + + subSess := dialClient(t, relayA) + _, msgs := subscribeNS(t, subSess, "video") + requireNamespace(t, msgs, "cam1") + + _ = subSess.Close(0, "done") + relayA.stop(t) +} + +// 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() + store := discovery.NewMemoryStore() + defer store.Close() + ctx := t.Context() + relayA := startTestRelay(ctx, relay.Config{Discovery: store, RelayAddr: "relay-A"}) + + // The subscriber drains its stream so the relay's writes never block. + subSess := dialClient(t, relayA) + _, msgs := subscribeNS(t, subSess, "room") + drained := make(chan struct{}) + go func() { + defer close(drained) + for { + if _, ok := <-msgs; !ok { + return + } + } + }() + + const rounds = 50 + var wg sync.WaitGroup + // Writer 1: a local publisher's PUBLISH_NAMESPACEs, forwarded (and their + // NAMESPACE_DONEs) from the relay's publisher-handler goroutine. + pubSess := dialClient(t, relayA) + wg.Go(func() { + for i := range rounds { + pns, err := pubSess.PublishNamespace(ctx, &message.PublishNamespace{ + Namespace: wire.TrackNamespace{[]byte("room"), fmt.Appendf(nil, "local%d", i)}, + }) + if err != nil { + return + } + _ = pns.Close() + } + }) + // Writer 2: remote advertisements, forwarded from the relay-level watch + // goroutine. + wg.Go(func() { + for i := range rounds { + _ = store.PublishNamespace(ctx, discovery.NamespaceInfo{ + Prefix: wire.TrackNamespace{[]byte("room"), fmt.Appendf(nil, "remote%d", i)}, + RelayAddr: "relay-C", + }) + } + }) + wg.Wait() + + _ = subSess.Close(0, "done") + _ = pubSess.Close(0, "done") + relayA.stop(t) + <-drained +} diff --git a/pkg/relay/cross_relay_state_test.go b/pkg/relay/cross_relay_state_test.go new file mode 100644 index 00000000..dff98927 --- /dev/null +++ b/pkg/relay/cross_relay_state_test.go @@ -0,0 +1,186 @@ +package relay_test + +import ( + "context" + "fmt" + "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/relay" + "github.com/floatdrop/moq-go/pkg/relay/discovery" +) + +// Track state that has to survive the hop between relays: the Largest Object +// watermark, PUBLISH_DONE codes, and the GOAWAY on shutdown. + +// 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() + store := discovery.NewMemoryStore() + defer store.Close() + ctx := t.Context() + + // Stand in for a peer relay: advertise a namespace at peerAddr so the relay + // resolves it as an upstream, and serve the far end of the dialled pipe. + const peerAddr = "peer:4433" + if err := store.PublishNamespace( + ctx, + discovery.NamespaceInfo{Prefix: ns("video"), RelayAddr: peerAddr}, + ); err != nil { + t.Fatalf("PublishNamespace: %v", err) + } + peerSessions := make(chan *session.Session, 1) + r := startTestRelay(ctx, relay.Config{ + GoawayTimeout: 2 * time.Second, // long enough that the drain is observable + Discovery: store, + RelayAddr: "relay-under-test:4433", + Dialer: func(_ context.Context, addr string) (session.Conn, error) { + if addr != peerAddr { + return nil, fmt.Errorf("no relay at %q", addr) + } + relaySide, peerSide := sessiontest.NewConnPair() + go func() { + // The relay dials as a client, so this end is the server. + sess, err := session.Server(context.Background(), peerSide) + if err != nil { + close(peerSessions) + return + } + peerSessions <- sess + }() + return relaySide, nil + }, + }) + + // A downstream SUBSCRIBE with no local publisher drives the upstream dial. + // It is not answered until the upstream is, and this peer never replies, + // so it runs in the background: the dial is all that is needed. + subSess := dialClient(t, r) + go func() { + _, _ = subSess.Subscribe(ctx, &message.Subscribe{Namespace: ns("video"), Name: []byte("cam1")}) + }() + + var peer *session.Session + select { + case peer = <-peerSessions: + if peer == nil { + t.Fatal("upstream peer SETUP failed") + } + case <-time.After(5 * time.Second): + t.Fatal("relay never dialled the advertised upstream") + } + + stopDone := make(chan struct{}) + go func() { + defer close(stopDone) + stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = r.r.Stop(stopCtx) + }() + + select { + case <-peer.GoawayReceived(): + case <-peer.Done(): + t.Fatal("upstream session was torn down without ever receiving a GOAWAY;" + + " §3.6 requires the GOAWAY first") + case <-time.After(5 * time.Second): + t.Fatal("upstream peer received no GOAWAY") + } + + <-stopDone + r.requireStartReturned(t) +} + +// 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() + store := discovery.NewMemoryStore() + defer store.Close() + ctx := t.Context() + relayA, relayB := startRelayPair(ctx, store) + + // The whole track is published before the subscriber joins, so live + // delivery cannot cover any of it. + pubSess, _ := publishOnRelay(t, relayB, "catalog", 7) + const sgCount = 3 + publishObjects(t, pubSess, 7, 0, sgCount) + // B must know the watermark before A subscribes: B omitting LARGEST_OBJECT + // because it knows nothing yet is not the bug under test. + waitRelayLargest(t, pubSess, ns("video"), []byte("catalog"), 0, sgCount-1) + + // A has no local publisher and follows Discovery to B. + subSess := dialClient(t, relayA) + subReq, err := subSess.Subscribe(ctx, &message.Subscribe{Namespace: ns("video"), Name: []byte("catalog")}) + if err != nil { + t.Fatalf("cross-relay Subscribe: %v", err) + } + + // §10.2.17: "If Objects have been published on this Track the Publisher MUST + // include this parameter." A is the publisher for this subscriber, and B has + // told it Objects exist. + if _, ok := subReq.OK.Parameters.Find(message.ParamLargestObject); !ok { + t.Fatalf("A's SUBSCRIBE_OK omitted LARGEST_OBJECT; it learned no Joining "+ + "Location from B's SUBSCRIBE_OK (params=%v)", subReq.OK.Parameters) + } + + // A FETCH is the only way this subscriber can reach content published + // before it arrived. A's own cache is empty, as its upstream uses the Next + // Object filter, so answering means stitching from B (§9.4). StartGroup=1 + // is the relative one-field form (§5.1.2): the current group up to the + // Largest Object. + fetchReq, err := subSess.Fetch(ctx, &message.Fetch{ + Namespace: ns("video"), + Name: []byte("catalog"), + Parameters: message.Parameters{ + message.GroupOrderParam(message.GroupOrderAscending), + message.RelativeStartFilter(1), + }, + }) + if err != nil { + t.Fatalf("FETCH rejected, so the backfill is unreachable: %v", err) + } + defer fetchReq.Close() + if n := len(readFetchResponse(t, subSess, message.GroupOrderAscending, 3*time.Second)); n != sgCount { + t.Errorf("joining FETCH returned %d objects, want %d — the backfill "+ + "did not cover the group published before the subscriber joined", n, sgCount) + } + + _ = subReq.Close() + _ = subSess.Close(0, "done") + _ = pubSess.Close(0, "done") + relayA.stop(t) + relayB.stop(t) +} + +// 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() + defer store.Close() + relayA, relayB := startRelayPair(t.Context(), store) + defer relayB.stop(t) + defer relayA.stop(t) + + pubSess, pub := publishOnRelay(t, relayB, "cam1", 7) + defer func() { _ = pubSess.Close(0, "done") }() + subSess := dialClient(t, relayA) + defer func() { _ = subSess.Close(0, "done") }() + subReq := subscribeCam1(t, subSess) + + if err := pub.Done(moqt.PublishDoneMalformedTrack, "bad track"); err != nil { + t.Fatalf("Done: %v", err) + } + if pd := awaitPublishDone(t, subReq); pd.StatusCode != moqt.PublishDoneMalformedTrack { + t.Fatalf("PUBLISH_DONE across two relays %#x, want MALFORMED_TRACK %#x", + pd.StatusCode, moqt.PublishDoneMalformedTrack) + } +} diff --git a/pkg/relay/cross_relay_test.go b/pkg/relay/cross_relay_test.go index bfb3e13b..bf0eaa53 100644 --- a/pkg/relay/cross_relay_test.go +++ b/pkg/relay/cross_relay_test.go @@ -6,6 +6,8 @@ import ( "errors" "fmt" "log/slog" + "maps" + "slices" "strings" "sync" "sync/atomic" @@ -15,181 +17,60 @@ 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" "github.com/floatdrop/moq-go/pkg/relay" "github.com/floatdrop/moq-go/pkg/relay/discovery" - "github.com/floatdrop/moq-go/pkg/relay/internal/relaytest" ) -// testRelay is a relay started on its own in-process pipeListener, used by the -// cross-relay tests that need direct control over two relay instances and a -// Dialer wiring one to the other. -type testRelay struct { - r *relay.Relay - l *pipeListener - startErr chan error -} +// Cross-relay routing: which remote relays a SUBSCRIBE with no local upstream +// is sent to, found through Discovery and reached through the Dialer. -// 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 - } - l := newPipeListener() - r := relay.New(l, cfg) - se := make(chan error, 1) - go func() { se <- r.Start(ctx) }() - return &testRelay{r: r, l: l, startErr: se} +// dialLog counts a Dialer's dials per address. +type dialLog struct { + mu sync.Mutex + n map[string]int } -func (tr *testRelay) stop(t *testing.T) { - t.Helper() - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - _ = tr.r.Stop(ctx) - select { - case err := <-tr.startErr: - if err != nil { - t.Errorf("Start returned: %v", err) - } - case <-time.After(2 * time.Second): - t.Error("Start did not return after Stop") +func (d *dialLog) record(addr string) { + d.mu.Lock() + defer d.mu.Unlock() + if d.n == nil { + d.n = map[string]int{} } + d.n[addr]++ } -// dialClient connects a fresh client session into tr's listener. -func dialClient(t *testing.T, tr *testRelay) *session.Session { - t.Helper() - conn, err := tr.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) - } - return sess +func (d *dialLog) counts() map[string]int { + d.mu.Lock() + defer d.mu.Unlock() + return maps.Clone(d.n) } // 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() - store := discovery.NewMemoryStore() defer store.Close() + relayA, relayB := startRelayPair(t.Context(), store) - ctx := t.Context() - - relayB := startTestRelay(ctx, relay.Config{Discovery: store, RelayAddr: "relay-B"}) - relayA := startTestRelay(ctx, relay.Config{ - Discovery: store, - RelayAddr: "relay-A", - Dialer: func(_ context.Context, addr string) (session.Conn, error) { - if addr == "relay-B" { - return relayB.l.Dial() - } - return nil, fmt.Errorf("no relay at %q", addr) - }, - }) - - // 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: ns("video")}) - if err != nil { - t.Fatalf("PublishNamespace: %v", err) - } - const pubAlias = uint64(7) - pubReq, err := pubSess.Publish(ctx, &message.Publish{ - Namespace: ns("video"), - Name: []byte("cam1"), - TrackAlias: pubAlias, - }) - if err != nil { - t.Fatalf("Publish: %v", err) - } - - // Subscriber connects to A and subscribes. Subscribe returns only after A - // has established its upstream to B (which established B's upstream to the - // publisher), so the full chain is live by the time we push objects. + pubSess, _ := publishOnRelay(t, relayB, "cam1", 7) + // Subscribe returns only once A's upstream to B, and B's to the publisher, + // are established, so the whole chain is live before anything is written. subSess := dialClient(t, relayA) - subReq, err := subSess.Subscribe(ctx, &message.Subscribe{ - Namespace: ns("video"), - Name: []byte("cam1"), - }) - if err != nil { - t.Fatalf("cross-relay Subscribe: %v", err) - } - - type subgroupResult struct { - header message.SubgroupHeader - objects []*message.SubgroupObject - } - subgroupCh := make(chan subgroupResult, 1) - go func() { - ds, err := subSess.AcceptDataStream(ctx) - if err != nil { - return - } - sg, ok := ds.(*session.IncomingSubgroupStream) - if !ok { - return - } - var objs []*message.SubgroupObject - for { - obj, err := sg.ReadObject() - if err != nil { - subgroupCh <- subgroupResult{header: sg.Header, objects: objs} - return - } - objs = append(objs, obj) - } - }() + subReq := subscribeCam1(t, subSess) - pubSg, err := pubSess.OpenSubgroup(message.SubgroupHeader{ - SubgroupIDMode: message.SubgroupIDExplicit, - TrackAlias: pubAlias, - GroupID: 0, - SubgroupID: 0, - }) - if err != nil { - t.Fatalf("OpenSubgroup: %v", err) - } + reads := readNextSubgroup(t, subSess) const sgCount = 5 - for i := range sgCount { - if err := pubSg.WriteObject(&message.SubgroupObject{ - ObjectIDDelta: 0, - Payload: []byte{byte('A' + i)}, - }); err != nil { - t.Fatalf("WriteObject #%d: %v", i, err) - } + publishObjects(t, pubSess, 7, 0, sgCount) + r := awaitSubgroupRead(t, reads) + if len(r.ids) != sgCount { + t.Fatalf("subscriber received %d objects, want %d", len(r.ids), sgCount) } - if err := pubSg.Close(); err != nil { - t.Fatalf("pubSg.Close: %v", err) + if r.header.TrackAlias != subReq.OK.TrackAlias { + t.Errorf("subgroup TrackAlias = %d, want %d (subscriber's outbound alias)", + r.header.TrackAlias, subReq.OK.TrackAlias) } - select { - case res := <-subgroupCh: - if len(res.objects) != sgCount { - t.Fatalf("subscriber received %d objects, want %d", len(res.objects), sgCount) - } - if res.header.TrackAlias != subReq.OK.TrackAlias { - t.Errorf("subgroup TrackAlias = %d, want %d (subscriber's outbound alias)", - res.header.TrackAlias, subReq.OK.TrackAlias) - } - case <-time.After(3 * time.Second): - t.Fatal("objects did not cross the relay boundary within deadline") - } - - // Teardown: close clients, then stop A (tears down its upstream to B), - // then B. - _ = subReq.Close() - _ = pubSg.Close() - _ = pubReq.Close() - _ = pns.Close() _ = subSess.Close(0, "done") _ = pubSess.Close(0, "done") relayA.stop(t) @@ -201,51 +82,21 @@ func TestCrossRelay_OnDemandSubscribe(t *testing.T) { // relay found through Discovery. func TestCrossRelay_LocalPublisherFailureFallsBackToDiscovery(t *testing.T) { t.Parallel() - store := discovery.NewMemoryStore() defer store.Close() + relayA, relayB := startRelayPair(t.Context(), store) - ctx := t.Context() - - relayB := startTestRelay(ctx, relay.Config{Discovery: store, RelayAddr: "relay-B"}) - relayA := startTestRelay(ctx, relay.Config{ - Discovery: store, - RelayAddr: "relay-A", - Dialer: func(_ context.Context, addr string) (session.Conn, error) { - if addr == "relay-B" { - return relayB.l.Dial() - } - return nil, fmt.Errorf("no relay at %q", addr) - }, - }) - - // Healthy publisher on B serves video/cam1. - pubB := dialClient(t, relayB) - 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: ns("video"), Name: []byte("cam1"), TrackAlias: pubAlias}, - ) - if err != nil { - t.Fatalf("B Publish: %v", err) - } + pubB, _ := publishOnRelay(t, relayB, "cam1", 9) - // 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. + // A local publisher of the same namespace on A refuses every upstream + // SUBSCRIBE, so A must try it, fail, and fall back to B. pLocal := dialClient(t, relayA) - pnsLocal, err := pLocal.PublishNamespace(ctx, &message.PublishNamespace{Namespace: ns("video")}) - if err != nil { - t.Fatalf("local PublishNamespace: %v", err) - } + publishNS(t, pLocal, "video") rejectDone := make(chan struct{}) go func() { defer close(rejectDone) for { - req, err := pLocal.AcceptRequest(ctx) + req, err := pLocal.AcceptRequest(t.Context()) if err != nil { return } @@ -253,63 +104,16 @@ 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: ns("video"), Name: []byte("cam1")}) - if err != nil { - t.Fatalf("Subscribe should have fallen back to Discovery, got: %v", err) - } - - objects := make(chan int, 1) - go func() { - ds, err := subSess.AcceptDataStream(ctx) - if err != nil { - return - } - sg, ok := ds.(*session.IncomingSubgroupStream) - if !ok { - return - } - n := 0 - for { - if _, err := sg.ReadObject(); err != nil { - objects <- n - return - } - n++ - } - }() + subscribeCam1(t, subSess) - sgB, err := pubB.OpenSubgroup(message.SubgroupHeader{ - SubgroupIDMode: message.SubgroupIDExplicit, - TrackAlias: pubAlias, - GroupID: 0, - SubgroupID: 0, - }) - if err != nil { - t.Fatalf("OpenSubgroup: %v", err) - } + reads := readNextSubgroup(t, subSess) const sgCount = 3 - for i := range sgCount { - if err := sgB.WriteObject(&message.SubgroupObject{Payload: []byte{byte('A' + i)}}); err != nil { - t.Fatalf("WriteObject #%d: %v", i, err) - } + publishObjects(t, pubB, 9, 0, sgCount) + if r := awaitSubgroupRead(t, reads); len(r.ids) != sgCount { + t.Fatalf("received %d objects via Discovery fallback, want %d", len(r.ids), sgCount) } - _ = sgB.Close() - select { - case n := <-objects: - if n != sgCount { - t.Fatalf("received %d objects via Discovery fallback, want %d", n, sgCount) - } - case <-time.After(3 * time.Second): - t.Fatal("no objects after the local publisher failed and Discovery fallback should have served") - } - - _ = subReq.Close() - _ = pnsLocal.Close() - _ = pnsB.Close() - _ = pubReqB.Close() _ = subSess.Close(0, "done") _ = pLocal.Close(0, "done") _ = pubB.Close(0, "done") @@ -323,91 +127,37 @@ func TestCrossRelay_LocalPublisherFailureFallsBackToDiscovery(t *testing.T) { // §2.1). func TestCrossRelay_MultiRemoteFanIn(t *testing.T) { t.Parallel() - store := discovery.NewMemoryStore() defer store.Close() - ctx := t.Context() relayB := startTestRelay(ctx, relay.Config{Discovery: store, RelayAddr: "relay-B"}) relayC := startTestRelay(ctx, relay.Config{Discovery: store, RelayAddr: "relay-C"}) - - var dialsB, dialsC atomic.Int64 + var dials dialLog relayA := startTestRelay(ctx, relay.Config{ - Discovery: store, - RelayAddr: "relay-A", - Dialer: func(_ context.Context, addr string) (session.Conn, error) { - switch addr { - case "relay-B": - dialsB.Add(1) - return relayB.l.Dial() - case "relay-C": - dialsC.Add(1) - return relayC.l.Dial() - default: - return nil, fmt.Errorf("no relay at %q", addr) - } - }, + Discovery: store, RelayAddr: "relay-A", Dialer: dialerTo(dials.record, relayB, relayC), }) - // 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: ns("video")}); err != nil { - t.Fatalf("PublishNamespace: %v", err) - } - p, err := ps.Publish(ctx, &message.Publish{Namespace: ns("video"), Name: []byte("cam1"), TrackAlias: 7}) - if err != nil { - t.Fatalf("Publish: %v", err) - } - return ps, p - } - pubBSess, pubB := startPub(relayB) - pubCSess, pubC := startPub(relayC) + // A redundant publisher of the same track on each of B and C. + pubBSess, _ := publishOnRelay(t, relayB, "cam1", 7) + pubCSess, _ := publishOnRelay(t, relayC, "cam1", 7) - // Subscriber on A. Subscribe returns only after A has established BOTH - // upstreams (to B and C), so both Dialer calls have happened by here. + // Subscribe returns only once A has established both upstreams. subSess := dialClient(t, relayA) - subReq, err := subSess.Subscribe(ctx, &message.Subscribe{Namespace: ns("video"), Name: []byte("cam1")}) - if err != nil { - t.Fatalf("cross-relay Subscribe: %v", err) - } - - if got := dialsB.Load(); got != 1 { - t.Errorf("Dialer fired %d times for relay-B; want 1 (dial-all)", got) - } - if got := dialsC.Load(); got != 1 { - t.Errorf("Dialer fired %d times for relay-C; want 1 (dial-all)", got) + subscribeCam1(t, subSess) + if got, want := dials.counts(), map[string]int{"relay-B": 1, "relay-C": 1}; !maps.Equal(got, want) { + t.Errorf("Dialer calls %v, want %v (dial-all)", got, want) } events := make(chan objEvent, 64) go readSubgroups(ctx, subSess, events) - // Both remotes push the same objects 0,1,2 on the same (group, subgroup). - push := func(p *session.Publication) { - sg, err := p.OpenSubgroup(message.SubgroupHeader{ - SubgroupIDMode: message.SubgroupIDExplicit, TrackAlias: 7, GroupID: 0, SubgroupID: 0, - }) - if err != nil { - t.Errorf("OpenSubgroup: %v", err) - return - } - for i := range 3 { - if err := sg.WriteObject(&message.SubgroupObject{ - ObjectIDDelta: 0, - Payload: []byte{byte('A' + i)}, - }); err != nil { - t.Errorf("WriteObject #%d: %v", i, err) - return - } - } - _ = sg.Close() - } - push(pubB) - push(pubC) + // Both remotes push the same Objects 0,1,2 on the same (group, subgroup). + publishObjects(t, pubBSess, 7, 0, 3) + publishObjects(t, pubCSess, 7, 0, 3) - // Collect with a quiet-period idle timeout: each of 0,1,2 must arrive exactly - // once across however many outbound streams the merge produced. + // Each of 0,1,2 must arrive exactly once across however many outbound + // streams the merge produced; collect until 500ms of quiet. seen := map[uint64]int{} hard := time.After(3 * time.Second) collect: @@ -423,18 +173,10 @@ collect: break collect } } - for _, id := range []uint64{0, 1, 2} { - if seen[id] != 1 { - t.Fatalf("object %d delivered %d times across two remotes, want exactly 1 (dedup): %v", id, seen[id], seen) - } - } - if len(seen) != 3 { - t.Fatalf("delivered set = %v, want {0,1,2}", seen) + if want := map[uint64]int{0: 1, 1: 1, 2: 1}; !maps.Equal(seen, want) { + t.Fatalf("delivered Object counts %v across two remotes, want each of 0,1,2 exactly once (dedup)", seen) } - _ = subReq.Close() - _ = pubB.Close() - _ = pubC.Close() _ = subSess.Close(0, "done") _ = pubBSess.Close(0, "done") _ = pubCSess.Close(0, "done") @@ -447,10 +189,8 @@ collect: // RelayAddr is never dialled; the subscriber is refused. func TestCrossRelay_SelfExclusion(t *testing.T) { t.Parallel() - store := discovery.NewMemoryStore() defer store.Close() - ctx := t.Context() var dials atomic.Int64 @@ -462,24 +202,17 @@ func TestCrossRelay_SelfExclusion(t *testing.T) { return nil, fmt.Errorf("unexpected dial to %q", addr) }, }) - - // Seed the store with a namespace advertised by relay-A itself. - if err := store.PublishNamespace(ctx, discovery.NamespaceInfo{ - Prefix: ns("video"), - RelayAddr: "relay-A", - }); err != nil { + if err := store.PublishNamespace( + ctx, + discovery.NamespaceInfo{Prefix: ns("video"), RelayAddr: "relay-A"}, + ); err != nil { t.Fatalf("seed PublishNamespace: %v", err) } subSess := dialClient(t, relayA) - _, err := subSess.Subscribe(ctx, &message.Subscribe{ - Namespace: ns("video"), - Name: []byte("cam1"), - }) - if err == nil { + if _, err := subSess.Subscribe(ctx, &message.Subscribe{Namespace: ns("video"), Name: []byte("cam1")}); err == nil { t.Fatal("Subscribe succeeded; want rejection (no remote relay, self excluded)") } - if got := dials.Load(); got != 0 { t.Errorf("Dialer fired %d times; want 0 (self must not be dialled)", got) } @@ -488,441 +221,53 @@ func TestCrossRelay_SelfExclusion(t *testing.T) { relayA.stop(t) } -// TestCrossRelay_PoolReuse pins that two cross-relay SUBSCRIBEs to the same -// remote relay share a single dialled session: subscribing to two distinct -// tracks in the same remote namespace dials once and reuses the pooled session. +// TestCrossRelay_PoolReuse: SUBSCRIBEs to two tracks on the same remote relay +// share one dialled session. func TestCrossRelay_PoolReuse(t *testing.T) { t.Parallel() - store := discovery.NewMemoryStore() defer store.Close() - ctx := t.Context() relayB := startTestRelay(ctx, relay.Config{Discovery: store, RelayAddr: "relay-B"}) - - var dials atomic.Int64 + var dials dialLog relayA := startTestRelay(ctx, relay.Config{ - Discovery: store, - RelayAddr: "relay-A", - Dialer: func(_ context.Context, addr string) (session.Conn, error) { - if addr != "relay-B" { - return nil, fmt.Errorf("no relay at %q", addr) - } - dials.Add(1) - return relayB.l.Dial() - }, + Discovery: store, RelayAddr: "relay-A", Dialer: dialerTo(dials.record, relayB), }) - // Publisher on B advertises the namespace and PUBLISHes two tracks. - pubSess := dialClient(t, relayB) - 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: ns("video"), Name: []byte("cam1"), TrackAlias: 1}) - if err != nil { - t.Fatalf("Publish cam1: %v", err) - } - pub2, err := pubSess.Publish(ctx, &message.Publish{Namespace: ns("video"), Name: []byte("cam2"), TrackAlias: 2}) - if err != nil { - t.Fatalf("Publish cam2: %v", err) - } + pubSess, _ := publishOnRelay(t, relayB, "cam1", 1) + publishVideoTrack(t, pubSess, "cam2", 2) subSess := dialClient(t, relayA) - 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: ns("video"), Name: []byte("cam2")}) - if err != nil { + subscribeCam1(t, subSess) + if _, err := subSess.Subscribe(ctx, &message.Subscribe{Namespace: ns("video"), Name: []byte("cam2")}); err != nil { t.Fatalf("Subscribe cam2: %v", err) } - - if got := dials.Load(); got != 1 { - t.Errorf("Dialer fired %d times for two tracks on one relay; want 1 (pool reuse)", got) + if got, want := dials.counts(), map[string]int{"relay-B": 1}; !maps.Equal(got, want) { + t.Errorf("Dialer calls %v for two tracks on one relay, want %v (pool reuse)", got, want) } - _ = sub1.Close() - _ = sub2.Close() - _ = pub1.Close() - _ = pub2.Close() - _ = pns.Close() _ = subSess.Close(0, "done") _ = pubSess.Close(0, "done") relayA.stop(t) relayB.stop(t) } -// TestCrossRelay_WatchNamespacesForward pins the consume side of -// WatchNamespaces: a namespace advertised by a *remote* relay is reflected to a -// local SUBSCRIBE_NAMESPACE holder as a NAMESPACE message. -func TestCrossRelay_WatchNamespacesForward(t *testing.T) { - t.Parallel() - - store := discovery.NewMemoryStore() - defer store.Close() - - ctx := t.Context() - - relayA := startTestRelay(ctx, relay.Config{Discovery: store, RelayAddr: "relay-A"}) - - subSess := dialClient(t, relayA) - nsReq, err := subSess.SubscribeNamespace(ctx, &message.SubscribeNamespace{ - TrackNamespacePrefix: ns("video"), - }) - if err != nil { - t.Fatalf("SubscribeNamespace: %v", err) - } - - // A remote relay advertises ["video","cam1"] into the shared store. A's - // WatchNamespaces consumer should forward it to our SUBSCRIBE_NAMESPACE - // holder as a NAMESPACE carrying the suffix ["cam1"]. - // - // runNamespaceWatch only sees events emitted AFTER it registered its - // watcher (MemoryStore does not replay history to new watchers), and that - // registration happens asynchronously in Start. So re-advertise on a ticker - // until the subscriber observes it — PublishNamespace re-emits OpPublish on - // every call. The injector is stopped once we've read the NAMESPACE. - stopInject := make(chan struct{}) - go func() { - ticker := time.NewTicker(20 * time.Millisecond) - defer ticker.Stop() - for { - _ = store.PublishNamespace(ctx, discovery.NamespaceInfo{ - Prefix: ns("video", "cam1"), - RelayAddr: "relay-C", - }) - select { - case <-stopInject: - return - case <-ticker.C: - } - } - }() - defer close(stopInject) - - got := relaytest.ReadNextMessage(t, nsReq, time.After(2*time.Second)) - nsMsg, ok := got.(*message.Namespace) - if !ok { - t.Fatalf("got %T, want *message.Namespace", got) - } - if len(nsMsg.TrackNamespaceSuffix) != 1 || string(nsMsg.TrackNamespaceSuffix[0]) != "cam1" { - t.Fatalf("NAMESPACE suffix = %v, want [cam1]", nsMsg.TrackNamespaceSuffix) - } - - _ = nsReq.Close() - _ = subSess.Close(0, "done") - relayA.stop(t) -} - -// 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() - - store := discovery.NewMemoryStore() - defer store.Close() - - ctx := t.Context() - - relayA := startTestRelay(ctx, relay.Config{Discovery: store, RelayAddr: "relay-A"}) - - subSess := dialClient(t, relayA) - nsReq, err := subSess.SubscribeNamespace(ctx, &message.SubscribeNamespace{ - TrackNamespacePrefix: ns("video"), - }) - if err != nil { - t.Fatalf("SubscribeNamespace: %v", err) - } - - remoteNS := ns("video", "cam1") - - // Same re-advertise ticker as TestCrossRelay_WatchNamespacesForward, and for - // the same reason: the watcher registers asynchronously in Start and - // MemoryStore does not replay history to new watchers. Reading the NAMESPACE - // back is what proves the watch is live, which is in turn what makes the - // single Unpublish below observable. - stopInject := make(chan struct{}) - var stopOnce sync.Once - stopInjector := func() { stopOnce.Do(func() { close(stopInject) }) } - go func() { - ticker := time.NewTicker(20 * time.Millisecond) - defer ticker.Stop() - for { - _ = store.PublishNamespace(ctx, discovery.NamespaceInfo{ - Prefix: remoteNS, - RelayAddr: "relay-C", - }) - select { - case <-stopInject: - return - case <-ticker.C: - } - } - }() - defer stopInjector() - - if got := relaytest.ReadNextMessage(t, nsReq, time.After(2*time.Second)); !isNamespace(got) { - t.Fatalf("got %T, want *message.Namespace before the retraction", got) - } - stopInjector() - - // The advertisement has to still be in the store for the retraction to emit - // anything — UnpublishNamespace on a missing entry is a silent no-op, which - // would leave this test hanging on a NAMESPACE_DONE that was never sent. - if err := store.UnpublishNamespace(ctx, remoteNS, "relay-C"); err != nil { - t.Fatalf("UnpublishNamespace: %v", err) - } - - // Ticks queued before the injector stopped may still be in flight, so skip - // any duplicate NAMESPACE sitting ahead of the NAMESPACE_DONE. - var done *message.NamespaceDone - for range 32 { - switch got := relaytest.ReadNextMessage(t, nsReq, time.After(2*time.Second)).(type) { - case *message.Namespace: - continue - case *message.NamespaceDone: - done = got - default: - t.Fatalf("got %T, want *message.NamespaceDone", got) - } - break - } - if done == nil { - t.Fatal("no NAMESPACE_DONE within 32 messages") - } - if len(done.TrackNamespaceSuffix) != 1 || string(done.TrackNamespaceSuffix[0]) != "cam1" { - t.Fatalf("NAMESPACE_DONE suffix = %v, want [cam1]", done.TrackNamespaceSuffix) - } - - _ = nsReq.Close() - _ = subSess.Close(0, "done") - relayA.stop(t) -} - -// 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() - - store := discovery.NewMemoryStore() - defer store.Close() - - ctx := t.Context() - - relayA := startTestRelay(ctx, relay.Config{Discovery: store, RelayAddr: "relay-A"}) - - // Both holders register on the same prefix before any event is injected, so - // 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: ns("video"), - }) - if err != nil { - t.Fatalf("SubscribeNamespace: %v", err) - } - - trSess := dialClient(t, relayA) - trSub, err := trSess.SubscribeTracks(ctx, &message.SubscribeTracks{ - TrackNamespacePrefix: ns("video"), - }) - if err != nil { - t.Fatalf("SubscribeTracks: %v", err) - } - - stopInject := make(chan struct{}) - go func() { - ticker := time.NewTicker(20 * time.Millisecond) - defer ticker.Stop() - for { - _ = store.PublishNamespace(ctx, discovery.NamespaceInfo{ - Prefix: ns("video", "cam1"), - RelayAddr: "relay-C", - }) - select { - case <-stopInject: - return - case <-ticker.C: - } - } - }() - defer close(stopInject) - - got := relaytest.ReadNextMessage(t, nsReq, time.After(2*time.Second)) - nsMsg, ok := got.(*message.Namespace) - if !ok { - t.Fatalf("got %T, want *message.Namespace", got) - } - if len(nsMsg.TrackNamespaceSuffix) != 1 || string(nsMsg.TrackNamespaceSuffix[0]) != "cam1" { - t.Fatalf("NAMESPACE suffix = %v, want [cam1]", nsMsg.TrackNamespaceSuffix) - } - - // 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 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 - } - quiet := make(chan parsed, 1) - go func() { - msg, err := message.Parse(trSub) - quiet <- parsed{msg: msg, err: err} - }() - select { - case p := <-quiet: - if p.err != nil { - t.Fatalf("SUBSCRIBE_TRACKS stream failed instead of staying quiet: %v", p.err) - } - t.Fatalf("unexpected %T on the SUBSCRIBE_TRACKS stream", p.msg) - case <-time.After(300 * time.Millisecond): - } - - _ = nsReq.Close() - _ = trSub.Close() - _ = nsSess.Close(0, "done") - _ = trSess.Close(0, "done") - relayA.stop(t) -} - -// 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() - - store := discovery.NewMemoryStore() - defer store.Close() - - ctx := t.Context() - - // Remote advertisement exists before the subscriber (and before relay A). - if err := store.PublishNamespace(ctx, discovery.NamespaceInfo{ - Prefix: ns("video", "cam1"), - RelayAddr: "relay-C", - }); err != nil { - t.Fatalf("seed PublishNamespace: %v", err) - } - - relayA := startTestRelay(ctx, relay.Config{Discovery: store, RelayAddr: "relay-A"}) - - subSess := dialClient(t, relayA) - nsReq, err := subSess.SubscribeNamespace(ctx, &message.SubscribeNamespace{ - TrackNamespacePrefix: ns("video"), - }) - if err != nil { - t.Fatalf("SubscribeNamespace: %v", err) - } - - got := relaytest.ReadNextMessage(t, nsReq, time.After(2*time.Second)) - nsMsg, ok := got.(*message.Namespace) - if !ok { - t.Fatalf("got %T, want *message.Namespace", got) - } - if len(nsMsg.TrackNamespaceSuffix) != 1 || string(nsMsg.TrackNamespaceSuffix[0]) != "cam1" { - t.Fatalf("seeded NAMESPACE suffix = %v, want [cam1]", nsMsg.TrackNamespaceSuffix) - } - - _ = nsReq.Close() - _ = subSess.Close(0, "done") - relayA.stop(t) -} - -// 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() - - store := discovery.NewMemoryStore() - defer store.Close() - - ctx := t.Context() - - relayA := startTestRelay(ctx, relay.Config{Discovery: store, RelayAddr: "relay-A"}) - - // Subscriber S subscribes to a prefix and continuously drains its stream so - // writes never block. - subSess := dialClient(t, relayA) - nsReq, err := subSess.SubscribeNamespace(ctx, &message.SubscribeNamespace{ - TrackNamespacePrefix: ns("room"), - }) - if err != nil { - t.Fatalf("SubscribeNamespace: %v", err) - } - drained := make(chan struct{}) - go func() { - defer close(drained) - for { - if _, err := message.Parse(nsReq); err != nil { - return - } - } - }() - - const rounds = 50 - var wg sync.WaitGroup - - // Writer 1: a local publisher repeatedly advertises namespaces under "room"; - // each PUBLISH_NAMESPACE forwards a NAMESPACE to S (and its Close a - // NAMESPACE_DONE) from relayA's publisher-handler goroutine. - pubSess := dialClient(t, relayA) - wg.Go(func() { - for i := range rounds { - pns, err := pubSess.PublishNamespace(ctx, &message.PublishNamespace{ - Namespace: wire.TrackNamespace{[]byte("room"), fmt.Appendf(nil, "local%d", i)}, - }) - if err != nil { - return - } - _ = pns.Close() - } - }) - - // Writer 2: remote advertisements injected into the shared store; each fires - // the relay-level watch goroutine to forward a NAMESPACE to the same S. - wg.Go(func() { - for i := range rounds { - _ = store.PublishNamespace(ctx, discovery.NamespaceInfo{ - Prefix: wire.TrackNamespace{[]byte("room"), fmt.Appendf(nil, "remote%d", i)}, - RelayAddr: "relay-C", - }) - } - }) - - wg.Wait() - - _ = nsReq.Close() - _ = subSess.Close(0, "done") - _ = pubSess.Close(0, "done") - relayA.stop(t) - <-drained -} - // 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() - dialer := func(_ context.Context, _ string) (session.Conn, error) { return nil, errors.New("unused") } - newWith := func(relayAddr string) string { var buf bytes.Buffer logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn})) store := discovery.NewMemoryStore() defer store.Close() - // New logs synchronously and starts no goroutines, so the buffer is - // fully written by the time New returns. + // New logs synchronously and starts no goroutines. _ = relay.New(newPipeListener(), relay.Config{ - Discovery: store, - RelayAddr: relayAddr, - Dialer: dialer, - Logger: logger, + Discovery: store, RelayAddr: relayAddr, Dialer: dialer, Logger: logger, }) return buf.String() } @@ -935,26 +280,21 @@ func TestCrossRelay_DialerWithoutRelayAddrWarns(t *testing.T) { } } -// TestCrossRelay_NoDialerNoop pins back-compat: with Discovery but no Dialer, a -// SUBSCRIBE with no local publisher is cleanly rejected (no cross-relay -// routing), exactly as a single-instance relay behaves. +// TestCrossRelay_NoDialerNoop: with Discovery but no Dialer, a SUBSCRIBE with +// no local publisher is refused exactly as on a single-instance relay. func TestCrossRelay_NoDialerNoop(t *testing.T) { t.Parallel() - store := discovery.NewMemoryStore() defer store.Close() - ctx := t.Context() - // Seed a remote namespace; without a Dialer the relay must not try to use - // it. - if err := store.PublishNamespace(ctx, discovery.NamespaceInfo{ - Prefix: ns("video"), - RelayAddr: "relay-B", - }); err != nil { + // A remote namespace the relay must not try to use without a Dialer. + if err := store.PublishNamespace( + ctx, + discovery.NamespaceInfo{Prefix: ns("video"), RelayAddr: "relay-B"}, + ); err != nil { t.Fatalf("seed PublishNamespace: %v", err) } - relayA := startTestRelay(ctx, relay.Config{Discovery: store, RelayAddr: "relay-A"}) subSess := dialClient(t, relayA) @@ -962,8 +302,6 @@ func TestCrossRelay_NoDialerNoop(t *testing.T) { if err == nil { t.Fatal("Subscribe succeeded; want rejection (no Dialer, no local publisher)") } - // Sanity: the rejection is the protocol-level REQUEST_ERROR, not a - // transport error. if _, ok := errors.AsType[*session.RequestRejectedError](err); !ok { t.Logf("Subscribe error (non-RequestRejectedError is acceptable): %v", err) } @@ -977,105 +315,49 @@ func TestCrossRelay_NoDialerNoop(t *testing.T) { // one. func TestCrossRelay_UpstreamFanInCapConverges(t *testing.T) { t.Parallel() - store := discovery.NewMemoryStore() defer store.Close() - ctx := t.Context() - relayB := startTestRelay(ctx, relay.Config{Discovery: store, RelayAddr: "relay-B"}) - relayC := startTestRelay(ctx, relay.Config{Discovery: store, RelayAddr: "relay-C"}) - relayD := startTestRelay(ctx, relay.Config{Discovery: store, RelayAddr: "relay-D"}) - remotes := map[string]*testRelay{"relay-B": relayB, "relay-C": relayC, "relay-D": relayD} - - // A publisher on every remote so whichever the leaves converge on can serve - // cam1 (establishment, and thus the dial, only completes against a relay that - // actually hosts the track). + var remotes []*testRelay var pubSessions []*session.Session - for addr, tr := range remotes { - ps := dialClient(t, tr) - 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: ns("video"), Name: []byte("cam1"), TrackAlias: 7}, - ); err != nil { - t.Fatalf("%s Publish: %v", addr, err) - } + for _, addr := range []string{"relay-B", "relay-C", "relay-D"} { + tr := startTestRelay(ctx, relay.Config{Discovery: store, RelayAddr: addr}) + remotes = append(remotes, tr) + // Every remote hosts cam1, so whichever the leaves converge on can + // establish the upstream. + ps, _ := publishOnRelay(t, tr, "cam1", 7) pubSessions = append(pubSessions, ps) } - // A per-leaf dialer that records only successful dials into known remotes. - // A candidate a leaf cannot reach (e.g. the other leaf, should it re-advertise - // the namespace) misses the lookup and is not counted — resolveUpstreams - // treats it as skip-and-fall-through, so the log holds exactly the leaf's - // established upstreams. - makeDialer := func(mu *sync.Mutex, log map[string]int) func(context.Context, string) (session.Conn, error) { - return func(_ context.Context, addr string) (session.Conn, error) { - tr, ok := remotes[addr] - if !ok { - return nil, fmt.Errorf("no relay at %q", addr) - } - mu.Lock() - log[addr]++ - mu.Unlock() - return tr.l.Dial() - } - } - dialed := func(mu *sync.Mutex, log map[string]int) (addrs []string, total int) { - mu.Lock() - defer mu.Unlock() - for a, n := range log { - if n > 0 { - addrs = append(addrs, a) - total += n - } - } - return addrs, total - } - - var muA1 sync.Mutex - logA1 := map[string]int{} + // Each leaf's log holds only dials into known remotes: resolveUpstreams + // skips an unreachable candidate (such as the other leaf) and falls + // through, so it is exactly the leaf's established upstreams. + var logA1, logA2 dialLog relayA1 := startTestRelay(ctx, relay.Config{ - Discovery: store, RelayAddr: "relay-A1", UpstreamFanIn: 1, - Dialer: makeDialer(&muA1, logA1), + Discovery: store, RelayAddr: "relay-A1", UpstreamFanIn: 1, Dialer: dialerTo(logA1.record, remotes...), }) - var muA2 sync.Mutex - logA2 := map[string]int{} relayA2 := startTestRelay(ctx, relay.Config{ - Discovery: store, RelayAddr: "relay-A2", UpstreamFanIn: 1, - Dialer: makeDialer(&muA2, logA2), + Discovery: store, RelayAddr: "relay-A2", UpstreamFanIn: 1, Dialer: dialerTo(logA2.record, remotes...), }) - // Subscribe blocks until the (single) upstream is established, so the dial - // logs are settled by the time each call returns. + // Subscribe blocks until the upstream is established, so the logs are + // settled when it returns. sub1 := dialClient(t, relayA1) - req1, err := sub1.Subscribe(ctx, &message.Subscribe{Namespace: ns("video"), Name: []byte("cam1")}) - if err != nil { - t.Fatalf("A1 Subscribe: %v", err) - } + subscribeCam1(t, sub1) sub2 := dialClient(t, relayA2) - req2, err := sub2.Subscribe(ctx, &message.Subscribe{Namespace: ns("video"), Name: []byte("cam1")}) - if err != nil { - t.Fatalf("A2 Subscribe: %v", err) - } - - a1Addrs, a1Total := dialed(&muA1, logA1) - a2Addrs, a2Total := dialed(&muA2, logA2) + subscribeCam1(t, sub2) - if len(a1Addrs) != 1 || a1Total != 1 { - t.Errorf("A1 dialed %v (total %d); want exactly one upstream (UpstreamFanIn=1)", a1Addrs, a1Total) - } - if len(a2Addrs) != 1 || a2Total != 1 { - t.Errorf("A2 dialed %v (total %d); want exactly one upstream (UpstreamFanIn=1)", a2Addrs, a2Total) + a1, a2 := logA1.counts(), logA2.counts() + for leaf, log := range map[string]map[string]int{"A1": a1, "A2": a2} { + if len(log) != 1 || slices.Max(slices.Collect(maps.Values(log))) != 1 { + t.Errorf("%s dialed %v; want exactly one upstream, once (UpstreamFanIn=1)", leaf, log) + } } - if len(a1Addrs) == 1 && len(a2Addrs) == 1 && a1Addrs[0] != a2Addrs[0] { - t.Errorf("leaves diverged: A1 chose %q, A2 chose %q; rendezvous ranking must converge", a1Addrs[0], a2Addrs[0]) + if len(a1) == 1 && len(a2) == 1 && !maps.Equal(a1, a2) { + t.Errorf("leaves diverged: A1 dialed %v, A2 dialed %v; rendezvous ranking must converge", a1, a2) } - _ = req1.Close() - _ = req2.Close() _ = sub1.Close(0, "done") _ = sub2.Close(0, "done") for _, ps := range pubSessions { @@ -1083,296 +365,7 @@ func TestCrossRelay_UpstreamFanInCapConverges(t *testing.T) { } relayA1.stop(t) relayA2.stop(t) - relayB.stop(t) - relayC.stop(t) - relayD.stop(t) -} - -// 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() - - store := discovery.NewMemoryStore() - defer store.Close() - - ctx := t.Context() - - // Stand in for a peer relay: advertise a namespace at "peer" so the relay - // 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: ns("video"), RelayAddr: peerAddr}); err != nil { - t.Fatalf("PublishNamespace: %v", err) - } - - peerSessions := make(chan *session.Session, 1) - r := startTestRelay(ctx, relay.Config{ - GoawayTimeout: 2 * time.Second, // long enough that the drain is observable - Discovery: store, - RelayAddr: "relay-under-test:4433", - Dialer: func(_ context.Context, addr string) (session.Conn, error) { - if addr != peerAddr { - return nil, fmt.Errorf("no relay at %q", addr) - } - relaySide, peerSide := sessiontest.NewConnPair() - go func() { - // The relay dials as a client, so this end completes SETUP as - // the server. - sess, err := session.Server(context.Background(), peerSide) - if err != nil { - close(peerSessions) - return - } - peerSessions <- sess - }() - return relaySide, nil - }, - }) - - // A downstream SUBSCRIBE with no local publisher drives the upstream dial. - // Issued in the background: the relay does not answer it until the upstream - // 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: ns("video"), Name: []byte("cam1")}) - }() - - var peer *session.Session - select { - case peer = <-peerSessions: - if peer == nil { - t.Fatal("upstream peer SETUP failed") - } - case <-time.After(5 * time.Second): - t.Fatal("relay never dialled the advertised upstream") - } - - stopDone := make(chan struct{}) - go func() { - defer close(stopDone) - stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - _ = r.r.Stop(stopCtx) - }() - - // The upstream peer must be told the relay is going away before its session - // is torn down. - select { - case <-peer.GoawayReceived(): - case <-peer.Done(): - t.Fatal("upstream session was torn down without ever receiving a GOAWAY;" + - " §3.6 requires the GOAWAY first") - case <-time.After(5 * time.Second): - t.Fatal("upstream peer received no GOAWAY") - } - - <-stopDone - select { - case err := <-r.startErr: - if err != nil { - t.Errorf("Start returned: %v", err) - } - case <-time.After(2 * time.Second): - t.Error("Start did not return after Stop") - } -} - -// 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() - - store := discovery.NewMemoryStore() - defer store.Close() - - ctx := t.Context() - - relayB := startTestRelay(ctx, relay.Config{Discovery: store, RelayAddr: "relay-B"}) - relayA := startTestRelay(ctx, relay.Config{ - Discovery: store, - RelayAddr: "relay-A", - Dialer: func(_ context.Context, addr string) (session.Conn, error) { - if addr == "relay-B" { - return relayB.l.Dial() - } - return nil, fmt.Errorf("no relay at %q", addr) - }, - }) - - // 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: ns("video")}) - if err != nil { - t.Fatalf("PublishNamespace: %v", err) - } - const pubAlias = uint64(7) - pubReq, err := pubSess.Publish(ctx, &message.Publish{ - Namespace: ns("video"), - Name: []byte("catalog"), - TrackAlias: pubAlias, - }) - if err != nil { - t.Fatalf("Publish: %v", err) - } - pubSg, err := pubSess.OpenSubgroup(message.SubgroupHeader{ - SubgroupIDMode: message.SubgroupIDExplicit, - TrackAlias: pubAlias, - GroupID: 0, - SubgroupID: 0, - }) - if err != nil { - t.Fatalf("OpenSubgroup: %v", err) - } - const sgCount = 3 - for i := range sgCount { - if err := pubSg.WriteObject(&message.SubgroupObject{ - ObjectIDDelta: 0, - Payload: []byte{byte('A' + i)}, - }); err != nil { - t.Fatalf("WriteObject #%d: %v", i, err) - } - } - if err := pubSg.Close(); err != nil { - t.Fatalf("pubSg.Close: %v", err) - } - - // Let B's fanout observe the objects, so B has a watermark to report in its - // SUBSCRIBE_OK. Without this the test could pass for the wrong reason: B - // omitting LARGEST_OBJECT because it genuinely knows nothing yet is not the - // bug under test. - time.Sleep(100 * time.Millisecond) - - // Subscriber joins on A, which has no local publisher and follows Discovery - // 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: ns("video"), Name: []byte("catalog")} - subReq, err := subSess.Subscribe(ctx, subMsg) - if err != nil { - t.Fatalf("cross-relay Subscribe: %v", err) - } - - // §10.2.17: "If Objects have been published on this Track the Publisher MUST - // include this parameter." A is the publisher for this subscriber, and B has - // told it objects exist, so its SUBSCRIBE_OK has to carry the watermark. - // This is the assertion the fix turns green on the wire. - if _, ok := subReq.OK.Parameters.Find(message.ParamLargestObject); !ok { - t.Fatalf("A's SUBSCRIBE_OK omitted LARGEST_OBJECT; it learned no Joining "+ - "Location from B's SUBSCRIBE_OK (params=%v)", subReq.OK.Parameters) - } - - // A FETCH is the only way this subscriber can reach content published before - // 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): the current group - // up to Largest Object. - fetchReq, err := subSess.Fetch(ctx, &message.Fetch{ - Namespace: subMsg.Namespace, - Name: subMsg.Name, - Parameters: message.Parameters{ - message.GroupOrderParam(message.GroupOrderAscending), - message.RelativeStartFilter(1), - }, - }) - if err != nil { - t.Fatalf("FETCH rejected, so the backfill is unreachable: %v", err) - } - defer fetchReq.Close() - - type fetchResult struct { - n int - err error - } - done := make(chan fetchResult, 1) - go func() { - ds, err := subSess.AcceptDataStream(ctx) - if err != nil { - done <- fetchResult{err: err} - return - } - fs, ok := ds.(*session.IncomingFetchStream) - if !ok { - done <- fetchResult{err: fmt.Errorf("got %T, want *session.IncomingFetchStream", ds)} - return - } - var n int - for { - if _, err := fs.ReadObject(); err != nil { - done <- fetchResult{n: n} - return - } - n++ - } - }() - - select { - case res := <-done: - if res.err != nil { - t.Fatalf("reading the joining FETCH response: %v", res.err) - } - if res.n != sgCount { - t.Errorf("joining FETCH returned %d objects, want %d — the backfill "+ - "did not cover the group published before the subscriber joined", - res.n, sgCount) - } - case <-time.After(3 * time.Second): - t.Fatal("joining FETCH response did not arrive within deadline") - } - - _ = subReq.Close() - _ = pubReq.Close() - _ = pns.Close() - _ = subSess.Close(0, "done") - _ = pubSess.Close(0, "done") - relayA.stop(t) - relayB.stop(t) -} - -// 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() - defer store.Close() - ctx := t.Context() - - relayB := startTestRelay(ctx, relay.Config{Discovery: store, RelayAddr: "relay-B"}) - defer relayB.stop(t) - relayA := startTestRelay(ctx, relay.Config{ - Discovery: store, - RelayAddr: "relay-A", - Dialer: func(_ context.Context, addr string) (session.Conn, error) { - if addr == "relay-B" { - return relayB.l.Dial() - } - return nil, fmt.Errorf("no relay at %q", addr) - }, - }) - defer relayA.stop(t) - - pubSess := dialClient(t, relayB) - defer func() { _ = pubSess.Close(0, "done") }() - pns, err := pubSess.PublishNamespace(ctx, &message.PublishNamespace{Namespace: ns("video")}) - if err != nil { - t.Fatalf("PublishNamespace: %v", err) - } - defer func() { _ = pns.Close() }() - pub := publishVideoTrack(t, pubSess, "cam1", 7) - - subSess := dialClient(t, relayA) - defer func() { _ = subSess.Close(0, "done") }() - subReq := subscribeCam1(t, subSess) - - if err := pub.Done(moqt.PublishDoneMalformedTrack, "bad track"); err != nil { - t.Fatalf("Done: %v", err) - } - if pd := awaitPublishDone(t, subReq); pd.StatusCode != moqt.PublishDoneMalformedTrack { - t.Fatalf("PUBLISH_DONE across two relays %#x, want MALFORMED_TRACK %#x", - pd.StatusCode, moqt.PublishDoneMalformedTrack) + for _, tr := range remotes { + tr.stop(t) } } diff --git a/pkg/relay/forward_state_test.go b/pkg/relay/forward_state_test.go index e4485c10..e56676be 100644 --- a/pkg/relay/forward_state_test.go +++ b/pkg/relay/forward_state_test.go @@ -1,7 +1,6 @@ package relay_test import ( - "context" "errors" "io" "slices" @@ -164,29 +163,6 @@ func TestRelay_ForwardStateOmissionResetsStream(t *testing.T) { } } -// readUntilEnd reads the subscriber's next subgroup stream to its end and -// returns the Object IDs it carried and how it ended. -func readUntilEnd(t *testing.T, subSess *session.Session) (ids []uint64, end error) { - t.Helper() - ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) - defer cancel() - ds, err := subSess.AcceptDataStream(ctx) - if err != nil { - t.Fatalf("AcceptDataStream: %v", err) - } - in, ok := ds.(*session.IncomingSubgroupStream) - if !ok { - t.Fatalf("AcceptDataStream = %T, want a subgroup stream", ds) - } - for { - o, err := in.ReadDecoded() - if err != nil { - return ids, err - } - ids = append(ids, o.ObjectID) - } -} - // requireReset fails unless end is a reset: not a FIN, not a clean end. func requireReset(t *testing.T, end error, why string) { t.Helper() diff --git a/pkg/relay/handler_fanout_firstobject_test.go b/pkg/relay/handler_fanout_firstobject_test.go index 778981dc..551d9b12 100644 --- a/pkg/relay/handler_fanout_firstobject_test.go +++ b/pkg/relay/handler_fanout_firstobject_test.go @@ -97,32 +97,6 @@ func firstObjectTopology( return pub, sub } -// writeSubgroupObjects opens one subgroup on the publisher and writes the -// given absolute object IDs (ascending), then FINs. -func writeSubgroupObjects(t *testing.T, pub *session.Publication, hdr message.SubgroupHeader, ids []uint64) { - t.Helper() - sg, err := pub.OpenSubgroup(hdr) - if err != nil { - t.Fatalf("OpenSubgroup: %v", err) - } - prev, has := uint64(0), false - for _, id := range ids { - obj := &message.SubgroupObject{Payload: []byte{byte('a' + id)}} - if !has { - obj.ObjectIDDelta = id - } else { - obj.ObjectIDDelta = id - prev - 1 - } - if err := sg.WriteObject(obj); err != nil { - t.Fatalf("WriteObject(%d): %v", id, err) - } - prev, has = id, true - } - if err := sg.Close(); err != nil { - t.Fatalf("subgroup Close: %v", err) - } -} - // TestFanout_FirstObjectBitOnPlainForward pins the §11.4.2 baseline: a // forwarded subgroup that begins with the subgroup's true first object // carries the FIRST_OBJECT bit (ReplayingSubgroup false). diff --git a/pkg/relay/handler_fanout_lifecycle_test.go b/pkg/relay/handler_fanout_lifecycle_test.go new file mode 100644 index 00000000..cf35afba --- /dev/null +++ b/pkg/relay/handler_fanout_lifecycle_test.go @@ -0,0 +1,167 @@ +package relay_test + +import ( + "errors" + "io" + "slices" + "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" +) + +// Downstream stream lifecycle: a subscriber that stops reading, or never +// accepts, only ever blocks its own writer, and an inbound reset reaches the +// subscriber as a reset. + +// 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) { + // MaxDropsBeforeReset stays disabled: the stalled writer blocks inside + // WriteObject on its first Object, so closing its inbox cannot unblock it + // and the drop-cap reset is unreachable here (the lag-window reset is + // TestFanout_LagWindowResetsSlowSubscriber's). + m := &recordingMetrics{} + pubSess, teardown := connectRelay(t, relay.Config{SendQueueSize: 256, Metrics: m}) + defer teardown() + pub := publishVideoTrack(t, pubSess, "cam1", 7) + fastSess := newCam1Subscriber(t, pubSess) + slowSess := newCam1Subscriber(t, pubSess) + + sg, err := pub.OpenSubgroup(message.SubgroupHeader{SubgroupIDMode: message.SubgroupIDExplicit, GroupID: 1}) + if err != nil { + t.Fatalf("OpenSubgroup: %v", err) + } + + const sendCount = 600 + + // The stalled subscriber accepts its stream but never reads it, so the + // relay's send window to it fills and its writer inbox overflows. Outbound + // streams open lazily on the writer's first Object, so the stream only + // appears once the flood starts. + slowAccepted := make(chan session.DataStream, 1) + go func() { + ds, _ := slowSess.AcceptDataStream(t.Context()) + slowAccepted <- ds + }() + + // The fast subscriber signals each read on fastRead so the publisher can + // pace itself to it. + fastRead := make(chan struct{}, sendCount) + fastReceived := make(chan int, 1) + go func() { + ds, err := fastSess.AcceptDataStream(t.Context()) + if err != nil { + fastReceived <- -1 + return + } + sg, ok := ds.(*session.IncomingSubgroupStream) + if !ok { + fastReceived <- -1 + return + } + count := 0 + for { + if _, err := sg.ReadObject(); err != nil { + fastReceived <- count + return + } + count++ + fastRead <- struct{}{} + } + }() + + // Staying at most window Objects ahead of the fast reader keeps its inbox + // (SendQueueSize) from overflowing however goroutines are scheduled. + const window = 64 + for i := range sendCount { + if i >= window { + select { + case <-fastRead: + case <-time.After(5 * time.Second): + t.Fatalf("publisher stalled waiting for fast subscriber at #%d", i) + } + } + if err := sg.WriteObject(&message.SubgroupObject{Payload: []byte("x")}); err != nil { + t.Fatalf("WriteObject #%d: %v", i, err) + } + } + if err := sg.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + select { + case got := <-fastReceived: + if got != sendCount { + t.Fatalf("fast subscriber received %d, want %d", got, sendCount) + } + case <-time.After(5 * time.Second): + t.Fatal("fast subscriber did not drain within deadline") + } + // Isolation is only shown if the stalled subscriber actually overflowed. + if got := m.dropped.Load(); got == 0 { + t.Fatal("stalled subscriber did not overflow (dropped == 0); test no longer exercises isolation") + } + select { + case ds := <-slowAccepted: + if ds == nil { + t.Fatal("slow subscriber accept failed") + } + case <-time.After(2 * time.Second): + t.Fatal("slow subscriber's outbound stream never appeared") + } +} + +// 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{}) + defer teardown() + publishVideoTrack(t, pubSess, "cam1", 7) + + // Never accepting, the dead subscriber leaves the relay's header write to + // it blocked for good on the unbuffered in-process pipes. + newCam1Subscriber(t, pubSess) + liveSess := newCam1Subscriber(t, pubSess) + + reads := readNextSubgroup(t, liveSess) + publishObjects(t, pubSess, 7, 0, 1) + if r := awaitSubgroupRead(t, reads); !slices.Equal(r.payloads, []string{"A"}) { + t.Fatalf("live subscriber got %q, want [A]", r.payloads) + } +} + +// 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() + pubSess, teardown := connectRelay(t, relay.Config{}) + defer teardown() + pub := publishVideoTrack(t, pubSess, "cam1", 7) + subSess := newCam1Subscriber(t, pubSess) + + reads := readNextSubgroup(t, subSess) + sg, err := pub.OpenSubgroup(message.SubgroupHeader{SubgroupIDMode: message.SubgroupIDExplicit, GroupID: 0}) + if err != nil { + t.Fatalf("OpenSubgroup: %v", err) + } + if err := sg.WriteObject(&message.SubgroupObject{Payload: []byte("only")}); err != nil { + t.Fatalf("WriteObject: %v", err) + } + sg.Cancel(moqt.StreamResetCancelled) + + r := awaitSubgroupRead(t, reads) + if len(r.ids) < 1 { + t.Fatalf("subscriber received %d objects, want >=1 (the one written before the reset)", len(r.ids)) + } + if errors.Is(r.end, io.EOF) { + t.Fatal("subscriber stream ended with io.EOF (FIN); want a reset") + } +} diff --git a/pkg/relay/handler_fanout_multipub_test.go b/pkg/relay/handler_fanout_multipub_test.go index 1f7c1a82..a82ce795 100644 --- a/pkg/relay/handler_fanout_multipub_test.go +++ b/pkg/relay/handler_fanout_multipub_test.go @@ -2,7 +2,6 @@ package relay_test import ( "bytes" - "context" "errors" "io" "maps" @@ -17,54 +16,6 @@ import ( "github.com/floatdrop/moq-go/pkg/relay" ) -// objEvent is one decoded object (or a per-stream/accept error) emitted by the -// subgroup reader used in the multi-publisher tests below. -type objEvent struct { - stream int // 1-based index of the outbound stream it arrived on - absID uint64 // §11.4.2 delta resolved to an absolute Object ID - err error // non-nil marks a stream end (io.EOF = FIN, else reset) or accept error -} - -// 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 { - ds, err := sub.AcceptDataStream(ctx) - if err != nil { - out <- objEvent{err: err} - return - } - sg, ok := ds.(*session.IncomingSubgroupStream) - if !ok { - continue - } - streamIdx++ - idx := streamIdx - var ( - prev uint64 - have bool - ) - for { - obj, err := sg.ReadObject() - if err != nil { - out <- objEvent{stream: idx, err: err} - break - } - var absID uint64 - if !have { - absID = obj.ObjectIDDelta - have = true - } else { - absID = prev + obj.ObjectIDDelta + 1 - } - prev = absID - out <- objEvent{stream: idx, absID: absID} - } - } -} - // 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). diff --git a/pkg/relay/handler_fanout_test.go b/pkg/relay/handler_fanout_test.go index 655c2639..87b8068d 100644 --- a/pkg/relay/handler_fanout_test.go +++ b/pkg/relay/handler_fanout_test.go @@ -3,345 +3,75 @@ package relay_test import ( "errors" "io" - "reflect" + "slices" "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" ) +// Forwarding: what an Object published upstream looks like on the subscriber's +// stream, and which Objects a subscription's filter lets through. + // TestFanout_PublisherToSubscriberSingleObject: one Object reaches a subscriber // on another session under the relay-allocated Track Alias. func TestFanout_PublisherToSubscriberSingleObject(t *testing.T) { t.Parallel() - pubSess, teardown := connectRelay(t, relay.Config{}) defer teardown() - - const publisherAlias = uint64(7) - - pubReqStream, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: ns("video"), - Name: []byte("cam1"), - TrackAlias: publisherAlias, - }) - if err != nil { - t.Fatalf("Publish: %v", err) - } - defer pubReqStream.Close() - + pub := publishVideoTrack(t, pubSess, "cam1", 7) subSess := dialAnotherClient(t, pubSess) - subReqStream, err := subSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: ns("video"), - Name: []byte("cam1"), - }) - if err != nil { - t.Fatalf("Subscribe: %v", err) - } - defer subReqStream.Close() + subReq := subscribeCam1(t, subSess) - pubSubgroup, err := pubSess.OpenSubgroup(message.SubgroupHeader{ - SubgroupIDMode: message.SubgroupIDExplicit, - TrackAlias: publisherAlias, - GroupID: 5, - SubgroupID: 0, - }) + reads := readNextSubgroup(t, subSess) + sg, err := pub.OpenSubgroup(message.SubgroupHeader{SubgroupIDMode: message.SubgroupIDExplicit, GroupID: 5}) if err != nil { t.Fatalf("OpenSubgroup: %v", err) } - - // Reader goroutine on the subscriber side. - type forwarded struct { - header message.SubgroupHeader - obj *message.SubgroupObject - err error - } - resCh := make(chan forwarded, 1) - go func() { - ds, err := subSess.AcceptDataStream(t.Context()) - if err != nil { - resCh <- forwarded{err: err} - return - } - sg, ok := ds.(*session.IncomingSubgroupStream) - if !ok { - resCh <- forwarded{err: errors.New("not a SubgroupStream")} - return - } - obj, err := sg.ReadObject() - resCh <- forwarded{header: sg.Header, obj: obj, err: err} - }() - - wantPayload := []byte("hello-6a") - if err := pubSubgroup.WriteObject(&message.SubgroupObject{ - ObjectIDDelta: 0, - Payload: wantPayload, - }); err != nil { + if err := sg.WriteObject(&message.SubgroupObject{Payload: []byte("hello-6a")}); err != nil { t.Fatalf("WriteObject: %v", err) } - - select { - case res := <-resCh: - if res.err != nil { - t.Fatalf("subscriber Accept/ReadObject: %v", res.err) - } - if string(res.obj.Payload) != string(wantPayload) { - t.Fatalf("payload = %q, want %q", res.obj.Payload, wantPayload) - } - if res.header.TrackAlias != subReqStream.OK.TrackAlias { - t.Fatalf("forwarded TrackAlias = %d, want %d (subscriber's outbound alias)", - res.header.TrackAlias, subReqStream.OK.TrackAlias) - } - if res.header.GroupID != 5 { - t.Fatalf("forwarded GroupID = %d, want 5", res.header.GroupID) - } - case <-time.After(2 * time.Second): - t.Fatal("subscriber did not receive forwarded object within deadline") - } -} - -// 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 - // its first object, so closing its inbox can't unblock it and the - // drop-cap reset path isn't reachable here (the lag-window reset is - // covered by TestFanout_LagWindowResetsSlowSubscriber). - m := &recordingMetrics{} - pubSess, teardown := connectRelay(t, relay.Config{SendQueueSize: 256, Metrics: m}) - defer teardown() - - const publisherAlias = uint64(7) - - pubReqStream, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: ns("video"), - Name: []byte("cam1"), - TrackAlias: publisherAlias, - }) - if err != nil { - t.Fatalf("Publish: %v", err) - } - defer pubReqStream.Close() - - // Two subscribers on independent sessions. - fastSess := dialAnotherClient(t, pubSess) - slowSess := dialAnotherClient(t, pubSess) - - fastReq, err := fastSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: ns("video"), - Name: []byte("cam1"), - }) - if err != nil { - t.Fatalf("fast Subscribe: %v", err) - } - defer fastReq.Close() - - slowReq, err := slowSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: ns("video"), - Name: []byte("cam1"), - }) - if err != nil { - t.Fatalf("slow Subscribe: %v", err) - } - defer slowReq.Close() - - pubSubgroup, err := pubSess.OpenSubgroup(message.SubgroupHeader{ - SubgroupIDMode: message.SubgroupIDExplicit, - TrackAlias: publisherAlias, - GroupID: 1, - SubgroupID: 0, - }) - if err != nil { - t.Fatalf("OpenSubgroup: %v", err) - } - - const sendCount = 600 - - // Stalled subscriber: accept but never call ReadObject — its outbound - // stream stays unread, the relay's send window fills, and the relay's - // per-subscriber writer inbox overflows and starts dropping objects for - // it. This must not affect the fast subscriber below. - slowAcceptDone := make(chan session.DataStream, 1) - go func() { - ds, err := slowSess.AcceptDataStream(t.Context()) - if err != nil { - slowAcceptDone <- nil - return - } - slowAcceptDone <- ds - }() - - // Fast subscriber: drain everything, signalling each read on fastRead so - // the publisher can pace itself and never overflow the fast inbox. - fastRead := make(chan struct{}, sendCount) - fastReceived := make(chan int, 1) - go func() { - ds, err := fastSess.AcceptDataStream(t.Context()) - if err != nil { - fastReceived <- -1 - return - } - sg, ok := ds.(*session.IncomingSubgroupStream) - if !ok { - fastReceived <- -1 - return - } - count := 0 - for { - if _, err := sg.ReadObject(); err != nil { - fastReceived <- count - return - } - count++ - fastRead <- struct{}{} - } - }() - - // No pre-flood wait on the slow accept: outbound subgroup streams open - // lazily on the writer goroutine's first forwarded object, so the slow - // subscriber's stream doesn't exist until the flood starts. The acceptor - // goroutine above picks it up whenever it appears; a writer blocked on - // the (unread) slow stream only ever blocks its own goroutine. - - // Flood, but stay at most `window` objects ahead of the fast subscriber's - // reads so the fast inbox (SendQueueSize) can never overflow regardless of - // goroutine scheduling. The stalled subscriber, which never reads, - // overflows and drops regardless of pacing. - const window = 64 - for i := range sendCount { - if i >= window { - select { - case <-fastRead: - case <-time.After(5 * time.Second): - t.Fatalf("publisher stalled waiting for fast subscriber at #%d", i) - } - } - if err := pubSubgroup.WriteObject(&message.SubgroupObject{ - ObjectIDDelta: 0, - Payload: []byte("x"), - }); err != nil { - t.Fatalf("WriteObject #%d: %v", i, err) - } - } - if err := pubSubgroup.Close(); err != nil { - t.Fatalf("pubSubgroup.Close: %v", err) + if err := sg.Close(); err != nil { + t.Fatalf("Close: %v", err) } - // Fast subscriber should have received them all, unaffected by the - // stalled peer. - select { - case got := <-fastReceived: - if got != sendCount { - t.Fatalf("fast subscriber received %d, want %d", got, sendCount) - } - case <-time.After(5 * time.Second): - t.Fatal("fast subscriber did not drain within deadline") + r := awaitSubgroupRead(t, reads) + if !slices.Equal(r.payloads, []string{"hello-6a"}) { + t.Fatalf("payloads = %q, want [hello-6a]", r.payloads) } - - // The scenario only proves isolation if the stalled subscriber actually - // overflowed — otherwise the queue absorbed everything and nothing was - // stressed. - if got := m.dropped.Load(); got == 0 { - t.Fatal("stalled subscriber did not overflow (dropped == 0); test no longer exercises isolation") + if r.header.TrackAlias != subReq.OK.TrackAlias { + t.Fatalf("forwarded TrackAlias = %d, want %d (subscriber's outbound alias)", + r.header.TrackAlias, subReq.OK.TrackAlias) } - - // The stalled subscriber's stream must have been offered (accepted) - // even though it never read an object. - select { - case ds := <-slowAcceptDone: - if ds == nil { - t.Fatal("slow subscriber accept failed") - } - case <-time.After(2 * time.Second): - t.Fatal("slow subscriber's outbound stream never appeared") + if r.header.GroupID != 5 { + t.Fatalf("forwarded GroupID = %d, want 5", r.header.GroupID) } } -// 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() +// requireFilterForwards publishes Objects 0..n-1 of group 0 to a subscriber +// whose SUBSCRIBE carries filter, and requires it to receive exactly want on +// one stream under its own alias, ending in a FIN. The relay re-encodes the +// Object ID deltas, so they must decode to the publisher's IDs (§11.4.2). +func requireFilterForwards(t *testing.T, filter *message.LocationFilter, n int, want []uint64) { + t.Helper() pubSess, teardown := connectRelay(t, relay.Config{}) - defer teardown() - - const publisherAlias = uint64(7) - pubReqStream, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: ns("video"), - Name: []byte("cam1"), - TrackAlias: publisherAlias, - }) - if err != nil { - t.Fatalf("Publish: %v", err) - } - defer pubReqStream.Close() - - // Subscriber A: subscribes, then never touches its data plane — no - // AcceptDataStream, so the relay's header write to it can never - // complete on the in-process unbuffered pipes. - deadSess := dialAnotherClient(t, pubSess) - deadReq, err := deadSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: ns("video"), - Name: []byte("cam1"), - }) - if err != nil { - t.Fatalf("dead Subscribe: %v", err) - } - defer deadReq.Close() - - liveSess := dialAnotherClient(t, pubSess) - liveReq, err := liveSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: ns("video"), - Name: []byte("cam1"), - }) - if err != nil { - t.Fatalf("live Subscribe: %v", err) - } - defer liveReq.Close() - - got := make(chan string, 1) - go func() { - ds, err := liveSess.AcceptDataStream(t.Context()) - if err != nil { - return - } - sg, ok := ds.(*session.IncomingSubgroupStream) - if !ok { - return - } - obj, err := sg.ReadObject() - if err != nil { - return - } - got <- string(obj.Payload) - }() + t.Cleanup(teardown) + publishVideoTrack(t, pubSess, "cam1", 7) + subSess := dialAnotherClient(t, pubSess) + subReq := subscribeCam1(t, subSess, message.LocationFilterParam(filter)) - sg, err := pubSess.OpenSubgroup(message.SubgroupHeader{ - SubgroupIDMode: message.SubgroupIDExplicit, - TrackAlias: publisherAlias, - GroupID: 0, - SubgroupID: 0, - }) - if err != nil { - t.Fatalf("OpenSubgroup: %v", err) + reads := readNextSubgroup(t, subSess) + publishObjects(t, pubSess, 7, 0, n) + r := awaitSubgroupRead(t, reads) + if !errors.Is(r.end, io.EOF) { + t.Fatalf("subscriber read ended with %v, want io.EOF", r.end) } - if err := sg.WriteObject(&message.SubgroupObject{Payload: []byte("through")}); err != nil { - t.Fatalf("WriteObject: %v", err) + if !slices.Equal(r.ids, want) { + t.Fatalf("subscriber saw object IDs %v, want %v", r.ids, want) } - - select { - case payload := <-got: - if payload != "through" { - t.Fatalf("live subscriber got %q, want %q", payload, "through") - } - case <-time.After(2 * time.Second): - t.Fatal("live subscriber starved — an unresponsive peer stalled the subgroup fanout") + if r.header.TrackAlias != subReq.OK.TrackAlias { + t.Fatalf("forwarded TrackAlias = %d, want %d", r.header.TrackAlias, subReq.OK.TrackAlias) } } @@ -350,351 +80,15 @@ func TestFanout_UnresponsiveSubscriberDoesNotStallSubgroup(t *testing.T) { // publisher's Object IDs (§5.1.2). func TestFanout_AbsoluteStartFilter_DropsObjectsBeforeStart(t *testing.T) { t.Parallel() - - pubSess, teardown := connectRelay(t, relay.Config{}) - defer teardown() - - const publisherAlias = uint64(7) - pubReqStream, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: ns("video"), - Name: []byte("cam1"), - TrackAlias: publisherAlias, - }) - if err != nil { - t.Fatalf("Publish: %v", err) - } - defer pubReqStream.Close() - - subSess := dialAnotherClient(t, pubSess) - subReq, err := subSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: ns("video"), - Name: []byte("cam1"), - Parameters: message.Parameters{ - message.LocationFilterParam(&message.LocationFilter{Fields: 2, StartGroup: 0, StartObject: 2}), - }, - }) - if err != nil { - t.Fatalf("Subscribe: %v", err) - } - defer subReq.Close() - - pubSubgroup, err := pubSess.OpenSubgroup(message.SubgroupHeader{ - SubgroupIDMode: message.SubgroupIDExplicit, - TrackAlias: publisherAlias, - GroupID: 0, - SubgroupID: 0, - }) - if err != nil { - t.Fatalf("OpenSubgroup: %v", err) - } - - type readResult struct { - header message.SubgroupHeader - ids []uint64 - err error - } - resCh := make(chan readResult, 1) - go func() { - ds, err := subSess.AcceptDataStream(t.Context()) - if err != nil { - resCh <- readResult{err: err} - return - } - sg, ok := ds.(*session.IncomingSubgroupStream) - if !ok { - resCh <- readResult{err: errors.New("not a SubgroupStream")} - return - } - var ( - ids []uint64 - prev uint64 - haveFirst bool - ) - for { - obj, err := sg.ReadObject() - if err != nil { - resCh <- readResult{header: sg.Header, ids: ids, err: err} - return - } - var absID uint64 - if !haveFirst { - absID = obj.ObjectIDDelta - haveFirst = true - } else { - absID = prev + obj.ObjectIDDelta + 1 - } - prev = absID - ids = append(ids, absID) - } - }() - - // Publish absolute IDs 0,1,2,3,4 (sequential, delta=0 each on the - // wire). The relay must drop 0,1 and forward 2,3,4 with re-encoded - // deltas so the subscriber decodes 2,3,4 too. - for i := range 5 { - if err := pubSubgroup.WriteObject(&message.SubgroupObject{ - ObjectIDDelta: 0, // sequential - Payload: []byte{byte('A' + i)}, - }); err != nil { - t.Fatalf("WriteObject #%d: %v", i, err) - } - } - if err := pubSubgroup.Close(); err != nil { - t.Fatalf("pubSubgroup.Close: %v", err) - } - - select { - case res := <-resCh: - if !errors.Is(res.err, io.EOF) { - t.Fatalf("subscriber read ended with %v, want io.EOF", res.err) - } - wantIDs := []uint64{2, 3, 4} - if !reflect.DeepEqual(res.ids, wantIDs) { - t.Fatalf("subscriber saw object IDs %v, want %v", res.ids, wantIDs) - } - if res.header.TrackAlias != subReq.OK.TrackAlias { - t.Fatalf("forwarded TrackAlias = %d, want %d", res.header.TrackAlias, subReq.OK.TrackAlias) - } - case <-time.After(2 * time.Second): - t.Fatal("subscriber did not drain within deadline") - } + requireFilterForwards(t, &message.LocationFilter{Fields: 2, StartGroup: 0, StartObject: 2}, 5, []uint64{2, 3, 4}) } // 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() - - pubSess, teardown := connectRelay(t, relay.Config{}) - defer teardown() - - const publisherAlias = uint64(7) - pubReqStream, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: ns("video"), - Name: []byte("cam1"), - TrackAlias: publisherAlias, - }) - if err != nil { - t.Fatalf("Publish: %v", err) - } - defer pubReqStream.Close() - - subSess := dialAnotherClient(t, pubSess) - subReq, err := subSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: ns("video"), - Name: []byte("cam1"), - Parameters: message.Parameters{ - message.LocationFilterParam( - &message.LocationFilter{Fields: 3, StartGroup: 0, StartObject: 1, EndGroupDelta: 0}, - ), - }, - }) - if err != nil { - t.Fatalf("Subscribe: %v", err) - } - defer subReq.Close() - - // Start the reader goroutine before issuing any writes — the relay's - // OpenSubgroup synchronously writes the downstream SUBGROUP_HEADER - // and blocks until the subscriber accepts the stream. - type readResult struct { - ids []uint64 - err error - } - resCh := make(chan readResult, 1) - go func() { - ds, err := subSess.AcceptDataStream(t.Context()) - if err != nil { - resCh <- readResult{err: err} - return - } - sg, ok := ds.(*session.IncomingSubgroupStream) - if !ok { - resCh <- readResult{err: errors.New("not a SubgroupStream")} - return - } - var ( - ids []uint64 - prev uint64 - haveFirst bool - ) - for { - obj, err := sg.ReadObject() - if err != nil { - resCh <- readResult{ids: ids, err: err} - return - } - var absID uint64 - if !haveFirst { - absID = obj.ObjectIDDelta - haveFirst = true - } else { - absID = prev + obj.ObjectIDDelta + 1 - } - prev = absID - ids = append(ids, absID) - } - }() - - sg0, err := pubSess.OpenSubgroup(message.SubgroupHeader{ - SubgroupIDMode: message.SubgroupIDExplicit, - TrackAlias: publisherAlias, - GroupID: 0, - SubgroupID: 0, - }) - if err != nil { - t.Fatalf("OpenSubgroup: %v", err) - } - for i := range 4 { - if err := sg0.WriteObject(&message.SubgroupObject{ - ObjectIDDelta: 0, - Payload: []byte{byte('A' + i)}, - }); err != nil { - t.Fatalf("WriteObject #%d: %v", i, err) - } - } - if err := sg0.Close(); err != nil { - t.Fatalf("sg0.Close: %v", err) - } - - select { - case res := <-resCh: - if !errors.Is(res.err, io.EOF) { - t.Fatalf("subscriber read ended with %v, want io.EOF", res.err) - } - wantIDs := []uint64{1, 2, 3} - if !reflect.DeepEqual(res.ids, wantIDs) { - t.Fatalf("subscriber saw object IDs %v, want %v", res.ids, wantIDs) - } - case <-time.After(2 * time.Second): - t.Fatal("subscriber did not drain within deadline") - } -} - -// TestSubscribe_InstallsPriorityAndGroupOrder: SUBSCRIBER_PRIORITY and -// GROUP_ORDER on a SUBSCRIBE are recorded on the downstream subscription. -func TestSubscribe_InstallsPriorityAndGroupOrder(t *testing.T) { - t.Parallel() - - pubSess, teardown := connectRelay(t, relay.Config{}) - defer teardown() - - pubReqStream, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: ns("video"), - Name: []byte("cam1"), - TrackAlias: 7, - }) - if err != nil { - t.Fatalf("Publish: %v", err) - } - defer pubReqStream.Close() - - subSess := dialAnotherClient(t, pubSess) - subReq, err := subSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: ns("video"), - Name: []byte("cam1"), - Parameters: message.Parameters{ - message.SubscriberPriorityParam(42), - message.GroupOrderParam(message.GroupOrderDescending), - }, - }) - if err != nil { - t.Fatalf("Subscribe: %v", err) - } - defer subReq.Close() - - // SUBSCRIBE_OK alone proves the relay accepted the parameters - // without rejecting them as malformed; the registry state is - // covered by the unit test on DownstreamSub setters. -} - -// 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() - - pubSess, teardown := connectRelay(t, relay.Config{}) - defer teardown() - - const publisherAlias = uint64(7) - pubReqStream, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: ns("video"), - Name: []byte("cam1"), - TrackAlias: publisherAlias, - }) - if err != nil { - t.Fatalf("Publish: %v", err) - } - defer pubReqStream.Close() - - 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() - - type readResult struct { - count int - err error - } - resCh := make(chan readResult, 1) - go func() { - ds, err := subSess.AcceptDataStream(t.Context()) - if err != nil { - resCh <- readResult{err: err} - return - } - sg, ok := ds.(*session.IncomingSubgroupStream) - if !ok { - resCh <- readResult{err: errors.New("not a SubgroupStream")} - return - } - count := 0 - for { - if _, err := sg.ReadObject(); err != nil { - resCh <- readResult{count: count, err: err} - return - } - count++ - } - }() - - pubSubgroup, err := pubSess.OpenSubgroup(message.SubgroupHeader{ - SubgroupIDMode: message.SubgroupIDExplicit, - TrackAlias: publisherAlias, - GroupID: 0, - SubgroupID: 0, - }) - if err != nil { - t.Fatalf("OpenSubgroup: %v", err) - } - - if err := pubSubgroup.WriteObject(&message.SubgroupObject{ - ObjectIDDelta: 0, - Payload: []byte("only"), - }); err != nil { - t.Fatalf("WriteObject: %v", err) - } - // Reset (not FIN) the inbound subgroup. The relay should detect the - // non-EOF read error and propagate the reset to the downstream - // subscriber. - pubSubgroup.Cancel(moqt.StreamResetCancelled) - - select { - case res := <-resCh: - if res.count < 1 { - t.Fatalf("subscriber received %d objects, want >=1 (the one written before the reset)", res.count) - } - if errors.Is(res.err, io.EOF) { - t.Fatal("subscriber stream ended with io.EOF (FIN); want a reset") - } - case <-time.After(2 * time.Second): - t.Fatal("subscriber did not see stream termination within deadline") - } + requireFilterForwards(t, + &message.LocationFilter{Fields: 3, StartGroup: 0, StartObject: 1, EndGroupDelta: 0}, 4, []uint64{1, 2, 3}) } // TestFanout_UpdatesTrackEntryLargestObject: each forwarded Object advances the @@ -702,206 +96,31 @@ func TestFanout_InboundResetCancelsDownstream(t *testing.T) { // LargestObject-filtered SUBSCRIBE. func TestFanout_UpdatesTrackEntryLargestObject(t *testing.T) { t.Parallel() - - pubSess, teardown := connectRelay(t, relay.Config{}) - defer teardown() - - const publisherAlias = uint64(7) - pubReqStream, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: ns("video"), - Name: []byte("cam1"), - TrackAlias: publisherAlias, - }) - if err != nil { - t.Fatalf("Publish: %v", err) - } - defer pubReqStream.Close() - - // Subscriber 1: drains everything the relay sends across the - // subscription's whole life. Crucially the goroutine loops over - // AcceptDataStream so the second subgroup (sent after we set up - // subscriber 2) doesn't deadlock on a missing acceptor. - 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) - - // Phase 1: publish absIDs 0 and 2 on group 4. After this the relay's - // TrackEntry.LargestObject must be {Group: 4, Object: 2}. Object 1 is - // left for phase 3: sending it twice with another Payload or Subgroup - // would make the track malformed (§9.1). - sg, err := pubSess.OpenSubgroup(message.SubgroupHeader{ - SubgroupIDMode: message.SubgroupIDExplicit, - TrackAlias: publisherAlias, - GroupID: 4, - SubgroupID: 0, - }) - if err != nil { - t.Fatalf("OpenSubgroup: %v", err) - } - for _, id := range []uint64{0, 2} { - if err := sg.WriteObjectAt(id, &message.SubgroupObject{Payload: []byte{byte('A' + id)}}); err != nil { - t.Fatalf("WriteObjectAt %d: %v", id, err) - } - } - if err := sg.Close(); err != nil { - t.Fatalf("sg.Close: %v", err) - } - - // Give the relay a moment to drain the inbound subgroup and update - // the TrackEntry watermark before we issue the FilterLargestObject - // SUBSCRIBE. Without this the snapshot might be taken before the - // fanout has finished processing the three objects. - deadline := time.Now().Add(2 * time.Second) - for time.Now().Before(deadline) { - // Best-effort wait — there's no public accessor for the - // entry's watermark; rely on a short sleep + retry loop in - // the subscribe step below. - time.Sleep(20 * time.Millisecond) - break - } - - // Phase 2: subscribe with FilterLargestObject. The relay's - // installSubscribeParams snapshots the entry watermark, so any - // object at a Location <= {4, 2} should be filtered out. - subSess2 := dialAnotherClient(t, pubSess) - subReq2, err := subSess2.Subscribe(t.Context(), &message.Subscribe{ - Namespace: ns("video"), - Name: []byte("cam1"), - Parameters: message.Parameters{ - message.LocationFilterParam(&message.LocationFilter{Fields: 2}), - }, - }) - if err != nil { - t.Fatalf("second Subscribe: %v", err) - } - defer subReq2.Close() - - // Sub2 reader: collect the absIDs of the objects that pass the - // filter, looping over streams so the relay's per-subgroup outbound - // open doesn't deadlock. - got2 := make(chan []uint64, 1) - go func() { - var ids []uint64 - for { - ds, err := subSess2.AcceptDataStream(t.Context()) - if err != nil { - got2 <- ids - return - } - sg, ok := ds.(*session.IncomingSubgroupStream) - if !ok { - continue - } - var ( - prev uint64 - have bool - ) - for { - obj, err := sg.ReadObject() - if err != nil { - break - } - var absID uint64 - if !have { - absID = obj.ObjectIDDelta - have = true - } else { - absID = prev + obj.ObjectIDDelta + 1 - } - prev = absID - ids = append(ids, absID) - } - } - }() - - // Phase 3: publish two more objects — one *below* the watermark - // (absID=1, which is < {4, 2}) and one *above* it (absID=3, which is - // > {4, 2}). FilterLargestObject must drop the below-watermark one - // and pass the above-watermark one. We send them on a fresh subgroup - // so the relay opens new streams (avoids interaction with subgroup 0 - // which already FIN'd). - sg2, err := pubSess.OpenSubgroup(message.SubgroupHeader{ - SubgroupIDMode: message.SubgroupIDExplicit, - TrackAlias: publisherAlias, - GroupID: 4, - SubgroupID: 1, - }) - if err != nil { - t.Fatalf("OpenSubgroup phase 3: %v", err) - } - // First object at absID=1 — wire delta=1. - if err := sg2.WriteObject(&message.SubgroupObject{ - ObjectIDDelta: 1, - Payload: []byte("below"), - }); err != nil { - t.Fatalf("WriteObject below: %v", err) - } - // Second object at absID=3 — wire delta = 3 - 1 - 1 = 1. - if err := sg2.WriteObject(&message.SubgroupObject{ - ObjectIDDelta: 1, - Payload: []byte("above"), - }); err != nil { - t.Fatalf("WriteObject above: %v", err) - } - if err := sg2.Close(); err != nil { - t.Fatalf("sg2.Close: %v", err) - } - - // Stop sub2's session to break its AcceptDataStream loop, then - // collect what it captured. - time.Sleep(100 * time.Millisecond) // let the relay deliver - subReq2.Close() - _ = subSess2.Close(0, "") - - select { - case ids := <-got2: - want := []uint64{3} - if !reflect.DeepEqual(ids, want) { - t.Fatalf( - "sub2 saw absIDs %v, want %v — watermark must have been {4, 2} so only 3 passes FilterLargestObject", - ids, - want, - ) - } - case <-time.After(2 * time.Second): - t.Fatal("sub2 collector did not finish within deadline") - } -} - -// 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. -func TestSubscribe_InvalidGroupOrderRejected(t *testing.T) { - t.Parallel() - pubSess, teardown := connectRelay(t, relay.Config{}) defer teardown() - - pubReqStream, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: ns("video"), - Name: []byte("cam1"), - TrackAlias: 7, - }) - if err != nil { - t.Fatalf("Publish: %v", err) + pub := publishVideoTrack(t, pubSess, "cam1", 7) + go drainAll(t.Context(), newCam1Subscriber(t, pubSess)) + + // Objects 0 and 2 of group 4 make the watermark {4, 2}. Object 1 is left + // for later: sending it twice with another Payload or Subgroup would make + // the track malformed (§9.1). + writeSubgroupObjects(t, pub, message.SubgroupHeader{SubgroupIDMode: message.SubgroupIDExplicit, GroupID: 4}, + []uint64{0, 2}) + waitRelayLargest(t, pubSess, ns("video"), []byte("cam1"), 4, 2) + + // installSubscribeParams snapshots the watermark for this filter, so of + // Object 1 (below {4, 2}) and Object 3 (above), only 3 passes. They go on + // a fresh subgroup, as subgroup 0 already ended. + sub2 := newCam1Subscriber(t, pubSess, message.LocationFilterParam(&message.LocationFilter{Fields: 2})) + reads := readNextSubgroup(t, sub2) + writeSubgroupObjects(t, pub, + message.SubgroupHeader{SubgroupIDMode: message.SubgroupIDExplicit, GroupID: 4, SubgroupID: 1}, + []uint64{1, 3}) + if r := awaitSubgroupRead(t, reads); !slices.Equal(r.ids, []uint64{3}) { + t.Fatalf("sub2 saw Objects %v, want [3]: the watermark must have been {4, 2} "+ + "so only 3 passes FilterLargestObject", r.ids) + } + if ds, ok := tryAcceptDataStream(t, sub2, 200*time.Millisecond); ok { + t.Fatalf("sub2 got a second data stream %T; only Object 3 passes FilterLargestObject", ds) } - defer pubReqStream.Close() - - subSess := dialAnotherClient(t, pubSess) - _, _ = subSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: ns("video"), - Name: []byte("cam1"), - Parameters: message.Parameters{ - message.ByteParam(message.ParamGroupOrder, 0x05), - }, - }) - - requireSessionClosed(t, subSess, "out-of-range GROUP_ORDER SUBSCRIBE (§10.2.8)") } diff --git a/pkg/relay/handler_fetch_session_test.go b/pkg/relay/handler_fetch_session_test.go index 4b53550e..b97c6a5f 100644 --- a/pkg/relay/handler_fetch_session_test.go +++ b/pkg/relay/handler_fetch_session_test.go @@ -1,6 +1,7 @@ package relay_test import ( + "bytes" "math" "testing" @@ -26,9 +27,9 @@ func TestFetch_RejectsUnknownTrack(t *testing.T) { requireRejectedWithCode(t, err, moqt.RequestDoesNotExist) } -// 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. +// TestTrackStatus_ReplyForKnownTrack: the Track Properties a PUBLISH carried +// are echoed byte for byte in TRACK_STATUS_OK to another session's +// TRACK_STATUS for the track. func TestTrackStatus_ReplyForKnownTrack(t *testing.T) { t.Parallel() pubSess, teardown := connectRelay(t, relay.Config{}) @@ -38,7 +39,7 @@ func TestTrackStatus_ReplyForKnownTrack(t *testing.T) { Namespace: ns("video"), Name: []byte("cam1"), TrackAlias: 1, - TrackProperties: []byte("rtp-h265"), + TrackProperties: opaqueProps("rtp-h265"), }) if err != nil { t.Fatalf("Publish: %v", err) @@ -55,8 +56,8 @@ func TestTrackStatus_ReplyForKnownTrack(t *testing.T) { } defer tsStream.Close() - if got := string(tsStream.OK.TrackProperties); got != "rtp-h265" { - t.Fatalf("TrackProperties = %q, want %q", got, "rtp-h265") + if got, want := tsStream.OK.TrackProperties, opaqueProps("rtp-h265"); !bytes.Equal(got, want) { + t.Fatalf("TrackProperties = %x, want %x", got, want) } } diff --git a/pkg/relay/harness_test.go b/pkg/relay/harness_test.go index 03f0a2ef..32c36ec3 100644 --- a/pkg/relay/harness_test.go +++ b/pkg/relay/harness_test.go @@ -2,6 +2,7 @@ package relay_test import ( "context" + "fmt" "net" "runtime" "slices" @@ -14,6 +15,7 @@ import ( "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" ) // The in-process relay harness: a [relay.Listener] over [sessiontest] pipes, @@ -315,3 +317,97 @@ func dialRaw(t *testing.T, l *pipeListener) (*session.Session, session.Conn) { t.Cleanup(func() { _ = sess.Close(moqt.SessionNoError, "") }) return sess, conn } + +// testRelay is a relay on its own pipeListener, for tests that wire several +// relays together through a Dialer or stop one mid-test. +type testRelay struct { + r *relay.Relay + l *pipeListener + addr string // cfg.RelayAddr + startErr chan error +} + +// startTestRelay starts a relay on its own pipeListener; its stop is the +// caller's. GoawayTimeout defaults to 50ms. +func startTestRelay(ctx context.Context, cfg relay.Config) *testRelay { + if cfg.GoawayTimeout == 0 { + cfg.GoawayTimeout = 50 * time.Millisecond + } + l := newPipeListener() + r := relay.New(l, cfg) + se := make(chan error, 1) + go func() { se <- r.Start(ctx) }() + return &testRelay{r: r, l: l, addr: cfg.RelayAddr, startErr: se} +} + +// stop stops the relay and requires Start to return cleanly. +func (tr *testRelay) stop(t *testing.T) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = tr.r.Stop(ctx) + tr.requireStartReturned(t) +} + +// requireStartReturned requires Start to return nil within 2s. +func (tr *testRelay) requireStartReturned(t *testing.T) { + t.Helper() + select { + case err := <-tr.startErr: + if err != nil { + t.Errorf("Start returned: %v", err) + } + case <-time.After(2 * time.Second): + t.Error("Start did not return after Stop") + } +} + +// dialClient connects a fresh client session into tr's listener. +func dialClient(t *testing.T, tr *testRelay) *session.Session { + t.Helper() + conn, err := tr.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) + } + return sess +} + +// dialerTo is a [relay.Config] Dialer reaching each of relays by its RelayAddr +// and failing any other address. onDial, when non-nil, sees every address it +// reaches. +func dialerTo(onDial func(addr string), relays ...*testRelay) func(context.Context, string) (session.Conn, error) { + return func(_ context.Context, addr string) (session.Conn, error) { + for _, tr := range relays { + if tr.addr == addr { + if onDial != nil { + onDial(addr) + } + return tr.l.Dial() + } + } + return nil, fmt.Errorf("no relay at %q", addr) + } +} + +// publishOnRelay dials a publisher into tr that advertises video, so Discovery +// routes the namespace to tr, and PUBLISHes video/ on alias. +func publishOnRelay(t *testing.T, tr *testRelay, name string, alias uint64) (*session.Session, *session.Publication) { + t.Helper() + sess := dialClient(t, tr) + publishNS(t, sess, "video") + return sess, publishVideoTrack(t, sess, name, alias) +} + +// startRelayPair starts relay-B, and relay-A dialling it for what the shared +// Discovery store routes there. +func startRelayPair(ctx context.Context, store discovery.DiscoveryStore) (relayA, relayB *testRelay) { + relayB = startTestRelay(ctx, relay.Config{Discovery: store, RelayAddr: "relay-B"}) + relayA = startTestRelay(ctx, relay.Config{ + Discovery: store, RelayAddr: "relay-A", Dialer: dialerTo(nil, relayB), + }) + return relayA, relayB +} diff --git a/pkg/relay/helpers_test.go b/pkg/relay/helpers_test.go index 2c701ffe..4b0b5066 100644 --- a/pkg/relay/helpers_test.go +++ b/pkg/relay/helpers_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "sync" "testing" "time" @@ -12,6 +13,8 @@ import ( "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" ) // Shared helpers for the relay_test package: they drive clients of a relay @@ -37,6 +40,12 @@ func dynamicGroupsProperties(value uint64) []byte { return message.AppendTrackProperties(trackProp(message.PropertyDynamicGroups, value)) } +// opaqueProps is well-formed Track Properties holding one property of a type +// the relay does not interpret, so it must pass through byte for byte. +func opaqueProps(v string) []byte { + return message.AppendTrackProperties([]wire.KVPair{{Type: 0x101, ByteVal: []byte(v)}}) +} + // Publishing. // publishVideoTrack PUBLISHes video/ on alias with the given Message @@ -168,6 +177,32 @@ func publishSubgroupWith(t *testing.T, pub *session.Publication, group uint64, o }() } +// writeSubgroupObjects opens one subgroup on the publisher and writes the +// given absolute object IDs (ascending), then FINs. +func writeSubgroupObjects(t *testing.T, pub *session.Publication, hdr message.SubgroupHeader, ids []uint64) { + t.Helper() + sg, err := pub.OpenSubgroup(hdr) + if err != nil { + t.Fatalf("OpenSubgroup: %v", err) + } + prev, has := uint64(0), false + for _, id := range ids { + obj := &message.SubgroupObject{Payload: []byte{byte('a' + id)}} + if !has { + obj.ObjectIDDelta = id + } else { + obj.ObjectIDDelta = id - prev - 1 + } + if err := sg.WriteObject(obj); err != nil { + t.Fatalf("WriteObject(%d): %v", id, err) + } + prev, has = id, true + } + if err := sg.Close(); err != nil { + t.Fatalf("subgroup Close: %v", err) + } +} + // 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. @@ -190,6 +225,63 @@ func openSubgroupWaiting( } } +// Namespaces. + +// 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...)}) + if err != nil { + t.Fatalf("PublishNamespace %v: %v", fields, err) + } + 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, + fields ...string, +) (*session.NamespaceSubscription, <-chan message.Message) { + t.Helper() + s, err := sess.SubscribeNamespace(t.Context(), &message.SubscribeNamespace{TrackNamespacePrefix: ns(fields...)}) + if err != nil { + t.Fatalf("SubscribeNamespace %v: %v", fields, err) + } + t.Cleanup(func() { _ = s.Close() }) + return s, streamMessages(t, s.Stream) +} + +// readvertise publishes info into store every 20ms until the returned stop, +// which waits for the last publish, is called (it also runs at cleanup). A +// relay's Discovery watch registers asynchronously in Start and MemoryStore +// does not replay history to new watchers, so one publish can go unseen. +func readvertise(t *testing.T, store *discovery.MemoryStore, info discovery.NamespaceInfo) (stop func()) { + t.Helper() + quit := make(chan struct{}) + exited := make(chan struct{}) + go func() { + defer close(exited) + tick := time.NewTicker(20 * time.Millisecond) + defer tick.Stop() + for { + _ = store.PublishNamespace(t.Context(), info) + select { + case <-quit: + return + case <-tick.C: + } + } + }() + stop = sync.OnceFunc(func() { + close(quit) + <-exited + }) + t.Cleanup(stop) + return stop +} + // Subscribing. // subscribeCam1 SUBSCRIBEs sess to video/cam1; the subscription closes at @@ -364,6 +456,117 @@ func tryAcceptDataStream(t *testing.T, sess *session.Session, d time.Duration) ( return ds, true } +// subgroupRead is one subgroup stream as a subscriber read it to its end. +type subgroupRead struct { + header message.SubgroupHeader + ids []uint64 // absolute Object IDs (§11.4.2) + payloads []string + end error // io.EOF for a FIN, otherwise a reset + err error // no subgroup stream was accepted +} + +// readNextSubgroup reads the next subgroup stream sess accepts to its end, off +// the test goroutine so it can start before the Objects are published. +func readNextSubgroup(t *testing.T, sess *session.Session) <-chan subgroupRead { + t.Helper() + out := make(chan subgroupRead, 1) + go func() { + ds, err := sess.AcceptDataStream(t.Context()) + if err != nil { + out <- subgroupRead{err: fmt.Errorf("AcceptDataStream: %w", err)} + return + } + in, ok := ds.(*session.IncomingSubgroupStream) + if !ok { + out <- subgroupRead{err: fmt.Errorf("AcceptDataStream = %T, want a subgroup stream", ds)} + return + } + r := subgroupRead{header: in.Header} + for { + o, err := in.ReadDecoded() + if err != nil { + r.end = err + out <- r + return + } + r.ids = append(r.ids, o.ObjectID) + r.payloads = append(r.payloads, string(o.Payload)) + } + }() + return out +} + +// awaitSubgroupRead waits up to 5s for [readNextSubgroup]'s stream to end. +func awaitSubgroupRead(t *testing.T, reads <-chan subgroupRead) subgroupRead { + t.Helper() + select { + case r := <-reads: + if r.err != nil { + t.Fatal(r.err) + } + return r + case <-time.After(5 * time.Second): + t.Fatal("no subgroup stream reached its end within 5s") + return subgroupRead{} + } +} + +// readUntilEnd reads the subscriber's next subgroup stream to its end and +// returns the Object IDs it carried and how it ended. +func readUntilEnd(t *testing.T, subSess *session.Session) (ids []uint64, end error) { + t.Helper() + r := awaitSubgroupRead(t, readNextSubgroup(t, subSess)) + return r.ids, r.end +} + +// objEvent is one Object, or the end of a stream (or of accepting), as +// [readSubgroups] emits it. +type objEvent struct { + stream int // 1-based index of the outbound stream it arrived on + absID uint64 // §11.4.2 delta resolved to an absolute Object ID + err error // non-nil marks a stream end (io.EOF = FIN, else reset) or accept error +} + +// 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 { + ds, err := sub.AcceptDataStream(ctx) + if err != nil { + out <- objEvent{err: err} + return + } + sg, ok := ds.(*session.IncomingSubgroupStream) + if !ok { + continue + } + streamIdx++ + idx := streamIdx + var ( + prev uint64 + have bool + ) + for { + obj, err := sg.ReadObject() + if err != nil { + out <- objEvent{stream: idx, err: err} + break + } + var absID uint64 + if !have { + absID = obj.ObjectIDDelta + have = true + } else { + absID = prev + obj.ObjectIDDelta + 1 + } + prev = absID + out <- objEvent{stream: idx, absID: absID} + } + } +} + // 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) { @@ -435,6 +638,37 @@ func isNamespaceDone(m message.Message) bool { return ok } +// requireQuiet fails if a message arrives on msgs within 300ms. +func requireQuiet(t *testing.T, msgs <-chan message.Message, what string) { + t.Helper() + select { + case m := <-msgs: + t.Fatalf("%s: unexpected %T %+v", what, m, m) + case <-time.After(300 * time.Millisecond): + } +} + +// 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) + 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) + } +} + +// 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) + d, ok := m.(*message.NamespaceDone) + if !ok || relaytest.FormatNamespace(d.TrackNamespaceSuffix) != relaytest.FormatNamespace(ns(suffix...)) { + t.Fatalf("got %T %+v, want NAMESPACE_DONE %v", m, m, suffix) + } +} + // 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 { diff --git a/pkg/relay/namespace_state_test.go b/pkg/relay/namespace_state_test.go index f1184efc..2ac2142c 100644 --- a/pkg/relay/namespace_state_test.go +++ b/pkg/relay/namespace_state_test.go @@ -21,63 +21,6 @@ import ( // 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 { - case m := <-msgs: - t.Fatalf("%s: unexpected %T %+v", what, m, m) - case <-time.After(300 * time.Millisecond): - } -} - -// 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) - 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) - } -} - -// 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) - d, ok := m.(*message.NamespaceDone) - if !ok || relaytest.FormatNamespace(d.TrackNamespaceSuffix) != relaytest.FormatNamespace(ns(suffix...)) { - t.Fatalf("got %T %+v, want NAMESPACE_DONE %v", m, m, suffix) - } -} - -// 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...)}) - if err != nil { - t.Fatalf("PublishNamespace %v: %v", fields, err) - } - 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, - fields ...string, -) (*session.NamespaceSubscription, <-chan message.Message) { - t.Helper() - s, err := sess.SubscribeNamespace(t.Context(), &message.SubscribeNamespace{TrackNamespacePrefix: ns(fields...)}) - if err != nil { - t.Fatalf("SubscribeNamespace %v: %v", fields, err) - } - t.Cleanup(func() { _ = s.Close() }) - return s, streamMessages(t, s.Stream) -} - // TestNamespace_SecondPublisherKeepsNamespaceAlive: two publishers of one // namespace announce it once, and it is done only when both have withdrawn. func TestNamespace_SecondPublisherKeepsNamespaceAlive(t *testing.T) { @@ -283,22 +226,9 @@ func TestNamespace_RemoteAndLocalSourcesShareOneAnnouncement(t *testing.T) { subSess := dialClient(t, relayA) _, msgs := subscribeNS(t, subSess, "video") remote := discovery.NamespaceInfo{Prefix: ns("video", "cam"), RelayAddr: "relay-C"} - // The watch starts asynchronously; re-advertise until it is seen. - stop := make(chan struct{}) - go func() { - tick := time.NewTicker(20 * time.Millisecond) - defer tick.Stop() - for { - _ = store.PublishNamespace(ctx, remote) - select { - case <-stop: - return - case <-tick.C: - } - } - }() + stop := readvertise(t, store, remote) requireNamespace(t, msgs, "cam") - close(stop) + stop() // A subscriber arriving now is seeded from the remote state, and counts // it as a source too. _, late := subscribeNS(t, dialClient(t, relayA), "video") diff --git a/pkg/relay/session_publish_test.go b/pkg/relay/session_publish_test.go new file mode 100644 index 00000000..185d9461 --- /dev/null +++ b/pkg/relay/session_publish_test.go @@ -0,0 +1,232 @@ +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/track" + "github.com/floatdrop/moq-go/pkg/relay" +) + +// Inbound PUBLISH, and the PUBLISH the relay forwards to SUBSCRIBE_TRACKS +// holders on a new bidi stream of their own (§6.1, §9.5, §10.20). + +// TestPublish_AcceptedAndRegistered: a PUBLISH is accepted, its request stream +// stays open, and closing it lets the handler exit cleanly. +func TestPublish_AcceptedAndRegistered(t *testing.T) { + t.Parallel() + clientSess, teardown := connectRelay(t, relay.Config{}) + defer teardown() + + stream, err := clientSess.Publish(t.Context(), &message.Publish{ + Namespace: ns("video"), + Name: []byte("cam1"), + TrackAlias: 1, + }) + if err != nil { + t.Fatalf("Publish: %v", err) + } + if err := stream.Close(); err != nil { + t.Fatalf("stream.Close: %v", err) + } +} + +// 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{}) + defer teardown() + + publishVideoTrack(t, clientSess, "cam1", 7) + _, err := clientSess.Publish(t.Context(), &message.Publish{ + Namespace: ns("video"), + Name: []byte("cam2"), + TrackAlias: 7, + }) + requireRejectedWithCode(t, err, moqt.RequestMalformedTrack) +} + +// 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{}) + defer teardown() + + publishVideoTrack(t, pubSess, "cam1", 42, message.LargestObjectParam(5, 9)) + subReq := subscribeCam1(t, dialAnotherClient(t, pubSess)) + + p, ok := subReq.OK.Parameters.Find(message.ParamLargestObject) + if !ok { + t.Fatalf("SUBSCRIBE_OK omitted LARGEST_OBJECT; the PUBLISH's value never "+ + "reached the entry (params=%v)", subReq.OK.Parameters) + } + if p.Group != 5 || p.Object != 9 { + t.Errorf("SUBSCRIBE_OK LARGEST_OBJECT = {%d,%d}, want {5,9}", p.Group, p.Object) + } +} + +// nextForwardedPublish accepts the next request the relay opens to sess, within +// 2s, and requires it to be a PUBLISH. +func nextForwardedPublish(t *testing.T, sess *session.Session) *message.Publish { + t.Helper() + ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) + defer cancel() + req, err := sess.AcceptRequest(ctx) + if err != nil { + t.Fatalf("AcceptRequest: %v", err) + } + pub, ok := req.First.(*message.Publish) + if !ok { + t.Fatalf("got %T, want *message.Publish", req.First) + } + return pub +} + +// forwardedCam7 has a SUBSCRIBE_TRACKS holder for video, sent with params, +// receive the PUBLISH the relay forwards when another client publishes +// video/cam7 rtp on alias 99. +func forwardedCam7(t *testing.T, params ...message.Parameter) *message.Publish { + t.Helper() + subSess, teardown := connectRelay(t, relay.Config{}) + t.Cleanup(teardown) + subscribeTracks(t, subSess, ns("video"), params...) + + publish(t, dialAnotherClient(t, subSess), &message.Publish{ + Namespace: ns("video", "cam7"), Name: []byte("rtp"), TrackAlias: 99, + }) + return nextForwardedPublish(t, subSess) +} + +// 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() + pub := forwardedCam7(t) + if string(pub.Name) != "rtp" { + t.Fatalf("forwarded Name = %q, want %q", pub.Name, "rtp") + } + // §11.1: the alias is per session, so the relay allocates its own on the + // subscriber's session (never 0) rather than copying the publisher's 99; + // see TestPublish_ForwardedAliasDoesNotCollide. + if pub.TrackAlias == 0 { + t.Fatal("forwarded TrackAlias is 0; want one allocated on the subscriber's session") + } + // §10.20.1: the SUBSCRIBE_TRACKS omitted FORWARD and GROUP_ORDER, so the + // forwarded PUBLISH carries neither (FORWARD defaults to 1, GROUP_ORDER to + // the publisher's preference). + if p, ok := pub.Parameters.Find(message.ParamForward); ok { + t.Errorf("forwarded FORWARD present (=%d), want omitted", p.Byte) + } + if p, ok := pub.Parameters.Find(message.ParamGroupOrder); ok { + t.Errorf("forwarded GROUP_ORDER present (=%d), want omitted", p.Byte) + } +} + +// TestPublish_ForwardsSubscribeTracksParams pins §10.20.1: the FORWARD +// (§10.2.18) and GROUP_ORDER (§10.2.8) parameters on a SUBSCRIBE_TRACKS are +// copied onto the PUBLISH the relay generates for that subscriber. +func TestPublish_ForwardsSubscribeTracksParams(t *testing.T) { + t.Parallel() + pub := forwardedCam7(t, message.ForwardParam(false), message.GroupOrderParam(message.GroupOrderDescending)) + if p, ok := pub.Parameters.Find(message.ParamForward); !ok || p.Byte != 0 { + t.Errorf("forwarded FORWARD = %d (present=%v), want 0", p.Byte, ok) + } + if p, ok := pub.Parameters.Find(message.ParamGroupOrder); !ok || + message.GroupOrder(p.Byte) != message.GroupOrderDescending { + t.Errorf("forwarded GROUP_ORDER = %d (present=%v), want Descending (0x2)", p.Byte, ok) + } +} + +// 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{}) + defer teardown() + publishVideoTrack(t, pub1, "cam1", 0) + + subSess := dialAnotherClient(t, pub1) + subReq := subscribeCam1(t, subSess) + subscribeTracks(t, subSess, ns("video")) + + publish(t, dialAnotherClient(t, pub1), &message.Publish{ + Namespace: ns("video", "cam7"), + Name: []byte("rtp"), + TrackAlias: subReq.OK.TrackAlias, // the alias the subscriber already holds for cam1 + }) + fwd := nextForwardedPublish(t, subSess) + // The alias registration AcceptPublish performs, without its REQUEST_OK + // write: the relay does not yet read its end of a forwarded PUBLISH stream, + // so on the unbuffered in-process pipe that write would never complete. + if err := subSess.RegisterInboundTrackAlias(fwd.TrackAlias, track.NewKey(fwd.Namespace, fwd.Name)); err != nil { + t.Fatalf("registering the forwarded PUBLISH's alias: %v", err) + } +} + +// TestSubscribeTracks_InvalidGroupOrderClosesSession pins §10.2.8: a +// SUBSCRIBE_TRACKS carrying a GROUP_ORDER outside {Ascending, Descending} is a +// session-level PROTOCOL_VIOLATION, so the relay closes the whole session. +func TestSubscribeTracks_InvalidGroupOrderClosesSession(t *testing.T) { + t.Parallel() + subSess, teardown := connectRelay(t, relay.Config{}) + defer teardown() + + _, _ = subSess.SubscribeTracks(t.Context(), &message.SubscribeTracks{ + TrackNamespacePrefix: ns("video"), + Parameters: message.Parameters{ + message.GroupOrderParam(message.GroupOrder(0x07)), // out of range + }, + }) + requireSessionClosed(t, subSess, "out-of-range GROUP_ORDER SUBSCRIBE_TRACKS (§10.2.8)") +} + +// 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{}) + defer teardown() + + tns := ns("video", "cam7") + publish(t, pubA, &message.Publish{ + Namespace: tns, Name: []byte("rtp"), TrackAlias: 99, + Parameters: message.Parameters{message.LargestObjectParam(9, 9)}, + }) + publish(t, dialAnotherClient(t, pubA), &message.Publish{ + Namespace: tns, Name: []byte("rtp"), TrackAlias: 100, + Parameters: message.Parameters{message.LargestObjectParam(3, 4)}, + }) + + // Existing tracks are forwarded when the SUBSCRIBE_TRACKS arrives (§10.20). + subSess := dialAnotherClient(t, pubA) + subscribeTracks(t, subSess, ns("video")) + pub := nextForwardedPublish(t, subSess) + + // Exactly one LARGEST_OBJECT: the upstream's copy is stripped and the + // entry's is appended, so a duplicate would mean the strip broke. + var seen int + for _, p := range pub.Parameters { + if p.Type != message.ParamLargestObject { + continue + } + seen++ + if p.Group != 9 || p.Object != 9 { + t.Errorf("forwarded LARGEST_OBJECT = {%d,%d}, want {9,9} — the relay "+ + "advertised publisher B's lower value instead of its own maximum", + p.Group, p.Object) + } + } + if seen != 1 { + t.Errorf("forwarded PUBLISH carried %d LARGEST_OBJECT parameters, want exactly 1", seen) + } +} diff --git a/pkg/relay/session_pubsub_test.go b/pkg/relay/session_pubsub_test.go deleted file mode 100644 index a3a59493..00000000 --- a/pkg/relay/session_pubsub_test.go +++ /dev/null @@ -1,1076 +0,0 @@ -package relay_test - -import ( - "bytes" - "errors" - "fmt" - "io" - "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/track" - "github.com/floatdrop/moq-go/pkg/moqt/wire" - "github.com/floatdrop/moq-go/pkg/relay" -) - -// TestPublish_AcceptedAndRegistered drives a single PUBLISH through the relay -// and verifies REQUEST_OK comes back, the request stream stays open, and -// closing it lets the handler exit cleanly. -func TestPublish_AcceptedAndRegistered(t *testing.T) { - t.Parallel() - clientSess, teardown := connectRelay(t, relay.Config{}) - defer teardown() - - stream, err := clientSess.Publish(t.Context(), &message.Publish{ - Namespace: ns("video"), - Name: []byte("cam1"), - TrackAlias: 1, - }) - if err != nil { - t.Fatalf("Publish: %v", err) - } - if err := stream.Close(); err != nil { - t.Fatalf("stream.Close: %v", err) - } -} - -// 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{}) - defer teardown() - - _, err := clientSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: ns("video"), - Name: []byte("cam1"), - }) - requireRejectedWithCode(t, err, moqt.RequestDoesNotExist) -} - -// 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{}) - defer teardown() - - pubStream, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: ns("video"), - Name: []byte("cam1"), - TrackAlias: 42, - TrackProperties: opaqueProps("hello props"), - }) - if err != nil { - t.Fatalf("Publish: %v", err) - } - defer pubStream.Close() - - subSess := dialAnotherClient(t, pubSess) - - subStream, err := subSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: ns("video"), - Name: []byte("cam1"), - }) - if err != nil { - t.Fatalf("Subscribe: %v", err) - } - defer subStream.Close() - - if subStream.OK == nil { - t.Fatal("SubscribeOK is nil") - } - // §9.6 — properties must be echoed back. The relay treats them - // opaquely so the bytes round-trip verbatim. - if got, want := subStream.OK.TrackProperties, opaqueProps("hello props"); !bytes.Equal(got, want) { - t.Fatalf("TrackProperties = %x, want %x", got, want) - } - // The relay's outbound alias for the subscriber's session is - // independent of the publisher's alias (§11.1). We don't check - // its value, only that it was allocated (i.e. monotonic — the - // session starts at 0). - if subStream.OK.TrackAlias == 42 { - // Coincidence is allowed but extremely unlikely on a fresh - // session whose AllocOutboundTrackAlias started at 0. - t.Logf("note: subscriber alias happened to equal publisher alias (%d)", subStream.OK.TrackAlias) - } -} - -// 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{}) - defer teardown() - - subStream, err := subSess.SubscribeTracks(t.Context(), &message.SubscribeTracks{ - TrackNamespacePrefix: ns("video"), - }) - if err != nil { - t.Fatalf("SubscribeTracks: %v", err) - } - defer subStream.Close() - - pubSess := dialAnotherClient(t, subSess) - - pubStream, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: ns("video", "cam7"), - Name: []byte("rtp"), - TrackAlias: 99, - }) - if err != nil { - t.Fatalf("Publish: %v", err) - } - defer pubStream.Close() - - // The forwarded PUBLISH arrives as a fresh inbound request on the - // subscriber session. - req, err := subSess.AcceptRequest(t.Context()) - if err != nil { - t.Fatalf("AcceptRequest: %v", err) - } - pub, ok := req.First.(*message.Publish) - if !ok { - t.Fatalf("got %T, want *message.Publish", req.First) - } - if string(pub.Name) != "rtp" { - t.Fatalf("forwarded Name = %q, want %q", pub.Name, "rtp") - } - if pub.TrackAlias == 0 { - // §11.1: the alias is per session, so the relay allocates its own on - // the subscriber's session (never 0, see AllocOutboundTrackAlias) - // rather than copying the publisher's 99 — see - // TestPublish_ForwardedAliasDoesNotCollide. - t.Fatal("forwarded TrackAlias is 0; want one allocated on the subscriber's session") - } - // §10.20.1: the SUBSCRIBE_TRACKS omitted FORWARD and GROUP_ORDER, so the - // forwarded PUBLISH carries neither (FORWARD defaults to 1, GROUP_ORDER to - // the publisher's preference). - if p, ok := pub.Parameters.Find(message.ParamForward); ok { - t.Errorf("forwarded FORWARD present (=%d), want omitted", p.Byte) - } - if p, ok := pub.Parameters.Find(message.ParamGroupOrder); ok { - t.Errorf("forwarded GROUP_ORDER present (=%d), want omitted", p.Byte) - } -} - -// 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{}) - defer teardown() - pub1Req, err := pub1.Publish(t.Context(), &message.Publish{ - Namespace: ns("video"), - Name: []byte("cam1"), - }) - if err != nil { - t.Fatalf("Publish cam1: %v", err) - } - defer pub1Req.Close() - - subSess := dialAnotherClient(t, pub1) - subReq, err := subSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: ns("video"), - Name: []byte("cam1"), - }) - if err != nil { - t.Fatalf("Subscribe cam1: %v", err) - } - defer subReq.Close() - - tracksReq, err := subSess.SubscribeTracks(t.Context(), &message.SubscribeTracks{ - TrackNamespacePrefix: ns("video"), - }) - if err != nil { - t.Fatalf("SubscribeTracks: %v", err) - } - defer tracksReq.Close() - - pub2 := dialAnotherClient(t, pub1) - pub2Req, err := pub2.Publish(t.Context(), &message.Publish{ - Namespace: ns("video", "cam7"), - Name: []byte("rtp"), - TrackAlias: subReq.OK.TrackAlias, // the alias the subscriber already holds for cam1 - }) - if err != nil { - t.Fatalf("Publish rtp: %v", err) - } - defer pub2Req.Close() - - req, err := subSess.AcceptRequest(t.Context()) - if err != nil { - t.Fatalf("AcceptRequest: %v", err) - } - fwd, ok := req.First.(*message.Publish) - if !ok { - t.Fatalf("got %T, want *message.Publish", req.First) - } - // The alias registration AcceptPublish performs, without its REQUEST_OK - // write: the relay does not yet read its end of a forwarded PUBLISH stream, - // so on the unbuffered in-process pipe that write would never complete. - if err := subSess.RegisterInboundTrackAlias(fwd.TrackAlias, track.NewKey(fwd.Namespace, fwd.Name)); err != nil { - t.Fatalf("registering the forwarded PUBLISH's alias: %v", err) - } -} - -// TestPublish_ForwardsSubscribeTracksParams pins §10.20.1: the FORWARD -// (§10.2.18) and GROUP_ORDER (§10.2.8) parameters on a SUBSCRIBE_TRACKS are -// copied onto the PUBLISH the relay generates for that subscriber. -func TestPublish_ForwardsSubscribeTracksParams(t *testing.T) { - t.Parallel() - subSess, teardown := connectRelay(t, relay.Config{}) - defer teardown() - - subStream, err := subSess.SubscribeTracks(t.Context(), &message.SubscribeTracks{ - TrackNamespacePrefix: ns("video"), - Parameters: message.Parameters{ - message.ForwardParam(false), - message.GroupOrderParam(message.GroupOrderDescending), - }, - }) - if err != nil { - t.Fatalf("SubscribeTracks: %v", err) - } - defer subStream.Close() - - pubSess := dialAnotherClient(t, subSess) - pubStream, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: ns("video", "cam7"), - Name: []byte("rtp"), - TrackAlias: 99, - }) - if err != nil { - t.Fatalf("Publish: %v", err) - } - defer pubStream.Close() - - req, err := subSess.AcceptRequest(t.Context()) - if err != nil { - t.Fatalf("AcceptRequest: %v", err) - } - pub, ok := req.First.(*message.Publish) - if !ok { - t.Fatalf("got %T, want *message.Publish", req.First) - } - if p, ok := pub.Parameters.Find(message.ParamForward); !ok || p.Byte != 0 { - t.Errorf("forwarded FORWARD = %d (present=%v), want 0", p.Byte, ok) - } - if p, ok := pub.Parameters.Find(message.ParamGroupOrder); !ok || - message.GroupOrder(p.Byte) != message.GroupOrderDescending { - t.Errorf("forwarded GROUP_ORDER = %d (present=%v), want Descending (0x2)", p.Byte, ok) - } -} - -// TestSubscribeTracks_InvalidGroupOrderClosesSession pins §10.2.8: a -// SUBSCRIBE_TRACKS carrying a GROUP_ORDER outside {Ascending, Descending} is a -// session-level PROTOCOL_VIOLATION, so the relay closes the whole session. -func TestSubscribeTracks_InvalidGroupOrderClosesSession(t *testing.T) { - t.Parallel() - subSess, teardown := connectRelay(t, relay.Config{}) - defer teardown() - - _, _ = subSess.SubscribeTracks(t.Context(), &message.SubscribeTracks{ - TrackNamespacePrefix: ns("video"), - Parameters: message.Parameters{ - message.GroupOrderParam(message.GroupOrder(0x07)), // out of range - }, - }) - - requireSessionClosed(t, subSess, "out-of-range GROUP_ORDER SUBSCRIBE_TRACKS (§10.2.8)") -} - -// 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{}) - defer teardown() - - stream1, err := clientSess.Publish(t.Context(), &message.Publish{ - Namespace: ns("video"), - Name: []byte("cam1"), - TrackAlias: 7, - }) - if err != nil { - t.Fatalf("first Publish: %v", err) - } - defer stream1.Close() - - _, err = clientSess.Publish(t.Context(), &message.Publish{ - Namespace: ns("video"), - Name: []byte("cam2"), - TrackAlias: 7, - }) - requireRejectedWithCode(t, err, moqt.RequestMalformedTrack) -} - -// 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{}) - defer teardown() - - // Publisher advertises ("video",) via PUBLISH_NAMESPACE, then runs a - // goroutine that accepts the upstream SUBSCRIBE the relay will issue - // and replies SUBSCRIBE_OK. - pubNSStream, err := pubSess.PublishNamespace(t.Context(), &message.PublishNamespace{ - Namespace: ns("video"), - }) - if err != nil { - t.Fatalf("PublishNamespace: %v", err) - } - defer pubNSStream.Close() - - pubResponded := make(chan struct{}) - go func() { - defer close(pubResponded) - req, err := pubSess.AcceptRequest(t.Context()) - if err != nil { - t.Errorf("publisher AcceptRequest: %v", err) - return - } - sub, ok := req.First.(*message.Subscribe) - if !ok { - t.Errorf("publisher received %T, want *message.Subscribe", req.First) - return - } - if string(sub.Name) != "cam1" { - t.Errorf("publisher upstream SUBSCRIBE name = %q", sub.Name) - } - if err := req.Reply(&message.SubscribeOK{ - TrackAlias: 77, - TrackProperties: opaqueProps("upstream props"), - }); err != nil { - t.Errorf("publisher SubscribeOK reply: %v", err) - return - } - }() - - subSess := dialAnotherClient(t, pubSess) - - subStream, err := subSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: ns("video"), - Name: []byte("cam1"), - }) - if err != nil { - t.Fatalf("Subscribe: %v", err) - } - defer subStream.Close() - - <-pubResponded - - // §9.6: Track Properties must be echoed back. The relay captured them - // from the upstream SUBSCRIBE_OK and replays them on the downstream - // reply. - if got, want := subStream.OK.TrackProperties, opaqueProps("upstream props"); !bytes.Equal(got, want) { - t.Fatalf("downstream TrackProperties = %x, want %x", got, want) - } -} - -// 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) - go func() { - req, err := pubSess.AcceptRequest(t.Context()) - if err != nil { - t.Errorf("publisher AcceptRequest: %v", err) - return - } - sub, ok := req.First.(*message.Subscribe) - if !ok { - t.Errorf("publisher received %T, want *message.Subscribe", req.First) - return - } - present := 0 - val := 0 - if p, ok := sub.Parameters.Find(message.ParamForward); ok { - present = 1 - val = int(p.Byte) - } - out <- [2]int{val, present} - if err := req.Reply(&message.SubscribeOK{TrackAlias: 77}); err != nil { - t.Errorf("publisher SubscribeOK reply: %v", err) - return - } - for { - if _, err := message.Parse(req.Stream); err != nil { - return - } - } - }() - return out -} - -// TestSubscribe_UpstreamForwardPausedWhenDownstreamForwardZero pins §9.2: when -// the only downstream subscriber sets Forward=0, the relay exercises its -// discretion and pauses the upstream with an explicit FORWARD=0. -func TestSubscribe_UpstreamForwardPausedWhenDownstreamForwardZero(t *testing.T) { - t.Parallel() - pubSess, teardown := connectRelay(t, relay.Config{}) - defer teardown() - - pubNSStream, err := pubSess.PublishNamespace(t.Context(), &message.PublishNamespace{ - Namespace: ns("video"), - }) - if err != nil { - t.Fatalf("PublishNamespace: %v", err) - } - defer pubNSStream.Close() - - fwd := upstreamForwardValue(t, pubSess) - - subSess := dialAnotherClient(t, pubSess) - subStream, err := subSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: ns("video"), - Name: []byte("cam1"), - Parameters: message.Parameters{message.ForwardParam(false)}, - }) - if err != nil { - t.Fatalf("Subscribe: %v", err) - } - defer subStream.Close() - - got := <-fwd - if got != [2]int{0, 1} { - t.Fatalf("upstream FORWARD = {val:%d present:%d}, want {0, 1} (explicit Forward=0)", got[0], got[1]) - } -} - -// TestSubscribe_UpstreamForwardOmittedWhenDownstreamForwards pins §9.2: when a -// downstream subscriber wants forwarding (FORWARD omitted → default 1), the -// relay's upstream SUBSCRIBE omits FORWARD too (implicit 1). -func TestSubscribe_UpstreamForwardOmittedWhenDownstreamForwards(t *testing.T) { - t.Parallel() - pubSess, teardown := connectRelay(t, relay.Config{}) - defer teardown() - - pubNSStream, err := pubSess.PublishNamespace(t.Context(), &message.PublishNamespace{ - Namespace: ns("video"), - }) - if err != nil { - t.Fatalf("PublishNamespace: %v", err) - } - defer pubNSStream.Close() - - fwd := upstreamForwardValue(t, pubSess) - - subSess := dialAnotherClient(t, pubSess) - subStream, err := subSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: ns("video"), - Name: []byte("cam1"), - }) - if err != nil { - t.Fatalf("Subscribe: %v", err) - } - defer subStream.Close() - - got := <-fwd - if got[1] != 0 { - t.Fatalf("upstream FORWARD present (=%d), want omitted (implicit 1)", got[0]) - } -} - -// 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{}) - defer teardown() - - pubNSStream, err := pubSess.PublishNamespace(t.Context(), &message.PublishNamespace{ - Namespace: ns("video"), - }) - if err != nil { - t.Fatalf("PublishNamespace: %v", err) - } - defer pubNSStream.Close() - - // Publisher: accept the upstream SUBSCRIBE (expect explicit Forward=0), - // reply SUBSCRIBE_OK, then read the resume REQUEST_UPDATE (expect Forward=1) - // and answer it with REQUEST_OK. - type result struct { - initialForward int - resumeForward int - initialHasParam bool - } - res := make(chan result, 1) - go func() { - req, err := pubSess.AcceptRequest(t.Context()) - if err != nil { - t.Errorf("publisher AcceptRequest: %v", err) - return - } - sub, ok := req.First.(*message.Subscribe) - if !ok { - t.Errorf("publisher received %T, want *message.Subscribe", req.First) - return - } - var r result - if p, ok := sub.Parameters.Find(message.ParamForward); ok { - r.initialHasParam = true - r.initialForward = int(p.Byte) - } - if err := req.Reply(&message.SubscribeOK{TrackAlias: 77}); err != nil { - t.Errorf("publisher SubscribeOK: %v", err) - return - } - m, err := message.Parse(req.Stream) - if err != nil { - t.Errorf("publisher read follow-up: %v", err) - return - } - upd, ok := m.(*message.RequestUpdate) - if !ok { - t.Errorf("publisher follow-up = %T, want *message.RequestUpdate", m) - return - } - if p, ok := upd.Parameters.Find(message.ParamForward); ok { - r.resumeForward = int(p.Byte) - } - // Answer the §10.9 REQUEST_UPDATE so the relay's resume Update() call - // completes rather than timing out. - if err := message.Marshal(req.Stream, &message.RequestOK{}); err != nil { - t.Errorf("publisher REQUEST_OK: %v", err) - } - res <- r - }() - - // Subscriber A (Forward=0) establishes the paused upstream. - subA := dialAnotherClient(t, pubSess) - subAStream, err := subA.Subscribe(t.Context(), &message.Subscribe{ - Namespace: ns("video"), - Name: []byte("cam1"), - Parameters: message.Parameters{message.ForwardParam(false)}, - }) - if err != nil { - t.Fatalf("subscriber A Subscribe: %v", err) - } - defer subAStream.Close() - - // 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: ns("video"), - Name: []byte("cam1"), - }) - if err != nil { - t.Fatalf("subscriber B Subscribe: %v", err) - } - defer subBStream.Close() - - select { - case got := <-res: - if !got.initialHasParam || got.initialForward != 0 { - t.Errorf("upstream initial FORWARD = {val:%d present:%v}, want explicit 0", - got.initialForward, got.initialHasParam) - } - if got.resumeForward != 1 { - t.Errorf("upstream resume REQUEST_UPDATE FORWARD = %d, want 1", got.resumeForward) - } - case <-time.After(5 * time.Second): - t.Fatal("publisher did not observe the §9.2 upstream resume REQUEST_UPDATE") - } -} - -// 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{}) - go func() { - defer close(ended) - req, err := pubSess.AcceptRequest(t.Context()) - if err != nil { - return - } - if err := req.Reply(&message.SubscribeOK{TrackAlias: alias}); err != nil { - t.Errorf("publisher SubscribeOK reply: %v", err) - return - } - for { - if _, err := message.Parse(req.Stream); err != nil { - return - } - } - }() - return ended -} - -// 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{} - pubSess, teardown := connectRelay(t, relay.Config{Metrics: closed}) - defer teardown() - - pubNSStream, err := pubSess.PublishNamespace(t.Context(), &message.PublishNamespace{ - Namespace: ns("video"), - }) - if err != nil { - t.Fatalf("PublishNamespace: %v", err) - } - defer pubNSStream.Close() - - const upstreamAlias = uint64(77) - acceptUpstreamSubscribe(t, pubSess, upstreamAlias) - - subA := dialAnotherClient(t, pubSess) - subAStream, err := subA.Subscribe(t.Context(), &message.Subscribe{ - Namespace: ns("video"), - Name: []byte("cam1"), - }) - if err != nil { - t.Fatalf("subscriber A Subscribe: %v", err) - } - defer subAStream.Close() - - subB := dialAnotherClient(t, pubSess) - subBStream, err := subB.Subscribe(t.Context(), &message.Subscribe{ - Namespace: ns("video"), - Name: []byte("cam1"), - }) - if err != nil { - t.Fatalf("subscriber B Subscribe: %v", err) - } - defer subBStream.Close() - - // A leaves entirely. The relay must NOT tear the upstream down — B - // still depends on it. Wait until the relay has actually evicted A's - // subscription (SubscriptionClosed fires in handleSubscribe's defer) - // so the publish below exercises the post-removal state. - _ = subA.Close(0, "subscriber A leaving") - waitFor(t, 2*time.Second, func() bool { return closed.subsClosed.Load() >= 1 }, - "relay never evicted subscriber A's subscription") - - // B must still be able to receive: publish one object upstream and - // expect it on B's data path. B's acceptor starts FIRST — the in-process - // pipes are unbuffered, so the relay's fanout write to B completes only - // once B reads. - got := make(chan string, 1) - go func() { - ds, err := subB.AcceptDataStream(t.Context()) - if err != nil { - return - } - sgIn, ok := ds.(*session.IncomingSubgroupStream) - if !ok { - return - } - obj, err := sgIn.ReadObject() - if err != nil { - return - } - got <- string(obj.Payload) - }() - - sg, err := pubSess.OpenSubgroup(message.SubgroupHeader{ - SubgroupIDMode: message.SubgroupIDExplicit, - TrackAlias: upstreamAlias, - GroupID: 0, - SubgroupID: 0, - }) - if err != nil { - t.Fatalf("OpenSubgroup: %v", err) - } - if err := sg.WriteObject(&message.SubgroupObject{Payload: []byte("alive")}); err != nil { - t.Fatalf("WriteObject: %v", err) - } - - select { - case payload := <-got: - if payload != "alive" { - t.Fatalf("subscriber B got %q, want %q", payload, "alive") - } - case <-time.After(2 * time.Second): - t.Fatal("subscriber B received nothing after A left — upstream was torn down with A") - } -} - -// 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{}) - defer teardown() - - pubNSStream, err := pubSess.PublishNamespace(t.Context(), &message.PublishNamespace{ - Namespace: ns("video"), - }) - if err != nil { - t.Fatalf("PublishNamespace: %v", err) - } - defer pubNSStream.Close() - - subscriptionEnded := acceptUpstreamSubscribe(t, pubSess, 77) - - subSess := dialAnotherClient(t, pubSess) - subStream, err := subSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: ns("video"), - Name: []byte("cam1"), - }) - if err != nil { - t.Fatalf("Subscribe: %v", err) - } - - // The only downstream unsubscribes (FINs its request stream). The relay - // must propagate the teardown upstream. - _ = subStream.Close() - - select { - case <-subscriptionEnded: - case <-time.After(2 * time.Second): - t.Fatal("publisher's subscription still open 2s after the last downstream left") - } -} - -// 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{}) - defer teardown() - - pubNSStream, err := pubSess.PublishNamespace(t.Context(), &message.PublishNamespace{ - Namespace: ns("video"), - }) - if err != nil { - t.Fatalf("PublishNamespace: %v", err) - } - defer pubNSStream.Close() - - subSess := dialAnotherClient(t, pubSess) - _, err = subSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: ns("audio"), // no publisher for this namespace - Name: []byte("mic"), - }) - requireRejectedWithCode(t, err, moqt.RequestDoesNotExist) -} - -// 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 { - upstream, want moqt.RequestErrorCode - retry uint64 - }{ - {moqt.RequestDoesNotExist, moqt.RequestDoesNotExist, 0}, - {moqt.RequestExcessiveLoad, moqt.RequestExcessiveLoad, 501}, - {moqt.RequestTimeout, moqt.RequestTimeout, 1}, - {moqt.RequestMalformedTrack, moqt.RequestMalformedTrack, 0}, - {moqt.RequestUnauthorized, moqt.RequestInternalError, 0}, - {moqt.RequestExpiredAuthToken, moqt.RequestInternalError, 2001}, - {moqt.RequestGoingAway, moqt.RequestInternalError, 0}, - {moqt.RequestInvalidRange, moqt.RequestInternalError, 0}, - {moqt.RequestErrorCode(0x7777), moqt.RequestInternalError, 31}, - } { - t.Run(fmt.Sprintf("%#x", uint64(tc.upstream)), func(t *testing.T) { - t.Parallel() - pubSess, teardown := connectRelay(t, relay.Config{}) - defer teardown() - - pubNSStream, err := pubSess.PublishNamespace(t.Context(), &message.PublishNamespace{ - Namespace: ns("video"), - }) - if err != nil { - t.Fatalf("PublishNamespace: %v", err) - } - defer pubNSStream.Close() - - go func() { - req, err := pubSess.AcceptRequest(t.Context()) - if err != nil { - return - } - _ = req.Reject(&session.RequestRejectedError{ - Code: tc.upstream, Reason: "upstream says no", RetryInterval: tc.retry, - }) - }() - - subSess := dialAnotherClient(t, pubSess) - _, err = subSess.Subscribe(t.Context(), &message.Subscribe{ - Namespace: ns("video"), - Name: []byte("cam1"), - }) - requireRejectedWithCode(t, err, tc.want) - if rej, _ := errors.AsType[*session.RequestRejectedError](err); rej.RetryInterval != tc.retry { - t.Fatalf("downstream Retry Interval %d, want the upstream's %d", rej.RetryInterval, tc.retry) - } - }) - } -} - -// 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{}) - defer teardown() - - pubReq, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: ns("video"), - Name: []byte("cam1"), - TrackAlias: 1, - }) - if err != nil { - t.Fatalf("Publish: %v", err) - } - defer pubReq.Close() - - 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() - - // Tear down the publisher session. The relay's per-session - // cleanup runs TrackRegistry.RemoveSession, which detects that - // the track has no remaining upstream publisher and writes - // PUBLISH_DONE on every dependent downstream's request stream. - _ = pubSess.Close(0, "publisher leaving") - - done := make(chan message.Message, 1) - go func() { - msg, _ := message.Parse(subReq) - done <- msg - }() - select { - case msg := <-done: - pd, ok := msg.(*message.PublishDone) - if !ok { - t.Fatalf("got %T, want *message.PublishDone", msg) - } - if pd.StatusCode != moqt.PublishDoneTrackEnded { - t.Errorf("PublishDone.StatusCode = %v, want PublishDoneTrackEnded", pd.StatusCode) - } - case <-time.After(2 * time.Second): - t.Fatal("subscriber did not see PUBLISH_DONE within 2s of publisher leaving") - } -} - -// 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{}) - defer teardown() - - pubReq, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: ns("video"), - Name: []byte("cam1"), - TrackAlias: 1, - }) - if err != nil { - t.Fatalf("Publish: %v", err) - } - defer pubReq.Close() - - 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() - - _ = pubSess.Close(0, "publisher leaving") - - // First message: PUBLISH_DONE. - first, err := message.Parse(subReq) - if err != nil { - t.Fatalf("Parse #1: %v", err) - } - if _, ok := first.(*message.PublishDone); !ok { - t.Fatalf("first message = %T, want *message.PublishDone", first) - } - - // Subsequent Parse should hit EOF — the relay FIN'd the stream - // right after PUBLISH_DONE. - if _, err := message.Parse(subReq); err == nil { - t.Fatal("second Parse returned nil error; expected EOF after FIN") - } else if !errors.Is(err, io.EOF) { - // Some transports surface FIN as a different sentinel - // (pipe-closed, etc.). Accept anything non-nil as long as - // the parse path didn't succeed. - t.Logf("second Parse returned %v (acceptable non-nil error after FIN)", err) - } -} - -// 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{}) - defer teardown() - - // The client publishes its own track, taking inbound alias 0. - pubStream, err := clientSess.Publish(t.Context(), &message.Publish{ - Namespace: ns("room", "self"), - Name: []byte("video"), - TrackAlias: 0, - }) - if err != nil { - t.Fatalf("Publish own track: %v", err) - } - defer pubStream.Close() - - // A peer publishes a track the client will subscribe to. - peerSess := dialAnotherClient(t, clientSess) - peerStream, err := peerSess.Publish(t.Context(), &message.Publish{ - Namespace: ns("room", "peer"), - Name: []byte("video"), - TrackAlias: 0, - }) - if err != nil { - t.Fatalf("peer Publish: %v", err) - } - defer peerStream.Close() - - // Subscribing to the peer's track on the same session that already - // 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: ns("room", "peer"), - Name: []byte("video"), - }) - if err != nil { - t.Fatalf("Subscribe to peer track (alias collision regression): %v", err) - } - defer subStream.Close() -} - -// 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{}) - defer teardown() - - video := ns("video") - pubStream, err := pubSess.Publish(t.Context(), &message.Publish{ - Namespace: video, - Name: []byte("cam1"), - TrackAlias: 42, - Parameters: message.Parameters{message.LargestObjectParam(5, 9)}, - }) - if err != nil { - t.Fatalf("Publish: %v", err) - } - defer pubStream.Close() - - subSess := dialAnotherClient(t, pubSess) - subReq, err := subSess.Subscribe(t.Context(), &message.Subscribe{Namespace: video, Name: []byte("cam1")}) - if err != nil { - t.Fatalf("Subscribe: %v", err) - } - defer subReq.Close() - - p, ok := subReq.OK.Parameters.Find(message.ParamLargestObject) - if !ok { - t.Fatalf("SUBSCRIBE_OK omitted LARGEST_OBJECT; the PUBLISH's value never "+ - "reached the entry (params=%v)", subReq.OK.Parameters) - } - if p.Group != 5 || p.Object != 9 { - t.Errorf("SUBSCRIBE_OK LARGEST_OBJECT = {%d,%d}, want {5,9}", p.Group, p.Object) - } -} - -// 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{}) - defer teardown() - - tns := ns("video", "cam7") - - // First publisher sets the entry's watermark to {9,9}. - pubStreamA, err := pubA.Publish(t.Context(), &message.Publish{ - Namespace: tns, - Name: []byte("rtp"), - TrackAlias: 99, - Parameters: message.Parameters{message.LargestObjectParam(9, 9)}, - }) - if err != nil { - t.Fatalf("Publish A: %v", err) - } - defer pubStreamA.Close() - - // Second publisher on the SAME track announces a lower one. - pubB := dialAnotherClient(t, pubA) - pubStreamB, err := pubB.Publish(t.Context(), &message.Publish{ - Namespace: tns, - Name: []byte("rtp"), - TrackAlias: 100, - Parameters: message.Parameters{message.LargestObjectParam(3, 4)}, - }) - if err != nil { - t.Fatalf("Publish B: %v", err) - } - defer pubStreamB.Close() - - // The subscriber gets one PUBLISH for the track (§10.20: existing tracks - // are forwarded when the SUBSCRIBE_TRACKS arrives). - subSess := dialAnotherClient(t, pubA) - subStream, err := subSess.SubscribeTracks(t.Context(), &message.SubscribeTracks{ - TrackNamespacePrefix: ns("video"), - }) - if err != nil { - t.Fatalf("SubscribeTracks: %v", err) - } - defer subStream.Close() - req, err := subSess.AcceptRequest(t.Context()) - if err != nil { - t.Fatalf("AcceptRequest: %v", err) - } - pub, ok := req.First.(*message.Publish) - if !ok { - t.Fatalf("got %T, want *message.Publish", req.First) - } - // Exactly one LARGEST_OBJECT: the upstream's copy is stripped and the - // entry's is appended, so a duplicate would mean the strip broke. - var seen int - for _, p := range pub.Parameters { - if p.Type != message.ParamLargestObject { - continue - } - seen++ - if p.Group != 9 || p.Object != 9 { - t.Errorf("forwarded LARGEST_OBJECT = {%d,%d}, want {9,9} — the relay "+ - "advertised publisher B's lower value instead of its own maximum", - p.Group, p.Object) - } - } - if seen != 1 { - t.Errorf("forwarded PUBLISH carried %d LARGEST_OBJECT parameters, want exactly 1", seen) - } -} - -// opaqueProps is well-formed Track Properties holding one property of a type -// the relay does not interpret, so it must pass through byte for byte. -func opaqueProps(v string) []byte { - return message.AppendTrackProperties([]wire.KVPair{{Type: 0x101, ByteVal: []byte(v)}}) -} diff --git a/pkg/relay/session_subscribe_test.go b/pkg/relay/session_subscribe_test.go new file mode 100644 index 00000000..a8950e8d --- /dev/null +++ b/pkg/relay/session_subscribe_test.go @@ -0,0 +1,146 @@ +package relay_test + +import ( + "bytes" + "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" +) + +// SUBSCRIBE to a track the relay already has an upstream for, or none at all: +// the reply, its parameters, and what the subscriber sees when the publisher +// goes away. + +// 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{}) + defer teardown() + + _, err := clientSess.Subscribe(t.Context(), &message.Subscribe{Namespace: ns("video"), Name: []byte("cam1")}) + requireRejectedWithCode(t, err, moqt.RequestDoesNotExist) +} + +// 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{}) + defer teardown() + publishNS(t, pubSess, "video") + + subSess := dialAnotherClient(t, pubSess) + _, err := subSess.Subscribe(t.Context(), &message.Subscribe{Namespace: ns("audio"), Name: []byte("mic")}) + requireRejectedWithCode(t, err, moqt.RequestDoesNotExist) +} + +// TestSubscribe_ServedFromExistingUpstream: a SUBSCRIBE to an already +// published track is answered from the existing upstream (§9.4). Its Track +// Alias is the relay's own (§11.1), so its value is not pinned here. +func TestSubscribe_ServedFromExistingUpstream(t *testing.T) { + t.Parallel() + pubSess, teardown := connectRelay(t, relay.Config{}) + defer teardown() + publish(t, pubSess, &message.Publish{ + Namespace: ns("video"), Name: []byte("cam1"), TrackAlias: 42, + TrackProperties: opaqueProps("hello props"), + }) + + subReq := subscribeCam1(t, dialAnotherClient(t, pubSess)) + // §9.6: the relay treats Track Properties opaquely, so they round-trip + // verbatim. + if got, want := subReq.OK.TrackProperties, opaqueProps("hello props"); !bytes.Equal(got, want) { + t.Fatalf("TrackProperties = %x, want %x", got, want) + } +} + +// 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{}) + defer teardown() + + // The client publishes its own track on inbound alias 0, and a peer + // publishes the track the client subscribes to. + publish(t, clientSess, &message.Publish{Namespace: ns("room", "self"), Name: []byte("video"), TrackAlias: 0}) + peerSess := dialAnotherClient(t, clientSess) + publish(t, peerSess, &message.Publish{Namespace: ns("room", "peer"), Name: []byte("video"), TrackAlias: 0}) + + sub, err := clientSess.Subscribe(t.Context(), &message.Subscribe{ + Namespace: ns("room", "peer"), + Name: []byte("video"), + }) + if err != nil { + t.Fatalf("Subscribe to peer track (alias collision regression): %v", err) + } + defer sub.Close() +} + +// TestSubscribe_InstallsPriorityAndGroupOrder: SUBSCRIBER_PRIORITY and +// GROUP_ORDER on a SUBSCRIBE are accepted rather than rejected as malformed; +// the registry state is covered by the unit test on the DownstreamSub setters. +func TestSubscribe_InstallsPriorityAndGroupOrder(t *testing.T) { + t.Parallel() + pubSess, _ := newCam1Publisher(t, nil) + newCam1Subscriber(t, pubSess, + message.SubscriberPriorityParam(42), + message.GroupOrderParam(message.GroupOrderDescending), + ) +} + +// 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. +func TestSubscribe_InvalidGroupOrderRejected(t *testing.T) { + t.Parallel() + pubSess, _ := newCam1Publisher(t, nil) + + subSess := dialAnotherClient(t, pubSess) + _, _ = subSess.Subscribe(t.Context(), &message.Subscribe{ + Namespace: ns("video"), + Name: []byte("cam1"), + Parameters: message.Parameters{message.ByteParam(message.ParamGroupOrder, 0x05)}, + }) + requireSessionClosed(t, subSess, "out-of-range GROUP_ORDER SUBSCRIBE (§10.2.8)") +} + +// subscribedThenPublisherLeft subscribes a client to video/cam1 and then ends +// the publisher's session. The relay's per-session cleanup finds the track has +// no upstream left and ends every dependent downstream subscription. +func subscribedThenPublisherLeft(t *testing.T) *session.Subscription { + t.Helper() + pubSess, _ := newCam1Publisher(t, nil) + sub := subscribeCam1(t, dialAnotherClient(t, pubSess)) + _ = pubSess.Close(0, "publisher leaving") + return sub +} + +// 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() + sub := subscribedThenPublisherLeft(t) + if pd := awaitPublishDone(t, sub); pd.StatusCode != moqt.PublishDoneTrackEnded { + t.Errorf("PublishDone.StatusCode = %v, want PublishDoneTrackEnded", pd.StatusCode) + } +} + +// TestSubscribe_PublisherDisappears_StreamClosesAfterPublishDone: the relay +// FINs the request stream after PUBLISH_DONE. +func TestSubscribe_PublisherDisappears_StreamClosesAfterPublishDone(t *testing.T) { + t.Parallel() + sub := subscribedThenPublisherLeft(t) + awaitPublishDone(t, sub) + // The pipe transport may surface the FIN as another error than io.EOF; + // what matters is that no further message parses. + if m, err := message.Parse(sub); err == nil { + t.Fatalf("read %T after PUBLISH_DONE; want the stream FINned", m) + } +} diff --git a/pkg/relay/session_upstream_test.go b/pkg/relay/session_upstream_test.go new file mode 100644 index 00000000..b509415e --- /dev/null +++ b/pkg/relay/session_upstream_test.go @@ -0,0 +1,316 @@ +package relay_test + +import ( + "bytes" + "errors" + "fmt" + "slices" + "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" +) + +// On-demand upstream SUBSCRIBE: a SUBSCRIBE under a namespace a client +// advertised with PUBLISH_NAMESPACE makes the relay SUBSCRIBE that client, and +// the upstream follows its downstream subscribers (§9.2, §9.4). + +// namespacePublisher starts a relay whose first client advertises video, so a +// SUBSCRIBE under it makes the relay SUBSCRIBE that client upstream. +func namespacePublisher(t *testing.T, cfg relay.Config) *session.Session { + t.Helper() + pubSess, teardown := connectRelay(t, cfg) + t.Cleanup(teardown) + publishNS(t, pubSess, "video") + return pubSess +} + +// 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{}) + go func() { + defer close(ended) + req, err := pubSess.AcceptRequest(t.Context()) + if err != nil { + return + } + if err := req.Reply(&message.SubscribeOK{TrackAlias: alias}); err != nil { + t.Errorf("publisher SubscribeOK reply: %v", err) + return + } + for { + if _, err := message.Parse(req.Stream); err != nil { + return + } + } + }() + return ended +} + +// 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 := namespacePublisher(t, relay.Config{}) + + pubResponded := make(chan struct{}) + go func() { + defer close(pubResponded) + req, err := pubSess.AcceptRequest(t.Context()) + if err != nil { + t.Errorf("publisher AcceptRequest: %v", err) + return + } + sub, ok := req.First.(*message.Subscribe) + if !ok { + t.Errorf("publisher received %T, want *message.Subscribe", req.First) + return + } + if string(sub.Name) != "cam1" { + t.Errorf("publisher upstream SUBSCRIBE name = %q", sub.Name) + } + if err := req.Reply(&message.SubscribeOK{ + TrackAlias: 77, + TrackProperties: opaqueProps("upstream props"), + }); err != nil { + t.Errorf("publisher SubscribeOK reply: %v", err) + } + }() + + subReq := subscribeCam1(t, dialAnotherClient(t, pubSess)) + <-pubResponded + // §9.6: the Track Properties from the upstream SUBSCRIBE_OK are echoed on + // the downstream one. + if got, want := subReq.OK.TrackProperties, opaqueProps("upstream props"); !bytes.Equal(got, want) { + t.Fatalf("downstream TrackProperties = %x, want %x", got, want) + } +} + +// upstreamForwardFor SUBSCRIBEs a downstream client with params and returns the +// FORWARD the relay's upstream SUBSCRIBE carried; absent means 1 (§10.2.18). +func upstreamForwardFor(t *testing.T, params ...message.Parameter) (value uint8, present bool) { + t.Helper() + pubSess := namespacePublisher(t, relay.Config{}) + type forward struct { + value uint8 + present bool + } + got := make(chan forward, 1) + go func() { + req, err := pubSess.AcceptRequest(t.Context()) + if err != nil { + t.Errorf("publisher AcceptRequest: %v", err) + return + } + sub, ok := req.First.(*message.Subscribe) + if !ok { + t.Errorf("publisher received %T, want *message.Subscribe", req.First) + return + } + var f forward + if p, ok := sub.Parameters.Find(message.ParamForward); ok { + f = forward{value: p.Byte, present: true} + } + got <- f + if err := req.Reply(&message.SubscribeOK{TrackAlias: 77}); err != nil { + t.Errorf("publisher SubscribeOK reply: %v", err) + return + } + for { + if _, err := message.Parse(req.Stream); err != nil { + return + } + } + }() + + newCam1Subscriber(t, pubSess, params...) + f := <-got + return f.value, f.present +} + +// TestSubscribe_UpstreamForwardPausedWhenDownstreamForwardZero pins §9.2: when +// the only downstream subscriber sets Forward=0, the relay exercises its +// discretion and pauses the upstream with an explicit FORWARD=0. +func TestSubscribe_UpstreamForwardPausedWhenDownstreamForwardZero(t *testing.T) { + t.Parallel() + if v, ok := upstreamForwardFor(t, message.ForwardParam(false)); !ok || v != 0 { + t.Fatalf("upstream FORWARD = %d (present=%v), want an explicit 0", v, ok) + } +} + +// TestSubscribe_UpstreamForwardOmittedWhenDownstreamForwards pins §9.2: when a +// downstream subscriber wants forwarding (FORWARD omitted → default 1), the +// relay's upstream SUBSCRIBE omits FORWARD too (implicit 1). +func TestSubscribe_UpstreamForwardOmittedWhenDownstreamForwards(t *testing.T) { + t.Parallel() + if v, ok := upstreamForwardFor(t); ok { + t.Fatalf("upstream FORWARD present (=%d), want omitted (implicit 1)", v) + } +} + +// 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 := namespacePublisher(t, relay.Config{}) + + // The publisher expects an explicit Forward=0 SUBSCRIBE, then a resuming + // REQUEST_UPDATE with Forward=1. + type result struct { + initialForward int + resumeForward int + initialHasParam bool + } + res := make(chan result, 1) + go func() { + req, err := pubSess.AcceptRequest(t.Context()) + if err != nil { + t.Errorf("publisher AcceptRequest: %v", err) + return + } + sub, ok := req.First.(*message.Subscribe) + if !ok { + t.Errorf("publisher received %T, want *message.Subscribe", req.First) + return + } + var r result + if p, ok := sub.Parameters.Find(message.ParamForward); ok { + r.initialHasParam = true + r.initialForward = int(p.Byte) + } + if err := req.Reply(&message.SubscribeOK{TrackAlias: 77}); err != nil { + t.Errorf("publisher SubscribeOK: %v", err) + return + } + m, err := message.Parse(req.Stream) + if err != nil { + t.Errorf("publisher read follow-up: %v", err) + return + } + upd, ok := m.(*message.RequestUpdate) + if !ok { + t.Errorf("publisher follow-up = %T, want *message.RequestUpdate", m) + return + } + if p, ok := upd.Parameters.Find(message.ParamForward); ok { + r.resumeForward = int(p.Byte) + } + // Answer the §10.9 REQUEST_UPDATE so the relay's resume completes + // rather than timing out. + if err := message.Marshal(req.Stream, &message.RequestOK{}); err != nil { + t.Errorf("publisher REQUEST_OK: %v", err) + } + res <- r + }() + + // Subscriber A (Forward=0) establishes the paused upstream; B (Forward + // omitted, so 1) reuses it and must resume it. + newCam1Subscriber(t, pubSess, message.ForwardParam(false)) + newCam1Subscriber(t, pubSess) + + select { + case got := <-res: + if !got.initialHasParam || got.initialForward != 0 { + t.Errorf("upstream initial FORWARD = {val:%d present:%v}, want explicit 0", + got.initialForward, got.initialHasParam) + } + if got.resumeForward != 1 { + t.Errorf("upstream resume REQUEST_UPDATE FORWARD = %d, want 1", got.resumeForward) + } + case <-time.After(5 * time.Second): + t.Fatal("publisher did not observe the §9.2 upstream resume REQUEST_UPDATE") + } +} + +// 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{} + pubSess := namespacePublisher(t, relay.Config{Metrics: closed}) + const upstreamAlias = uint64(77) + acceptUpstreamSubscribe(t, pubSess, upstreamAlias) + + subA := newCam1Subscriber(t, pubSess) + subB := newCam1Subscriber(t, pubSess) + + // A leaves entirely, and the relay must keep the upstream B depends on. + // Wait for it to evict A's subscription (SubscriptionClosed fires in + // handleSubscribe's defer) so the publish below sees the post-removal + // state. + _ = subA.Close(0, "subscriber A leaving") + waitFor(t, 2*time.Second, func() bool { return closed.subsClosed.Load() >= 1 }, + "relay never evicted subscriber A's subscription") + + reads := readNextSubgroup(t, subB) + publishObjects(t, pubSess, upstreamAlias, 0, 1) + if r := awaitSubgroupRead(t, reads); !slices.Equal(r.payloads, []string{"A"}) { + t.Fatalf("subscriber B got %q after A left, want [A]", r.payloads) + } +} + +// TestSubscribe_LastDownstreamTearsDownUpstream: when the last downstream +// subscriber leaves, the relay ends its upstream subscription. +func TestSubscribe_LastDownstreamTearsDownUpstream(t *testing.T) { + t.Parallel() + pubSess := namespacePublisher(t, relay.Config{}) + subscriptionEnded := acceptUpstreamSubscribe(t, pubSess, 77) + + subStream := subscribeCam1(t, dialAnotherClient(t, pubSess)) + _ = subStream.Close() + + select { + case <-subscriptionEnded: + case <-time.After(2 * time.Second): + t.Fatal("publisher's subscription still open 2s after the last downstream left") + } +} + +// 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 { + upstream, want moqt.RequestErrorCode + retry uint64 + }{ + {moqt.RequestDoesNotExist, moqt.RequestDoesNotExist, 0}, + {moqt.RequestExcessiveLoad, moqt.RequestExcessiveLoad, 501}, + {moqt.RequestTimeout, moqt.RequestTimeout, 1}, + {moqt.RequestMalformedTrack, moqt.RequestMalformedTrack, 0}, + {moqt.RequestUnauthorized, moqt.RequestInternalError, 0}, + {moqt.RequestExpiredAuthToken, moqt.RequestInternalError, 2001}, + {moqt.RequestGoingAway, moqt.RequestInternalError, 0}, + {moqt.RequestInvalidRange, moqt.RequestInternalError, 0}, + {moqt.RequestErrorCode(0x7777), moqt.RequestInternalError, 31}, + } { + t.Run(fmt.Sprintf("%#x", uint64(tc.upstream)), func(t *testing.T) { + t.Parallel() + pubSess := namespacePublisher(t, relay.Config{}) + go func() { + req, err := pubSess.AcceptRequest(t.Context()) + if err != nil { + return + } + _ = req.Reject(&session.RequestRejectedError{ + Code: tc.upstream, Reason: "upstream says no", RetryInterval: tc.retry, + }) + }() + + subSess := dialAnotherClient(t, pubSess) + _, err := subSess.Subscribe(t.Context(), &message.Subscribe{Namespace: ns("video"), Name: []byte("cam1")}) + requireRejectedWithCode(t, err, tc.want) + if rej, _ := errors.AsType[*session.RequestRejectedError](err); rej.RetryInterval != tc.retry { + t.Fatalf("downstream Retry Interval %d, want the upstream's %d", rej.RetryInterval, tc.retry) + } + }) + } +} From 1c30e60b43ba62e60c2122273beabaf82eb84f52 Mon Sep 17 00:00:00 2001 From: Vsevolod Strukchinsky Date: Sat, 26 Sep 2026 16:00:11 +0500 Subject: [PATCH 4/5] test: review follow-ups on the test split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - relay: helpers_test.go (759 lines) split by role into helpers_data_test.go (data-stream readers) and helpers_namespace_test.go (namespace and request-stream messages). - relay: TestPublish_ForwardedAliasDoesNotCollide registers each forwarded PUBLISH until the colliding rtp one; it relied on rtp being forwarded first, while cam1 may be too (§10.20). Verified: with every forwarded alias forced to 1 the test fails either way. - session: a fetch End of Range marker is compared by its Location only, not by Subgroup ID and Priority the draft leaves undefined; and the invalid-status test cites §11.2.1.1, not §11. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/moqt/session/datastream_fetch_test.go | 5 + pkg/moqt/session/datastream_subgroup_test.go | 2 +- pkg/relay/helpers_data_test.go | 211 ++++++++++++ pkg/relay/helpers_namespace_test.go | 149 ++++++++ pkg/relay/helpers_test.go | 341 +------------------ pkg/relay/session_publish_test.go | 21 +- 6 files changed, 384 insertions(+), 345 deletions(-) create mode 100644 pkg/relay/helpers_data_test.go create mode 100644 pkg/relay/helpers_namespace_test.go diff --git a/pkg/moqt/session/datastream_fetch_test.go b/pkg/moqt/session/datastream_fetch_test.go index e0da1ccc..89b57aec 100644 --- a/pkg/moqt/session/datastream_fetch_test.go +++ b/pkg/moqt/session/datastream_fetch_test.go @@ -218,7 +218,12 @@ func TestIncomingFetchStream_ReadDecoded(t *testing.T) { }, } + // A marker carries only its Location: its other fields are not the + // draft's to define. view := func(d *session.DecodedFetchObject) string { + if d.EndOfNonExistentRange { + return fmt.Sprintf("{G=%d O=%d endOfNonExistentRange}", d.GroupID, d.ObjectID) + } return fmt.Sprintf("{G=%d O=%d Sub=%d Pri=%d payload=%q endOfNonExistentRange=%t}", d.GroupID, d.ObjectID, d.SubgroupID, d.PublisherPriority, d.Payload, d.EndOfNonExistentRange) } diff --git a/pkg/moqt/session/datastream_subgroup_test.go b/pkg/moqt/session/datastream_subgroup_test.go index c3bbac7d..af510432 100644 --- a/pkg/moqt/session/datastream_subgroup_test.go +++ b/pkg/moqt/session/datastream_subgroup_test.go @@ -10,7 +10,7 @@ import ( // TestSubgroupObjectReadRejectsInvalidStatus: ReadObject validates each decoded // object, so an empty payload with a status that is not -// Normal/EndOfGroup/EndOfTrack is a §11 protocol violation, not a valid object. +// Normal/EndOfGroup/EndOfTrack is a protocol violation (§11.2.1.1), not a valid object. func TestSubgroupObjectReadRejectsInvalidStatus(t *testing.T) { cli, srv := openPair(t) hdr := message.SubgroupHeader{TrackAlias: 42, GroupID: 7, SubgroupIDMode: message.SubgroupIDImplicitZero} diff --git a/pkg/relay/helpers_data_test.go b/pkg/relay/helpers_data_test.go new file mode 100644 index 00000000..97ba4009 --- /dev/null +++ b/pkg/relay/helpers_data_test.go @@ -0,0 +1,211 @@ +package relay_test + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/floatdrop/moq-go/pkg/moqt/message" + "github.com/floatdrop/moq-go/pkg/moqt/session" +) + +// Helpers that read what the relay sends on data streams. + +// 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 +} + +// subgroupRead is one subgroup stream as a subscriber read it to its end. +type subgroupRead struct { + header message.SubgroupHeader + ids []uint64 // absolute Object IDs (§11.4.2) + payloads []string + end error // io.EOF for a FIN, otherwise a reset + err error // no subgroup stream was accepted +} + +// readNextSubgroup reads the next subgroup stream sess accepts to its end, off +// the test goroutine so it can start before the Objects are published. +func readNextSubgroup(t *testing.T, sess *session.Session) <-chan subgroupRead { + t.Helper() + out := make(chan subgroupRead, 1) + go func() { + ds, err := sess.AcceptDataStream(t.Context()) + if err != nil { + out <- subgroupRead{err: fmt.Errorf("AcceptDataStream: %w", err)} + return + } + in, ok := ds.(*session.IncomingSubgroupStream) + if !ok { + out <- subgroupRead{err: fmt.Errorf("AcceptDataStream = %T, want a subgroup stream", ds)} + return + } + r := subgroupRead{header: in.Header} + for { + o, err := in.ReadDecoded() + if err != nil { + r.end = err + out <- r + return + } + r.ids = append(r.ids, o.ObjectID) + r.payloads = append(r.payloads, string(o.Payload)) + } + }() + return out +} + +// awaitSubgroupRead waits up to 5s for [readNextSubgroup]'s stream to end. +func awaitSubgroupRead(t *testing.T, reads <-chan subgroupRead) subgroupRead { + t.Helper() + select { + case r := <-reads: + if r.err != nil { + t.Fatal(r.err) + } + return r + case <-time.After(5 * time.Second): + t.Fatal("no subgroup stream reached its end within 5s") + return subgroupRead{} + } +} + +// readUntilEnd reads the subscriber's next subgroup stream to its end and +// returns the Object IDs it carried and how it ended. +func readUntilEnd(t *testing.T, subSess *session.Session) (ids []uint64, end error) { + t.Helper() + r := awaitSubgroupRead(t, readNextSubgroup(t, subSess)) + return r.ids, r.end +} + +// objEvent is one Object, or the end of a stream (or of accepting), as +// [readSubgroups] emits it. +type objEvent struct { + stream int // 1-based index of the outbound stream it arrived on + absID uint64 // §11.4.2 delta resolved to an absolute Object ID + err error // non-nil marks a stream end (io.EOF = FIN, else reset) or accept error +} + +// 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 { + ds, err := sub.AcceptDataStream(ctx) + if err != nil { + out <- objEvent{err: err} + return + } + sg, ok := ds.(*session.IncomingSubgroupStream) + if !ok { + continue + } + streamIdx++ + idx := streamIdx + var ( + prev uint64 + have bool + ) + for { + obj, err := sg.ReadObject() + if err != nil { + out <- objEvent{stream: idx, err: err} + break + } + var absID uint64 + if !have { + absID = obj.ObjectIDDelta + have = true + } else { + absID = prev + obj.ObjectIDDelta + 1 + } + prev = absID + out <- objEvent{stream: idx, absID: absID} + } + } +} + +// 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 + } + } + } + } +} diff --git a/pkg/relay/helpers_namespace_test.go b/pkg/relay/helpers_namespace_test.go new file mode 100644 index 00000000..e2413d76 --- /dev/null +++ b/pkg/relay/helpers_namespace_test.go @@ -0,0 +1,149 @@ +package relay_test + +import ( + "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/relay/discovery" + "github.com/floatdrop/moq-go/pkg/relay/internal/relaytest" +) + +// Helpers for namespace publishing, SUBSCRIBE_NAMESPACE and the messages on +// request streams. + +// 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...)}) + if err != nil { + t.Fatalf("PublishNamespace %v: %v", fields, err) + } + 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, + fields ...string, +) (*session.NamespaceSubscription, <-chan message.Message) { + t.Helper() + s, err := sess.SubscribeNamespace(t.Context(), &message.SubscribeNamespace{TrackNamespacePrefix: ns(fields...)}) + if err != nil { + t.Fatalf("SubscribeNamespace %v: %v", fields, err) + } + t.Cleanup(func() { _ = s.Close() }) + return s, streamMessages(t, s.Stream) +} + +// readvertise publishes info into store every 20ms until the returned stop, +// which waits for the last publish, is called (it also runs at cleanup). A +// relay's Discovery watch registers asynchronously in Start and MemoryStore +// does not replay history to new watchers, so one publish can go unseen. +func readvertise(t *testing.T, store *discovery.MemoryStore, info discovery.NamespaceInfo) (stop func()) { + t.Helper() + quit := make(chan struct{}) + exited := make(chan struct{}) + go func() { + defer close(exited) + tick := time.NewTicker(20 * time.Millisecond) + defer tick.Stop() + for { + _ = store.PublishNamespace(t.Context(), info) + select { + case <-quit: + return + case <-tick.C: + } + } + }() + stop = sync.OnceFunc(func() { + close(quit) + <-exited + }) + t.Cleanup(stop) + return stop +} + +// Subscribing. + +// 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 +} + +// requireQuiet fails if a message arrives on msgs within 300ms. +func requireQuiet(t *testing.T, msgs <-chan message.Message, what string) { + t.Helper() + select { + case m := <-msgs: + t.Fatalf("%s: unexpected %T %+v", what, m, m) + case <-time.After(300 * time.Millisecond): + } +} + +// 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) + 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) + } +} + +// 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) + d, ok := m.(*message.NamespaceDone) + if !ok || relaytest.FormatNamespace(d.TrackNamespaceSuffix) != relaytest.FormatNamespace(ns(suffix...)) { + t.Fatalf("got %T %+v, want NAMESPACE_DONE %v", m, m, suffix) + } +} diff --git a/pkg/relay/helpers_test.go b/pkg/relay/helpers_test.go index 4b0b5066..c54fe6fc 100644 --- a/pkg/relay/helpers_test.go +++ b/pkg/relay/helpers_test.go @@ -1,10 +1,8 @@ package relay_test import ( - "context" "errors" "fmt" - "sync" "testing" "time" @@ -13,13 +11,12 @@ import ( "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" ) // 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. +// started by connectRelay (harness_test.go). Readers of data streams are in +// helpers_data_test.go, namespace and request-stream message helpers in +// helpers_namespace_test.go, and FETCH helpers in helpers_fetch_test.go. // ns builds a Track Namespace from its fields. func ns(fields ...string) wire.TrackNamespace { @@ -227,63 +224,6 @@ func openSubgroupWaiting( // Namespaces. -// 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...)}) - if err != nil { - t.Fatalf("PublishNamespace %v: %v", fields, err) - } - 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, - fields ...string, -) (*session.NamespaceSubscription, <-chan message.Message) { - t.Helper() - s, err := sess.SubscribeNamespace(t.Context(), &message.SubscribeNamespace{TrackNamespacePrefix: ns(fields...)}) - if err != nil { - t.Fatalf("SubscribeNamespace %v: %v", fields, err) - } - t.Cleanup(func() { _ = s.Close() }) - return s, streamMessages(t, s.Stream) -} - -// readvertise publishes info into store every 20ms until the returned stop, -// which waits for the last publish, is called (it also runs at cleanup). A -// relay's Discovery watch registers asynchronously in Start and MemoryStore -// does not replay history to new watchers, so one publish can go unseen. -func readvertise(t *testing.T, store *discovery.MemoryStore, info discovery.NamespaceInfo) (stop func()) { - t.Helper() - quit := make(chan struct{}) - exited := make(chan struct{}) - go func() { - defer close(exited) - tick := time.NewTicker(20 * time.Millisecond) - defer tick.Stop() - for { - _ = store.PublishNamespace(t.Context(), info) - select { - case <-quit: - return - case <-tick.C: - } - } - }() - stop = sync.OnceFunc(func() { - close(quit) - <-exited - }) - t.Cleanup(stop) - return stop -} - -// 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 { @@ -394,281 +334,6 @@ func acceptForwarded(t *testing.T, r *session.Request) *session.IncomingPublicat // 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 -} - -// subgroupRead is one subgroup stream as a subscriber read it to its end. -type subgroupRead struct { - header message.SubgroupHeader - ids []uint64 // absolute Object IDs (§11.4.2) - payloads []string - end error // io.EOF for a FIN, otherwise a reset - err error // no subgroup stream was accepted -} - -// readNextSubgroup reads the next subgroup stream sess accepts to its end, off -// the test goroutine so it can start before the Objects are published. -func readNextSubgroup(t *testing.T, sess *session.Session) <-chan subgroupRead { - t.Helper() - out := make(chan subgroupRead, 1) - go func() { - ds, err := sess.AcceptDataStream(t.Context()) - if err != nil { - out <- subgroupRead{err: fmt.Errorf("AcceptDataStream: %w", err)} - return - } - in, ok := ds.(*session.IncomingSubgroupStream) - if !ok { - out <- subgroupRead{err: fmt.Errorf("AcceptDataStream = %T, want a subgroup stream", ds)} - return - } - r := subgroupRead{header: in.Header} - for { - o, err := in.ReadDecoded() - if err != nil { - r.end = err - out <- r - return - } - r.ids = append(r.ids, o.ObjectID) - r.payloads = append(r.payloads, string(o.Payload)) - } - }() - return out -} - -// awaitSubgroupRead waits up to 5s for [readNextSubgroup]'s stream to end. -func awaitSubgroupRead(t *testing.T, reads <-chan subgroupRead) subgroupRead { - t.Helper() - select { - case r := <-reads: - if r.err != nil { - t.Fatal(r.err) - } - return r - case <-time.After(5 * time.Second): - t.Fatal("no subgroup stream reached its end within 5s") - return subgroupRead{} - } -} - -// readUntilEnd reads the subscriber's next subgroup stream to its end and -// returns the Object IDs it carried and how it ended. -func readUntilEnd(t *testing.T, subSess *session.Session) (ids []uint64, end error) { - t.Helper() - r := awaitSubgroupRead(t, readNextSubgroup(t, subSess)) - return r.ids, r.end -} - -// objEvent is one Object, or the end of a stream (or of accepting), as -// [readSubgroups] emits it. -type objEvent struct { - stream int // 1-based index of the outbound stream it arrived on - absID uint64 // §11.4.2 delta resolved to an absolute Object ID - err error // non-nil marks a stream end (io.EOF = FIN, else reset) or accept error -} - -// 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 { - ds, err := sub.AcceptDataStream(ctx) - if err != nil { - out <- objEvent{err: err} - return - } - sg, ok := ds.(*session.IncomingSubgroupStream) - if !ok { - continue - } - streamIdx++ - idx := streamIdx - var ( - prev uint64 - have bool - ) - for { - obj, err := sg.ReadObject() - if err != nil { - out <- objEvent{stream: idx, err: err} - break - } - var absID uint64 - if !have { - absID = obj.ObjectIDDelta - have = true - } else { - absID = prev + obj.ObjectIDDelta + 1 - } - prev = absID - out <- objEvent{stream: idx, absID: absID} - } - } -} - -// 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 -} - -// requireQuiet fails if a message arrives on msgs within 300ms. -func requireQuiet(t *testing.T, msgs <-chan message.Message, what string) { - t.Helper() - select { - case m := <-msgs: - t.Fatalf("%s: unexpected %T %+v", what, m, m) - case <-time.After(300 * time.Millisecond): - } -} - -// 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) - 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) - } -} - -// 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) - d, ok := m.(*message.NamespaceDone) - if !ok || relaytest.FormatNamespace(d.TrackNamespaceSuffix) != relaytest.FormatNamespace(ns(suffix...)) { - t.Fatalf("got %T %+v, want NAMESPACE_DONE %v", m, m, suffix) - } -} - // 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 { diff --git a/pkg/relay/session_publish_test.go b/pkg/relay/session_publish_test.go index 185d9461..a08e73c8 100644 --- a/pkg/relay/session_publish_test.go +++ b/pkg/relay/session_publish_test.go @@ -163,13 +163,22 @@ func TestPublish_ForwardedAliasDoesNotCollide(t *testing.T) { Name: []byte("rtp"), TrackAlias: subReq.OK.TrackAlias, // the alias the subscriber already holds for cam1 }) - fwd := nextForwardedPublish(t, subSess) - // The alias registration AcceptPublish performs, without its REQUEST_OK - // write: the relay does not yet read its end of a forwarded PUBLISH stream, - // so on the unbuffered in-process pipe that write would never complete. - if err := subSess.RegisterInboundTrackAlias(fwd.TrackAlias, track.NewKey(fwd.Namespace, fwd.Name)); err != nil { - t.Fatalf("registering the forwarded PUBLISH's alias: %v", err) + // cam1 is already published, so its PUBLISH may be forwarded too + // (§10.20), in either order: register each forwarded alias until the rtp + // one's. This is the alias + // registration AcceptPublish performs, without its REQUEST_OK write: the + // relay does not yet read its end of a forwarded PUBLISH stream, so on the + // unbuffered in-process pipe that write would never complete. + for range 2 { + fwd := nextForwardedPublish(t, subSess) + if err := subSess.RegisterInboundTrackAlias(fwd.TrackAlias, track.NewKey(fwd.Namespace, fwd.Name)); err != nil { + t.Fatalf("registering the forwarded PUBLISH's alias for %s: %v", fwd.Name, err) + } + if string(fwd.Name) == "rtp" { + return + } } + t.Fatal("the rtp PUBLISH was not forwarded") } // TestSubscribeTracks_InvalidGroupOrderClosesSession pins §10.2.8: a From b83648306fd1647b16d860fcb1be19119a99d8ee Mon Sep 17 00:00:00 2001 From: Vsevolod Strukchinsky Date: Sat, 26 Sep 2026 16:02:39 +0500 Subject: [PATCH 5/5] test: word two test comments as the draft does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review nits: an End of Range marker's Subgroup ID and Priority are not present (§11.4.4.2), and a SUBSCRIBE_TRACKS holder is sent PUBLISH for existing tracks too (§10.20). Comment wording only. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/moqt/session/datastream_fetch_test.go | 4 ++-- pkg/relay/session_publish_test.go | 11 +++++------ 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/pkg/moqt/session/datastream_fetch_test.go b/pkg/moqt/session/datastream_fetch_test.go index 89b57aec..7c843cef 100644 --- a/pkg/moqt/session/datastream_fetch_test.go +++ b/pkg/moqt/session/datastream_fetch_test.go @@ -218,8 +218,8 @@ func TestIncomingFetchStream_ReadDecoded(t *testing.T) { }, } - // A marker carries only its Location: its other fields are not the - // draft's to define. + // Subgroup ID and Priority are not present on an End of Range + // (§11.4.4.2), so a marker compares by Location only. view := func(d *session.DecodedFetchObject) string { if d.EndOfNonExistentRange { return fmt.Sprintf("{G=%d O=%d endOfNonExistentRange}", d.GroupID, d.ObjectID) diff --git a/pkg/relay/session_publish_test.go b/pkg/relay/session_publish_test.go index a08e73c8..fd72b87d 100644 --- a/pkg/relay/session_publish_test.go +++ b/pkg/relay/session_publish_test.go @@ -163,12 +163,11 @@ func TestPublish_ForwardedAliasDoesNotCollide(t *testing.T) { Name: []byte("rtp"), TrackAlias: subReq.OK.TrackAlias, // the alias the subscriber already holds for cam1 }) - // cam1 is already published, so its PUBLISH may be forwarded too - // (§10.20), in either order: register each forwarded alias until the rtp - // one's. This is the alias - // registration AcceptPublish performs, without its REQUEST_OK write: the - // relay does not yet read its end of a forwarded PUBLISH stream, so on the - // unbuffered in-process pipe that write would never complete. + // cam1 is already published, so its PUBLISH is forwarded too (§10.20), in + // either order: register each forwarded alias until the rtp one's. This is + // the alias registration AcceptPublish performs, without its REQUEST_OK + // write: the relay does not yet read its end of a forwarded PUBLISH stream, + // so on the unbuffered in-process pipe that write would never complete. for range 2 { fwd := nextForwardedPublish(t, subSess) if err := subSess.RegisterInboundTrackAlias(fwd.TrackAlias, track.NewKey(fwd.Namespace, fwd.Name)); err != nil {