diff --git a/STATUS.md b/STATUS.md index 3938b8ca..9a117cb0 100644 --- a/STATUS.md +++ b/STATUS.md @@ -115,7 +115,7 @@ By package, bottom-up along the dependency stack: | 5.1.5 | Combining filters | DONE | `ForwardDecision` ANDs Forward + Location + Range filters per object (§5.1.5); Range filters combine SetIDs via AND/OR. | | 5.1.6 | Joining an ongoing track | DONE | A Location Filter plus FILL_PARAMETERS, served as a fill fetch stream (draft-20 removed the Joining FETCH). | | 5.1.6.1 | Dynamically starting new groups | DONE | Relay forwards a downstream `NEW_GROUP_REQUEST` upstream per §10.2.19: included in the on-demand upstream SUBSCRIBE (no established upstream) or sent as an upstream REQUEST_UPDATE, gated on `DYNAMIC_GROUPS` support, Largest-Group, and outstanding-request bookkeeping. | -| 5.2 | Fetch state management | DONE | FETCH lifecycle. | +| 5.2 | Fetch state management | DONE | FETCH lifecycle. The relay resets the request and data streams with CANCELLED on a STOP_SENDING before its data stream FINs. | ## §6 Namespace discovery @@ -136,7 +136,7 @@ By package, bottom-up along the dependency stack: | § | Feature | Status | Notes | |-----|-------------------------------|--------|-------| -| 8 | Delivery timeouts / reliability| PARTIAL| OBJECT_DELIVERY_TIMEOUT enforced on subgroup streams in `session/datastream_out.go`; SUBGROUP_DELIVERY_TIMEOUT only where the transport reports acknowledgement (`session.DeliveryTrackingSendStream`), which no bundled adapter does, so not on quic-go or WebTransport; reset w/ `StreamResetDeliveryTimeout`. Datagrams are never dropped for either timeout (§8 "MUST drop the datagrams"). See Limitations. OBJECT_DELIVERY_TIMEOUT is measured per object from its receipt time (`WriteObjectReceivedAt`), not from stream open. `WithDeliveryTimeouts` takes the publisher's and subscriber's halves separately so the §12.1/§12.2 first-object override resolves within the publisher's half before `DeliveryTimeouts.Effective` takes the smaller of the two. The relay sources both sides — the publisher's Track Properties (decoded once onto the entry) and the subscriber's SUBSCRIBE parameters (§10.2.3/§10.2.4) — and passes them to every subgroup stream it opens downstream, resetting that stream alone with DELIVERY_TIMEOUT while the subscription continues. Not enforced on the raw `Write` path (no object boundaries) or inbound — see Limitations. | +| 8 | Delivery timeouts / reliability| PARTIAL| OBJECT_DELIVERY_TIMEOUT enforced on subgroup streams in `session/datastream_out.go`; SUBGROUP_DELIVERY_TIMEOUT only where the transport reports acknowledgement (`session.DeliveryTrackingSendStream`), which no bundled adapter does, so not on quic-go or WebTransport; reset w/ `StreamResetDeliveryTimeout`. Datagrams are never dropped for either timeout (§8 "MUST drop the datagrams"). See Limitations. OBJECT_DELIVERY_TIMEOUT is measured per object from its receipt time (`WriteObjectReceivedAt`), not from stream open. `WithDeliveryTimeouts` takes the publisher's and subscriber's halves separately so the §12.1/§12.2 first-object override resolves within the publisher's half before `DeliveryTimeouts.Effective` takes the smaller of the two. The relay sources both sides — the publisher's Track Properties (decoded once onto the entry) and the subscriber's SUBSCRIBE parameters (§10.2.3/§10.2.4) — and passes them to every subgroup stream it opens downstream (a replay stream, which starts past the Subgroup's first Object, gets that Object's override from the fanout), resetting that stream alone with DELIVERY_TIMEOUT while the subscription continues. Not enforced on the raw `Write` path (no object boundaries) or inbound — see Limitations. | ## §9 Relays @@ -164,7 +164,7 @@ By package, bottom-up along the dependency stack: | 10.2.3 | SUBGROUP_DELIVERY_TIMEOUT | 0x06 | PARTIAL| Parsed and resolved; the stream reset is not enforced on the bundled transports, and datagrams are not dropped (see §8). | | 10.2.4 | OBJECT_DELIVERY_TIMEOUT | 0x02 | DONE | | | 10.2.5 | FILL_TIMEOUT | 0x0A | DONE | The budget for a FETCH's or fill's upstream FETCH, its response included: when it runs out, what arrived is served and the rest is an End of Timed-Out Range; 0 asks no upstream. Default 5s. | -| 10.2.6 | RENDEZVOUS_TIMEOUT | 0x04 | DONE | | +| 10.2.6 | RENDEZVOUS_TIMEOUT | 0x04 | DONE | The relay holds a SUBSCRIBE with no publisher, capped by `Config.MaxRendezvousTimeout` (30s), and answers TIMEOUT when the hold runs out. No publisher means none matched, or each answered DOES_NOT_EXIST or TIMEOUT, or is draining; any other refusal ends the hold with its error. A PUBLISH of the track, or a covering namespace newly published here or advertised through Discovery, wakes the hold; a publisher that already answered is not asked again. Upstream SUBSCRIBEs to other relays carry what is left of the hold, and are cut short when a publisher arrives here. | | 10.2.7 | SUBSCRIBER_PRIORITY | 0x20 | DONE | | | 10.2.8 | GROUP_ORDER | 0x22 | DONE | A value outside {1, 2} closes the session, wherever it appears, FILL_PARAMETERS included. | | 10.2.9 | LOCATION_FILTER | 0x21 | DONE | An end Group overflowing 2^64-1 closes the session with PROTOCOL_VIOLATION (§5.1.2); a value that does not parse, with KEY_VALUE_FORMATTING_ERROR (§1.4.3). | @@ -198,14 +198,14 @@ By package, bottom-up along the dependency stack: | 10.11 | PUBLISH | 0x1D | DONE | | | 10.12 | PUBLISH_DONE | 0x0B | DONE | Sent once every stream of the subscription has closed and no datagram send is in progress, with the exact Stream Count; written on its own goroutine, so subscribers do not wait on each other. When a track's last upstream ends, its PUBLISH_DONE code reaches subscribers if it is about the track (TRACK_ENDED, MALFORMED_TRACK); codes about the relay's own upstream subscription become INTERNAL_ERROR. Session `Publication.Done` resets the subgroups still open with CANCELLED, refuses later opens and writes (`ErrPublicationEnded`), and counts every subgroup opened, however the opens race it. | | 10.13 | FETCH | 0x16 | DONE | Standalone, the only kind in draft-20. From the cache, a Location is non-existent only on a signal: a Prior Group or Object ID Gap, a Group's or the Track's end, or an upstream's FETCH. Other uncached Locations are FETCHed from a fetch-capable upstream in one span, within FILL_TIMEOUT, or else marked End of Unknown (or Timed-Out) Range. | -| 10.14 | FETCH_OK | 0x18 | DONE | An End Location before the FETCH's Start closes the session. A Start relative to the Largest Object is compared through End ≤ Largest; an End of {0,0} is let through, as it cannot be told apart from "no content yet". | +| 10.14 | FETCH_OK | 0x18 | DONE | The relay sets End Of Track when the End Location is the Object an END_OF_TRACK status made the Track's final one; it does not learn a Track's end from an upstream FETCH_OK's End Of Track. An End Location before the FETCH's Start closes the session. A Start relative to the Largest Object is compared through End ≤ Largest; an End of {0,0} is let through, as it cannot be told apart from "no content yet". | | 10.15 | TRACK_STATUS | 0x0D | DONE | Reply via REQUEST_OK, then FIN; any follow-up from the requester closes the session. | | 10.16 | PUBLISH_NAMESPACE | 0x06 | DONE | | | 10.17 | NAMESPACE | 0x08 | DONE | Per namespace, counted over local and remote sources. | | 10.18 | NAMESPACE_DONE | 0x0E | DONE | Never before its NAMESPACE. | | 10.19 | SUBSCRIBE_NAMESPACE | 0x50 | DONE | A first response other than REQUEST_OK / REQUEST_ERROR, a GOAWAY included, closes the session with PROTOCOL_VIOLATION. `NamespaceSubscription.Broker` closes it on a NAMESPACE_DONE for a suffix no NAMESPACE announced, and on a PUBLISH_STATE_NOTIFY or REQUEST_UPDATE from the publisher (§10.9, §10.10); `NamespaceSubscription.Update` updates the subscription through the broker. The NAMESPACE_DONE check resolves suffixes against the prefix in force, switching at the REQUEST_OK that accepts a TRACK_NAMESPACE_PREFIX update (§10.9.2); once any Update on the subscription gives up, responses no longer pair reliably, so the prefix is unknown and the check stops. A caller reading the stream with `message.Parse` gets none of these checks. | -| 10.20 | SUBSCRIBE_TRACKS | 0x51 | DONE | A first response other than REQUEST_OK / REQUEST_ERROR, a GOAWAY included, closes the session with PROTOCOL_VIOLATION. §10.20.1: its SUBSCRIBE parameters become each forwarded PUBLISH's subscription; an out-of-range value closes the session (§10.2.8/§10.2.18). A REQUEST_UPDATE merges into them for later PUBLISHes (§10.2.18: "Existing subscriptions are unaffected"), and existing tracks that newly match by prefix or Range Filter are forwarded then. | -| 10.21 | PUBLISH_SKIPPED | 0x0F | DONE | Prohibition scoped to a single PUBLISH (§6.1) — not sticky across re-PUBLISHes. `TrackSubscription.ReadPublishSkipped` and its broker close the session on a PUBLISH_STATE_NOTIFY or REQUEST_UPDATE from the publisher (§10.9, §10.10); `ReadPublishSkipped` skips a single GOAWAY (§10.4). | +| 10.20 | SUBSCRIBE_TRACKS | 0x51 | DONE | A first response other than REQUEST_OK / REQUEST_ERROR, a GOAWAY included, closes the session with PROTOCOL_VIOLATION. §10.20.1: its SUBSCRIBE parameters become each forwarded PUBLISH's subscription; an out-of-range value closes the session (§10.2.8/§10.2.18). A REQUEST_UPDATE merges into them for later PUBLISHes (§10.2.18: "Existing subscriptions are unaffected"), and existing tracks that newly match by prefix or Range Filter are forwarded then, unless skipped (§6.1). | +| 10.21 | PUBLISH_SKIPPED | 0x0F | DONE | Prohibition scoped to a single PUBLISH (§6.1): the relay offers the track again only once a new upstream PUBLISH or SUBSCRIBE is added, not after a prefix update moves away and back. `TrackSubscription.ReadPublishSkipped` and its broker close the session on a PUBLISH_STATE_NOTIFY or REQUEST_UPDATE from the publisher (§10.9, §10.10); `ReadPublishSkipped` skips a single GOAWAY (§10.4). | ## §11 Data streams and datagrams @@ -219,7 +219,7 @@ By package, bottom-up along the dependency stack: | 11.4 | Streams (subgroup / fetch) | DONE | Typed in/out subgroup + fetch streams. | | 11.4.1 | Stream cancellation | DONE | Bidi request-stream termination ends the request (handlers unregister on stream end); the relay sends PUBLISH_DONE on graceful subscription termination rather than abrupt reset. | | 11.4.2 | Subgroup header + delta object IDs | DONE | All subgroup-ID modes; `ReadDecoded` resolves deltas. A subgroup stream whose Track Alias is not bound yet is held unread (§11.4.2 MAY buffer): `session.Demux` parks it, count-bounded; the relay waits up to 1 s (`IncomingSubgroupStream.AwaitInboundTrack`, at most 32 streams per session, the rest reset with EXCESSIVE_LOAD). The §11.4.2 MUST to give control streams connection flow control first is not met by the bundled transports, so enough early data can delay the SUBSCRIBE_OK until the relay's wait runs out and the streams are reset. | -| 11.4.3 | Closing subgroup streams | DONE | Relay forwards only the next object on a stream, otherwise reset+reopen: the next object is one ID greater, read next from the same upstream stream (only filtered-out objects between), or covered by its Prior Object ID Gap; an object the relay dropped, or one from another upstream, breaks the run. FINs on clean inbound EOF, resets on inbound reset, resets with MALFORMED_TRACK after a terminal EndOfGroup/EndOfTrack object (§2.4.2), marks reliable boundaries for RESET_STREAM_AT (`SetReliableBoundary`, transport-gated on `EnableStreamResetPartialDelivery`), and resets (not FINs) in-flight subgroups whose group falls out of range after a narrowing REQUEST_UPDATE. A subscription that skipped any Object of the Subgroup other than one before its Start Location (a filter, Forward State 0, a Start raised past Objects already sent, an inbox overflow with EXCESSIVE_LOAD, an expiry) gets resets, never a FIN, on that Subgroup's streams. Objects published before a subscription joined are treated as before its Start. | +| 11.4.3 | Closing subgroup streams | DONE | Relay forwards only the next object on a stream, otherwise reset+reopen: the next object is one ID greater, read next from the same upstream stream (only filtered-out objects between), or covered by its Prior Object ID Gap; an object the relay dropped, or one from another upstream, breaks the run. FINs on clean inbound EOF, resets on inbound reset, resets with MALFORMED_TRACK after a terminal EndOfGroup/EndOfTrack object (§2.4.2), marks reliable boundaries for RESET_STREAM_AT (`SetReliableBoundary`, transport-gated on `EnableStreamResetPartialDelivery`), and resets (not FINs) in-flight subgroups whose group falls out of range after a narrowing REQUEST_UPDATE. A subscription that skipped any Object of the Subgroup other than one before its Start Location (a filter, Forward State 0, a Start raised past Objects already sent, an inbox overflow with EXCESSIVE_LOAD, an expiry) gets resets, never a FIN, on that Subgroup's streams. Objects published before a subscription joined are treated as before its Start. With several upstreams on one Subgroup, a clean end FINs only if the contributors that ended cleanly delivered every Object from their start on and the Objects forwarded below that start form an unbroken run reaching it; otherwise, since a reset upstream may have held Objects nobody forwarded, the streams reset with CANCELLED. Object IDs forwarded out of order or not consecutive (a Group split across Subgroups) break that run, so there a clean upstream that started above Objects a peer forwarded resets too. The run counts from the lowest Object the dedup ledger saw forwarded, including through upstreams that already left, whose run is forgotten: a clean replay upstream arriving after them resets unless it covers from that Object, even when it did continue their run. | | 11.4.4 | Fetch header | DONE | Serialization Flags of 128 or more that are not an End of Range close the session. | | 11.4.4.1 | Fetch flags | DONE | All subgroup modes + delta/priority/properties/status flags. A first Object that references a prior Object's fields closes the session. | | 11.4.4.2 | End of range | DONE | Non-existent (0x8C) / unknown (0x10C) / timed-out (0x20C) handled; a marker covers the Locations after the previous element in the order the response carries them (see Limitations). An Object after a leading marker that references a prior Subgroup ID or Priority closes the session. | @@ -231,7 +231,7 @@ By package, bottom-up along the dependency stack: |-------|--------------------------------|------|--------|-------| | 12.1 | SUBGROUP_DELIVERY_TIMEOUT | 0x06 | PARTIAL| Track + Object Property; the first object of a subgroup overrides the Track-level value (§8 resolution in `message.DeliveryTimeouts`, enforced in `OutgoingSubgroupStream` where the transport reports acknowledgement — none of the bundled ones do, see §8). | | 12.2 | OBJECT_DELIVERY_TIMEOUT | 0x02 | DONE | Track + Object Property; first-object override, as §12.1. | -| 12.3 | MAX_CACHE_DURATION | 0x04 | DONE | Per Object: each carries the value of the upstream it arrived through (captured with the Track Alias in `session.InboundTrack`), and is not forwarded live or served from the cache past it; a present 0 is never served from the cache. In FETCH and fill an expired Object is an End of Unknown Range, whether it expired before the snapshot or while the stream was written. | +| 12.3 | MAX_CACHE_DURATION | 0x04 | PARTIAL| Per Object: each carries the value of the upstream it arrived through (captured with the Track Alias in `session.InboundTrack`), and is not forwarded live or served from the cache past it; a present 0 is never served from the cache. An Object stitched from an upstream FETCH carries that FETCH_OK's value, and a present 0 sets no limit on it. Objects age from when the relay read them whole, not from their beginning. In FETCH and fill an expired Object is an End of Unknown Range, whether it expired before the snapshot or while the stream was written. | | 12.4 | DEFAULT_PUBLISHER_PRIORITY | 0x0E | DONE | | | 12.5 | DEFAULT_PUBLISHER_GROUP_ORDER | 0x22 | DONE | A value outside {1, 2} closes the session, also inside Immutable Properties; an omitted one is Ascending. | | 12.6 | DYNAMIC_GROUPS | 0x30 | DONE | A value above 1 closes the session, also inside Immutable Properties. | @@ -415,9 +415,10 @@ Known protocol gaps, roughly ordered by how load-bearing they are: forgotten and the rebuilt stream may FIN. - **A refused upstream FETCH_OK ends only that fetch (§2.5.1)** — the relay answers FETCH_OK before stitching, so it resets the downstream fetch or fill - stream and never takes the REQUEST_ERROR UNSUPPORTED_EXTENSION branch. The - track's live subscription and cache-only FETCHes carry on, where §2.5.1's - lead says the relay "MUST NOT process or forward that track". + stream (a FETCH's request stream too, with the same code: §3.3.3, the + maintainer's choice) and never takes the REQUEST_ERROR UNSUPPORTED_EXTENSION + branch. The track's live subscription and cache-only FETCHes carry on, where + §2.5.1's lead says the relay "MUST NOT process or forward that track". - **Inbound GOAWAY, as the subscriber (§10.4, §9.4.1, §3.6)** — the relay stops initiating requests to that peer but neither unsubscribes ("A subscriber SHOULD individually unsubscribe from each existing @@ -427,9 +428,13 @@ Known protocol gaps, roughly ordered by how load-bearing they are: - **Malformed tracks (§2.4.2, §9.1, §12.8, §12.9)** — the session reports Object Properties that make a track malformed (`session.ErrMalformedTrack`), and the relay then ends the track: PUBLISH_DONE MALFORMED_TRACK to every - downstream subscriber, its subscription to that publisher cancelled, the - Objects triggering it not cached (removed, if earlier ones were). The relay - also detects, on live subgroup and datagram Objects of any upstream, against + downstream subscriber; every downstream fetch stream (fill fetch streams + included), and a FETCH's request stream, reset with MALFORMED_TRACK (§3.3.3, + the maintainer's choice); its subscription and FETCHes to that publisher + cancelled (an upstream FETCH to another publisher ends with its downstream + stream, with CANCELLED); the Objects triggering it not cached (removed, if + earlier ones were). The relay also detects, on live subgroup and datagram + Objects of any upstream, against the last 32 Groups (`registry.TrackEntry.ClaimDelivered`, `SubgroupEnded`, `RecordDuplicate`): §2.4.2's list — a Subgroup's Publisher Priority changing; an Object past a Subgroup's, Group's or Track's end, or two @@ -455,9 +460,7 @@ Known protocol gaps, roughly ordered by how load-bearing they are: a relay's, past the window, and a duplicate once the first copy left the cache (evicted or expired) or before a concurrent contributor cached it. An Object another upstream had claimed but not yet cached when a later end put - it past that end stays cached. A downstream FETCH already being served from - the cache when the track is found malformed is not reset: the relay does not - track fetch streams per track. One interpretation: an Object with two + it past that end stays cached. One interpretation: an Object with two Immutable Properties is treated as malformed, although §12.7 states "MUST NOT contain more than one instance" outside its list of malformed conditions. @@ -530,44 +533,39 @@ Session layer: Relay: -- Any REQUEST_UPDATE turns INCLUDE_PROPERTIES=0 back off, so the subscriber - resolves the wrong default Publisher Priority. §10.9: a parameter absent from - REQUEST_UPDATE "remains unchanged", and INCLUDE_PROPERTIES cannot appear in - one (§10.2.21, §12.4). -- A merged Subgroup FINs when one contributor ends cleanly although its Objects - began after ones a reset contributor never delivered (§11.4.3). -- Replay streams (joiners, gap and properties reopens) lose the first Object's - delivery-timeout override (§8, §12.1, §12.2). -- FETCH_OK never sets End Of Track (§10.14). -- A cancelled FETCH keeps writing its data stream (§5.2: "MUST reset"). -- Objects from an upstream FETCH are exempt from MAX_CACHE_DURATION, and cached - Objects age from when they were read whole rather than their beginning (§12.3). -- A fill range is evaluated against a later Largest Object than SUBSCRIBE_OK or - REQUEST_UPDATE_OK reported (§5.1.3). -- TRACK_STATUS returns DOES_NOT_EXIST for a PUBLISHed track with no properties - or Objects, which SUBSCRIBE accepts (§10.15: "treats it identically"). -- A client cannot SUBSCRIBE to a track it publishes under its own - PUBLISH_NAMESPACE (§5.1). -- RENDEZVOUS_TIMEOUT is ignored (§10.2.6 SHOULD hold the subscription; §9.5). -- REQUEST_ERROR MALFORMED_TRACK, defined for FETCH, answers SUBSCRIBE, PUBLISH - and REQUEST_UPDATE failures (§10.6.2). -- The relay keeps initiating requests on a session it sent GOAWAY to (§10.4 - SHOULD avoid), and closes with GOAWAY_TIMEOUT when it sent none (§3.5). -- A PUBLISH can follow PUBLISH_SKIPPED for the same upstream PUBLISH after a - prefix update moves away and back (§6.1). -- Upstream FETCHes to a publisher whose track is found malformed are not - cancelled (§2.4.2). +- Objects age from when they were read whole rather than their beginning + (§12.3). +- TRACK_STATUS is not answered as SUBSCRIBE would be in two cases (§10.15): + with only the namespace advertised it answers an empty OK where SUBSCRIBE + goes upstream and may fail, and a leftover entry with Track Properties or a + LARGEST_OBJECT but no established upstream answers OK where SUBSCRIBE would + go upstream. +- A client's SUBSCRIBE to a track it publishes gets DOES_NOT_EXIST while an + upstream SUBSCRIBE for that track to the client is still pending, and its + FETCH marks a hole unknown while a stitch FETCH for the track to it is in + flight: the relay cannot tell either from its own request routed back to it + by a relay peer (§6.2 has no loop protection), so it declines the second hop + rather than loop. Self-subscriptions are otherwise "identical" (§5.1). +- A SUBSCRIBE whose only candidate upstream is a draining relay reached through + the upstream pool gets DOES_NOT_EXIST, not the GOING_AWAY a draining local + publisher yields: the pool skips such a relay before any request. +- A session that registers after `Stop` began is drained on its own grace + period and ignores `Stop`'s ctx, so a cancelled `Stop` can still wait up to + `GoawayTimeout` for it. - Filters are not aggregated upstream (§6.3.1 SHOULD). -- A REQUEST_UPDATE's AUTHORIZATION_TOKENs go through the TokenVerifier only on - SUBSCRIBE_NAMESPACE and SUBSCRIBE_TRACKS; on SUBSCRIBE, FETCH and - PUBLISH_NAMESPACE they are resolved but not verified (§10.2.2). +- Cancelling `Start`'s ctx ends each session's handler without closing the + session, and drops it from the set `Stop` closes; `Stop` then waits without + bound on the reader of an upstream SUBSCRIBE on such a session, whose stream + stays open. `Start`'s doc says the cancel "terminates live sessions". + Reproduced by two relays wired through Discovery and stopped in `t.Cleanup` + (after `t.Context` ends) while a cross-relay subscription is live. Documentation: - Limitations: "Duplicate Objects … are not compared" is stale; the LOC entry names `PropAudioLevel = 0x0A` (it is 0x0C); "Handles the application reads itself" says `CheckPeerParams` checks roles; "Inbound GOAWAY" omits request streams. -- Table rows 10.2.6, 10.2.15, 10.2.21 and 12.3 overstate what is done (see +- Table rows 10.2.15 and 10.2.21 overstate what is done (see the items above), and the package summary still lists joining FETCH. - `session/namespace.go` says NAMESPACE / NAMESPACE_DONE go on a PUBLISH_NAMESPACE stream (§10.17, §10.18). diff --git a/benchmarks/baseline-go1.27.txt b/benchmarks/baseline-go1.27.txt index fce101f6..6d815bde 100644 --- a/benchmarks/baseline-go1.27.txt +++ b/benchmarks/baseline-go1.27.txt @@ -2,219 +2,229 @@ goos: darwin goarch: arm64 pkg: github.com/floatdrop/moq-go/pkg/moqt/wire cpu: Apple M3 Max -BenchmarkVarintEncode-14 321027895 3.738 ns/op 0 B/op 0 allocs/op -BenchmarkVarintEncode-14 321830211 3.726 ns/op 0 B/op 0 allocs/op -BenchmarkVarintEncode-14 311733856 3.936 ns/op 0 B/op 0 allocs/op -BenchmarkVarintEncode-14 289130354 4.019 ns/op 0 B/op 0 allocs/op -BenchmarkVarintEncode-14 317602628 3.864 ns/op 0 B/op 0 allocs/op -BenchmarkVarintEncode-14 314398329 3.797 ns/op 0 B/op 0 allocs/op -BenchmarkVarintEncode-14 287868932 4.083 ns/op 0 B/op 0 allocs/op -BenchmarkVarintEncode-14 300245230 3.978 ns/op 0 B/op 0 allocs/op -BenchmarkVarintEncode-14 297443534 4.061 ns/op 0 B/op 0 allocs/op -BenchmarkVarintEncode-14 300640990 4.029 ns/op 0 B/op 0 allocs/op -BenchmarkVarintBytesRoundTrip-14 5142760 242.7 ns/op 4943.77 MB/s 1280 B/op 1 allocs/op -BenchmarkVarintBytesRoundTrip-14 5087658 243.5 ns/op 4928.38 MB/s 1280 B/op 1 allocs/op -BenchmarkVarintBytesRoundTrip-14 4648306 249.0 ns/op 4819.79 MB/s 1280 B/op 1 allocs/op -BenchmarkVarintBytesRoundTrip-14 4887609 250.4 ns/op 4791.88 MB/s 1280 B/op 1 allocs/op -BenchmarkVarintBytesRoundTrip-14 5076520 253.7 ns/op 4729.96 MB/s 1280 B/op 1 allocs/op -BenchmarkVarintBytesRoundTrip-14 5084828 199.6 ns/op 6013.04 MB/s 1280 B/op 1 allocs/op -BenchmarkVarintBytesRoundTrip-14 7702612 160.6 ns/op 7471.10 MB/s 1280 B/op 1 allocs/op -BenchmarkVarintBytesRoundTrip-14 7449004 166.2 ns/op 7221.12 MB/s 1280 B/op 1 allocs/op -BenchmarkVarintBytesRoundTrip-14 7306693 162.7 ns/op 7373.97 MB/s 1280 B/op 1 allocs/op -BenchmarkVarintBytesRoundTrip-14 7294168 161.9 ns/op 7410.88 MB/s 1280 B/op 1 allocs/op -BenchmarkKVPairsEncode-14 24544665 48.55 ns/op 0 B/op 0 allocs/op -BenchmarkKVPairsEncode-14 24405972 48.27 ns/op 0 B/op 0 allocs/op -BenchmarkKVPairsEncode-14 25261850 47.81 ns/op 0 B/op 0 allocs/op -BenchmarkKVPairsEncode-14 24848878 48.41 ns/op 0 B/op 0 allocs/op -BenchmarkKVPairsEncode-14 24244953 47.97 ns/op 0 B/op 0 allocs/op -BenchmarkKVPairsEncode-14 24672404 47.84 ns/op 0 B/op 0 allocs/op -BenchmarkKVPairsEncode-14 24905457 47.65 ns/op 0 B/op 0 allocs/op -BenchmarkKVPairsEncode-14 25253654 47.62 ns/op 0 B/op 0 allocs/op -BenchmarkKVPairsEncode-14 25326674 47.66 ns/op 0 B/op 0 allocs/op -BenchmarkKVPairsEncode-14 24211262 48.21 ns/op 0 B/op 0 allocs/op -BenchmarkFrameRoundTrip-14 7064598 169.0 ns/op 7099.17 MB/s 1286 B/op 3 allocs/op -BenchmarkFrameRoundTrip-14 7093482 170.8 ns/op 7026.94 MB/s 1286 B/op 3 allocs/op -BenchmarkFrameRoundTrip-14 7089346 171.1 ns/op 7015.24 MB/s 1286 B/op 3 allocs/op -BenchmarkFrameRoundTrip-14 6903037 173.4 ns/op 6922.13 MB/s 1286 B/op 3 allocs/op -BenchmarkFrameRoundTrip-14 6599911 175.5 ns/op 6837.05 MB/s 1286 B/op 3 allocs/op -BenchmarkFrameRoundTrip-14 6869698 173.5 ns/op 6916.39 MB/s 1286 B/op 3 allocs/op -BenchmarkFrameRoundTrip-14 6986758 170.8 ns/op 7026.54 MB/s 1286 B/op 3 allocs/op -BenchmarkFrameRoundTrip-14 7136299 169.9 ns/op 7063.08 MB/s 1286 B/op 3 allocs/op -BenchmarkFrameRoundTrip-14 7066100 169.3 ns/op 7089.02 MB/s 1286 B/op 3 allocs/op -BenchmarkFrameRoundTrip-14 6962319 173.2 ns/op 6927.89 MB/s 1286 B/op 3 allocs/op +BenchmarkVarintEncode-14 295981798 3.829 ns/op 0 B/op 0 allocs/op +BenchmarkVarintEncode-14 322084926 3.723 ns/op 0 B/op 0 allocs/op +BenchmarkVarintEncode-14 324213368 3.773 ns/op 0 B/op 0 allocs/op +BenchmarkVarintEncode-14 325912597 3.701 ns/op 0 B/op 0 allocs/op +BenchmarkVarintEncode-14 322598313 3.742 ns/op 0 B/op 0 allocs/op +BenchmarkVarintEncode-14 323638705 3.713 ns/op 0 B/op 0 allocs/op +BenchmarkVarintEncode-14 320933989 3.739 ns/op 0 B/op 0 allocs/op +BenchmarkVarintEncode-14 307532594 3.818 ns/op 0 B/op 0 allocs/op +BenchmarkVarintEncode-14 321608614 3.733 ns/op 0 B/op 0 allocs/op +BenchmarkVarintEncode-14 322690015 3.740 ns/op 0 B/op 0 allocs/op +BenchmarkVarintBytesRoundTrip-14 7549238 157.7 ns/op 7607.66 MB/s 1280 B/op 1 allocs/op +BenchmarkVarintBytesRoundTrip-14 7676830 157.8 ns/op 7602.62 MB/s 1280 B/op 1 allocs/op +BenchmarkVarintBytesRoundTrip-14 7940700 153.8 ns/op 7800.23 MB/s 1280 B/op 1 allocs/op +BenchmarkVarintBytesRoundTrip-14 7883989 153.9 ns/op 7798.64 MB/s 1280 B/op 1 allocs/op +BenchmarkVarintBytesRoundTrip-14 7971604 156.3 ns/op 7678.28 MB/s 1280 B/op 1 allocs/op +BenchmarkVarintBytesRoundTrip-14 8028280 153.2 ns/op 7832.46 MB/s 1280 B/op 1 allocs/op +BenchmarkVarintBytesRoundTrip-14 7552918 158.3 ns/op 7579.34 MB/s 1280 B/op 1 allocs/op +BenchmarkVarintBytesRoundTrip-14 7862512 153.0 ns/op 7841.02 MB/s 1280 B/op 1 allocs/op +BenchmarkVarintBytesRoundTrip-14 7798154 154.9 ns/op 7747.96 MB/s 1280 B/op 1 allocs/op +BenchmarkVarintBytesRoundTrip-14 7636155 154.7 ns/op 7756.75 MB/s 1280 B/op 1 allocs/op +BenchmarkKVPairsEncode-14 23846527 49.63 ns/op 0 B/op 0 allocs/op +BenchmarkKVPairsEncode-14 24517168 49.18 ns/op 0 B/op 0 allocs/op +BenchmarkKVPairsEncode-14 23607834 49.46 ns/op 0 B/op 0 allocs/op +BenchmarkKVPairsEncode-14 24345235 49.18 ns/op 0 B/op 0 allocs/op +BenchmarkKVPairsEncode-14 24545880 49.48 ns/op 0 B/op 0 allocs/op +BenchmarkKVPairsEncode-14 23208774 51.05 ns/op 0 B/op 0 allocs/op +BenchmarkKVPairsEncode-14 24205321 49.52 ns/op 0 B/op 0 allocs/op +BenchmarkKVPairsEncode-14 24291026 49.06 ns/op 0 B/op 0 allocs/op +BenchmarkKVPairsEncode-14 24288506 49.47 ns/op 0 B/op 0 allocs/op +BenchmarkKVPairsEncode-14 24167196 49.46 ns/op 0 B/op 0 allocs/op +BenchmarkFrameRoundTrip-14 7213255 169.6 ns/op 7075.83 MB/s 1286 B/op 3 allocs/op +BenchmarkFrameRoundTrip-14 7041984 171.7 ns/op 6987.07 MB/s 1286 B/op 3 allocs/op +BenchmarkFrameRoundTrip-14 6903937 172.3 ns/op 6965.55 MB/s 1286 B/op 3 allocs/op +BenchmarkFrameRoundTrip-14 6657242 175.2 ns/op 6851.01 MB/s 1286 B/op 3 allocs/op +BenchmarkFrameRoundTrip-14 7099976 171.1 ns/op 7011.97 MB/s 1286 B/op 3 allocs/op +BenchmarkFrameRoundTrip-14 6887997 175.4 ns/op 6841.85 MB/s 1286 B/op 3 allocs/op +BenchmarkFrameRoundTrip-14 6904917 174.2 ns/op 6889.71 MB/s 1286 B/op 3 allocs/op +BenchmarkFrameRoundTrip-14 6942393 169.6 ns/op 7074.29 MB/s 1286 B/op 3 allocs/op +BenchmarkFrameRoundTrip-14 7331383 165.9 ns/op 7234.15 MB/s 1286 B/op 3 allocs/op +BenchmarkFrameRoundTrip-14 6936639 166.2 ns/op 7222.29 MB/s 1286 B/op 3 allocs/op PASS -ok github.com/floatdrop/moq-go/pkg/moqt/wire 48.448s +ok github.com/floatdrop/moq-go/pkg/moqt/wire 48.205s goos: darwin goarch: arm64 pkg: github.com/floatdrop/moq-go/pkg/moqt/message cpu: Apple M3 Max -BenchmarkSubgroupObjectAppend-14 53739639 22.05 ns/op 54428.64 MB/s 0 B/op 0 allocs/op -BenchmarkSubgroupObjectAppend-14 55184781 21.66 ns/op 55403.74 MB/s 0 B/op 0 allocs/op -BenchmarkSubgroupObjectAppend-14 55256781 21.60 ns/op 55545.76 MB/s 0 B/op 0 allocs/op -BenchmarkSubgroupObjectAppend-14 55371624 21.55 ns/op 55688.53 MB/s 0 B/op 0 allocs/op -BenchmarkSubgroupObjectAppend-14 55471128 21.65 ns/op 55420.26 MB/s 0 B/op 0 allocs/op -BenchmarkSubgroupObjectAppend-14 55526313 21.68 ns/op 55357.21 MB/s 0 B/op 0 allocs/op -BenchmarkSubgroupObjectAppend-14 55510581 21.64 ns/op 55464.33 MB/s 0 B/op 0 allocs/op -BenchmarkSubgroupObjectAppend-14 55566058 21.63 ns/op 55466.82 MB/s 0 B/op 0 allocs/op -BenchmarkSubgroupObjectAppend-14 55433652 21.66 ns/op 55410.62 MB/s 0 B/op 0 allocs/op -BenchmarkSubgroupObjectAppend-14 55478502 21.63 ns/op 55489.95 MB/s 0 B/op 0 allocs/op -BenchmarkSubgroupObjectParse-14 8326988 145.6 ns/op 8241.96 MB/s 1312 B/op 2 allocs/op -BenchmarkSubgroupObjectParse-14 8328770 143.9 ns/op 8339.10 MB/s 1312 B/op 2 allocs/op -BenchmarkSubgroupObjectParse-14 8242035 144.2 ns/op 8321.23 MB/s 1312 B/op 2 allocs/op -BenchmarkSubgroupObjectParse-14 8467743 145.7 ns/op 8235.25 MB/s 1312 B/op 2 allocs/op -BenchmarkSubgroupObjectParse-14 8405872 156.0 ns/op 7694.70 MB/s 1312 B/op 2 allocs/op -BenchmarkSubgroupObjectParse-14 7906308 150.7 ns/op 7962.69 MB/s 1312 B/op 2 allocs/op -BenchmarkSubgroupObjectParse-14 8052553 148.4 ns/op 8085.32 MB/s 1312 B/op 2 allocs/op -BenchmarkSubgroupObjectParse-14 8363772 144.7 ns/op 8294.88 MB/s 1312 B/op 2 allocs/op -BenchmarkSubgroupObjectParse-14 8332819 144.3 ns/op 8315.87 MB/s 1312 B/op 2 allocs/op -BenchmarkSubgroupObjectParse-14 8315462 144.1 ns/op 8326.85 MB/s 1312 B/op 2 allocs/op -BenchmarkMarshalRoundTrip/SUBSCRIBE-14 3254118 370.9 ns/op 712 B/op 18 allocs/op -BenchmarkMarshalRoundTrip/SUBSCRIBE-14 3225433 372.1 ns/op 712 B/op 18 allocs/op -BenchmarkMarshalRoundTrip/SUBSCRIBE-14 3228500 372.0 ns/op 712 B/op 18 allocs/op -BenchmarkMarshalRoundTrip/SUBSCRIBE-14 3240044 370.2 ns/op 712 B/op 18 allocs/op -BenchmarkMarshalRoundTrip/SUBSCRIBE-14 3225062 371.9 ns/op 712 B/op 18 allocs/op -BenchmarkMarshalRoundTrip/SUBSCRIBE-14 3248653 372.3 ns/op 712 B/op 18 allocs/op -BenchmarkMarshalRoundTrip/SUBSCRIBE-14 3214995 374.9 ns/op 712 B/op 18 allocs/op -BenchmarkMarshalRoundTrip/SUBSCRIBE-14 3229539 373.6 ns/op 712 B/op 18 allocs/op -BenchmarkMarshalRoundTrip/SUBSCRIBE-14 3227611 372.6 ns/op 712 B/op 18 allocs/op -BenchmarkMarshalRoundTrip/SUBSCRIBE-14 3221492 373.3 ns/op 712 B/op 18 allocs/op -BenchmarkMarshalRoundTrip/REQUEST_OK-14 11056362 108.8 ns/op 144 B/op 8 allocs/op -BenchmarkMarshalRoundTrip/REQUEST_OK-14 11001927 111.0 ns/op 144 B/op 8 allocs/op -BenchmarkMarshalRoundTrip/REQUEST_OK-14 10972105 108.9 ns/op 144 B/op 8 allocs/op -BenchmarkMarshalRoundTrip/REQUEST_OK-14 11105742 110.5 ns/op 144 B/op 8 allocs/op -BenchmarkMarshalRoundTrip/REQUEST_OK-14 10968858 109.5 ns/op 144 B/op 8 allocs/op -BenchmarkMarshalRoundTrip/REQUEST_OK-14 11109859 107.9 ns/op 144 B/op 8 allocs/op -BenchmarkMarshalRoundTrip/REQUEST_OK-14 11070097 108.4 ns/op 144 B/op 8 allocs/op -BenchmarkMarshalRoundTrip/REQUEST_OK-14 10922756 110.0 ns/op 144 B/op 8 allocs/op -BenchmarkMarshalRoundTrip/REQUEST_OK-14 10782663 111.6 ns/op 144 B/op 8 allocs/op -BenchmarkMarshalRoundTrip/REQUEST_OK-14 10736396 110.9 ns/op 144 B/op 8 allocs/op -BenchmarkParametersRoundTrip-14 6803314 178.7 ns/op 450 B/op 3 allocs/op -BenchmarkParametersRoundTrip-14 6620612 176.9 ns/op 450 B/op 3 allocs/op -BenchmarkParametersRoundTrip-14 6817016 175.4 ns/op 450 B/op 3 allocs/op -BenchmarkParametersRoundTrip-14 6736610 179.0 ns/op 450 B/op 3 allocs/op -BenchmarkParametersRoundTrip-14 6571808 175.9 ns/op 450 B/op 3 allocs/op -BenchmarkParametersRoundTrip-14 6919807 174.3 ns/op 450 B/op 3 allocs/op -BenchmarkParametersRoundTrip-14 6804342 175.4 ns/op 450 B/op 3 allocs/op -BenchmarkParametersRoundTrip-14 6610230 175.1 ns/op 450 B/op 3 allocs/op -BenchmarkParametersRoundTrip-14 6921813 173.7 ns/op 450 B/op 3 allocs/op -BenchmarkParametersRoundTrip-14 6933732 174.4 ns/op 450 B/op 3 allocs/op +BenchmarkSubgroupObjectAppend-14 52756110 22.25 ns/op 53931.15 MB/s 0 B/op 0 allocs/op +BenchmarkSubgroupObjectAppend-14 55016952 21.73 ns/op 55217.67 MB/s 0 B/op 0 allocs/op +BenchmarkSubgroupObjectAppend-14 55500312 21.73 ns/op 55232.75 MB/s 0 B/op 0 allocs/op +BenchmarkSubgroupObjectAppend-14 55270884 21.71 ns/op 55261.67 MB/s 0 B/op 0 allocs/op +BenchmarkSubgroupObjectAppend-14 55485663 21.79 ns/op 55076.97 MB/s 0 B/op 0 allocs/op +BenchmarkSubgroupObjectAppend-14 55550198 21.72 ns/op 55256.18 MB/s 0 B/op 0 allocs/op +BenchmarkSubgroupObjectAppend-14 55549341 21.70 ns/op 55306.53 MB/s 0 B/op 0 allocs/op +BenchmarkSubgroupObjectAppend-14 55230289 21.74 ns/op 55201.14 MB/s 0 B/op 0 allocs/op +BenchmarkSubgroupObjectAppend-14 55135446 21.71 ns/op 55283.05 MB/s 0 B/op 0 allocs/op +BenchmarkSubgroupObjectAppend-14 55361938 21.78 ns/op 55106.17 MB/s 0 B/op 0 allocs/op +BenchmarkSubgroupObjectParse-14 8226066 143.0 ns/op 8389.62 MB/s 1312 B/op 2 allocs/op +BenchmarkSubgroupObjectParse-14 7927676 152.3 ns/op 7876.77 MB/s 1312 B/op 2 allocs/op +BenchmarkSubgroupObjectParse-14 7912317 148.6 ns/op 8073.72 MB/s 1312 B/op 2 allocs/op +BenchmarkSubgroupObjectParse-14 7989118 148.6 ns/op 8076.86 MB/s 1312 B/op 2 allocs/op +BenchmarkSubgroupObjectParse-14 7748602 153.4 ns/op 7821.08 MB/s 1312 B/op 2 allocs/op +BenchmarkSubgroupObjectParse-14 7956508 153.0 ns/op 7842.09 MB/s 1312 B/op 2 allocs/op +BenchmarkSubgroupObjectParse-14 8232489 151.3 ns/op 7932.32 MB/s 1312 B/op 2 allocs/op +BenchmarkSubgroupObjectParse-14 8285191 150.5 ns/op 7971.99 MB/s 1312 B/op 2 allocs/op +BenchmarkSubgroupObjectParse-14 7762840 152.9 ns/op 7847.45 MB/s 1312 B/op 2 allocs/op +BenchmarkSubgroupObjectParse-14 7790086 149.8 ns/op 8009.32 MB/s 1312 B/op 2 allocs/op +BenchmarkMarshalRoundTrip/SUBSCRIBE-14 3136048 391.3 ns/op 712 B/op 18 allocs/op +BenchmarkMarshalRoundTrip/SUBSCRIBE-14 2982566 401.7 ns/op 712 B/op 18 allocs/op +BenchmarkMarshalRoundTrip/SUBSCRIBE-14 3024003 388.4 ns/op 712 B/op 18 allocs/op +BenchmarkMarshalRoundTrip/SUBSCRIBE-14 3104505 383.9 ns/op 712 B/op 18 allocs/op +BenchmarkMarshalRoundTrip/SUBSCRIBE-14 3100698 388.2 ns/op 712 B/op 18 allocs/op +BenchmarkMarshalRoundTrip/SUBSCRIBE-14 2977452 406.1 ns/op 712 B/op 18 allocs/op +BenchmarkMarshalRoundTrip/SUBSCRIBE-14 3003663 392.8 ns/op 712 B/op 18 allocs/op +BenchmarkMarshalRoundTrip/SUBSCRIBE-14 3095326 388.7 ns/op 712 B/op 18 allocs/op +BenchmarkMarshalRoundTrip/SUBSCRIBE-14 3056283 389.9 ns/op 712 B/op 18 allocs/op +BenchmarkMarshalRoundTrip/SUBSCRIBE-14 3126422 384.9 ns/op 712 B/op 18 allocs/op +BenchmarkMarshalRoundTrip/REQUEST_OK-14 10646713 114.4 ns/op 144 B/op 8 allocs/op +BenchmarkMarshalRoundTrip/REQUEST_OK-14 10479216 112.8 ns/op 144 B/op 8 allocs/op +BenchmarkMarshalRoundTrip/REQUEST_OK-14 10651777 112.4 ns/op 144 B/op 8 allocs/op +BenchmarkMarshalRoundTrip/REQUEST_OK-14 10538190 114.5 ns/op 144 B/op 8 allocs/op +BenchmarkMarshalRoundTrip/REQUEST_OK-14 9730482 117.0 ns/op 144 B/op 8 allocs/op +BenchmarkMarshalRoundTrip/REQUEST_OK-14 10321244 114.6 ns/op 144 B/op 8 allocs/op +BenchmarkMarshalRoundTrip/REQUEST_OK-14 10602782 113.0 ns/op 144 B/op 8 allocs/op +BenchmarkMarshalRoundTrip/REQUEST_OK-14 10469304 115.1 ns/op 144 B/op 8 allocs/op +BenchmarkMarshalRoundTrip/REQUEST_OK-14 10514528 112.6 ns/op 144 B/op 8 allocs/op +BenchmarkMarshalRoundTrip/REQUEST_OK-14 10610563 116.9 ns/op 144 B/op 8 allocs/op +BenchmarkParametersRoundTrip-14 6557442 183.4 ns/op 450 B/op 3 allocs/op +BenchmarkParametersRoundTrip-14 6740769 178.2 ns/op 450 B/op 3 allocs/op +BenchmarkParametersRoundTrip-14 6747019 177.2 ns/op 450 B/op 3 allocs/op +BenchmarkParametersRoundTrip-14 6780544 175.6 ns/op 450 B/op 3 allocs/op +BenchmarkParametersRoundTrip-14 6869194 175.2 ns/op 450 B/op 3 allocs/op +BenchmarkParametersRoundTrip-14 6840391 175.6 ns/op 450 B/op 3 allocs/op +BenchmarkParametersRoundTrip-14 6846596 176.1 ns/op 450 B/op 3 allocs/op +BenchmarkParametersRoundTrip-14 6856827 176.3 ns/op 450 B/op 3 allocs/op +BenchmarkParametersRoundTrip-14 6816386 175.5 ns/op 450 B/op 3 allocs/op +BenchmarkParametersRoundTrip-14 6777985 176.4 ns/op 450 B/op 3 allocs/op +BenchmarkCheckObjectProperties-14 27061056 44.33 ns/op 0 B/op 0 allocs/op +BenchmarkCheckObjectProperties-14 27117417 44.24 ns/op 0 B/op 0 allocs/op +BenchmarkCheckObjectProperties-14 27048957 44.37 ns/op 0 B/op 0 allocs/op +BenchmarkCheckObjectProperties-14 27069118 44.36 ns/op 0 B/op 0 allocs/op +BenchmarkCheckObjectProperties-14 27101240 44.60 ns/op 0 B/op 0 allocs/op +BenchmarkCheckObjectProperties-14 26939024 44.35 ns/op 0 B/op 0 allocs/op +BenchmarkCheckObjectProperties-14 27020636 44.61 ns/op 0 B/op 0 allocs/op +BenchmarkCheckObjectProperties-14 27185994 44.44 ns/op 0 B/op 0 allocs/op +BenchmarkCheckObjectProperties-14 27032100 44.71 ns/op 0 B/op 0 allocs/op +BenchmarkCheckObjectProperties-14 27141363 44.61 ns/op 0 B/op 0 allocs/op PASS -ok github.com/floatdrop/moq-go/pkg/moqt/message 60.537s +ok github.com/floatdrop/moq-go/pkg/moqt/message 72.398s goos: darwin goarch: arm64 pkg: github.com/floatdrop/moq-go/pkg/moqt/session cpu: Apple M3 Max -BenchmarkSubgroupForwardCodec-14 4958886 230.6 ns/op 5203.66 MB/s 1345 B/op 2 allocs/op -BenchmarkSubgroupForwardCodec-14 5207600 232.0 ns/op 5172.98 MB/s 1345 B/op 2 allocs/op -BenchmarkSubgroupForwardCodec-14 5167160 229.6 ns/op 5227.18 MB/s 1345 B/op 2 allocs/op -BenchmarkSubgroupForwardCodec-14 5238770 227.9 ns/op 5265.60 MB/s 1345 B/op 2 allocs/op -BenchmarkSubgroupForwardCodec-14 5196810 231.3 ns/op 5188.76 MB/s 1345 B/op 2 allocs/op -BenchmarkSubgroupForwardCodec-14 5151434 233.8 ns/op 5131.78 MB/s 1345 B/op 2 allocs/op -BenchmarkSubgroupForwardCodec-14 5221936 233.5 ns/op 5139.61 MB/s 1345 B/op 2 allocs/op -BenchmarkSubgroupForwardCodec-14 5108614 232.7 ns/op 5156.18 MB/s 1345 B/op 2 allocs/op -BenchmarkSubgroupForwardCodec-14 4962627 233.4 ns/op 5141.02 MB/s 1345 B/op 2 allocs/op -BenchmarkSubgroupForwardCodec-14 5162101 235.1 ns/op 5104.66 MB/s 1345 B/op 2 allocs/op -BenchmarkControlRoundTrip-14 237145 4839 ns/op 2295 B/op 46 allocs/op -BenchmarkControlRoundTrip-14 251162 4895 ns/op 2284 B/op 46 allocs/op -BenchmarkControlRoundTrip-14 235059 4856 ns/op 2296 B/op 46 allocs/op -BenchmarkControlRoundTrip-14 251352 4860 ns/op 2284 B/op 46 allocs/op -BenchmarkControlRoundTrip-14 250308 4842 ns/op 2285 B/op 46 allocs/op -BenchmarkControlRoundTrip-14 262777 4725 ns/op 2276 B/op 46 allocs/op -BenchmarkControlRoundTrip-14 243777 4725 ns/op 2289 B/op 46 allocs/op -BenchmarkControlRoundTrip-14 241180 4819 ns/op 2292 B/op 46 allocs/op -BenchmarkControlRoundTrip-14 239038 4831 ns/op 2293 B/op 46 allocs/op -BenchmarkControlRoundTrip-14 252574 4840 ns/op 2283 B/op 46 allocs/op -BenchmarkSubgroupThroughput-14 1500952 795.7 ns/op 1508.15 MB/s 1344 B/op 2 allocs/op -BenchmarkSubgroupThroughput-14 1482056 792.6 ns/op 1513.97 MB/s 1344 B/op 2 allocs/op -BenchmarkSubgroupThroughput-14 1536045 798.5 ns/op 1502.77 MB/s 1344 B/op 2 allocs/op -BenchmarkSubgroupThroughput-14 1501648 798.9 ns/op 1502.10 MB/s 1344 B/op 2 allocs/op -BenchmarkSubgroupThroughput-14 1489243 799.9 ns/op 1500.16 MB/s 1344 B/op 2 allocs/op -BenchmarkSubgroupThroughput-14 1513743 797.5 ns/op 1504.61 MB/s 1344 B/op 2 allocs/op -BenchmarkSubgroupThroughput-14 1488202 801.9 ns/op 1496.52 MB/s 1344 B/op 2 allocs/op -BenchmarkSubgroupThroughput-14 1492542 804.8 ns/op 1491.07 MB/s 1344 B/op 2 allocs/op -BenchmarkSubgroupThroughput-14 1493115 802.3 ns/op 1495.74 MB/s 1344 B/op 2 allocs/op -BenchmarkSubgroupThroughput-14 1512944 793.8 ns/op 1511.67 MB/s 1344 B/op 2 allocs/op +BenchmarkSubgroupForwardCodec-14 4807971 238.5 ns/op 5030.57 MB/s 1345 B/op 2 allocs/op +BenchmarkSubgroupForwardCodec-14 5174083 235.8 ns/op 5089.62 MB/s 1345 B/op 2 allocs/op +BenchmarkSubgroupForwardCodec-14 5062897 236.3 ns/op 5078.86 MB/s 1345 B/op 2 allocs/op +BenchmarkSubgroupForwardCodec-14 5160572 233.8 ns/op 5132.76 MB/s 1345 B/op 2 allocs/op +BenchmarkSubgroupForwardCodec-14 4972740 237.9 ns/op 5044.98 MB/s 1345 B/op 2 allocs/op +BenchmarkSubgroupForwardCodec-14 5099942 232.0 ns/op 5171.51 MB/s 1345 B/op 2 allocs/op +BenchmarkSubgroupForwardCodec-14 5219554 232.1 ns/op 5170.25 MB/s 1345 B/op 2 allocs/op +BenchmarkSubgroupForwardCodec-14 5182874 233.0 ns/op 5150.25 MB/s 1345 B/op 2 allocs/op +BenchmarkSubgroupForwardCodec-14 5063281 239.4 ns/op 5013.10 MB/s 1345 B/op 2 allocs/op +BenchmarkSubgroupForwardCodec-14 5036644 240.9 ns/op 4981.60 MB/s 1345 B/op 2 allocs/op +BenchmarkControlRoundTrip-14 248739 4647 ns/op 2494 B/op 47 allocs/op +BenchmarkControlRoundTrip-14 253561 4651 ns/op 2490 B/op 47 allocs/op +BenchmarkControlRoundTrip-14 264484 4694 ns/op 2483 B/op 47 allocs/op +BenchmarkControlRoundTrip-14 260314 4610 ns/op 2485 B/op 47 allocs/op +BenchmarkControlRoundTrip-14 266079 4630 ns/op 2482 B/op 47 allocs/op +BenchmarkControlRoundTrip-14 260312 4658 ns/op 2485 B/op 47 allocs/op +BenchmarkControlRoundTrip-14 266318 4649 ns/op 2481 B/op 47 allocs/op +BenchmarkControlRoundTrip-14 261603 4677 ns/op 2485 B/op 47 allocs/op +BenchmarkControlRoundTrip-14 255108 4632 ns/op 2489 B/op 47 allocs/op +BenchmarkControlRoundTrip-14 259537 4616 ns/op 2486 B/op 47 allocs/op +BenchmarkSubgroupThroughput-14 1693988 705.2 ns/op 1701.72 MB/s 1344 B/op 2 allocs/op +BenchmarkSubgroupThroughput-14 1699359 709.9 ns/op 1690.26 MB/s 1344 B/op 2 allocs/op +BenchmarkSubgroupThroughput-14 1694817 710.4 ns/op 1689.20 MB/s 1344 B/op 2 allocs/op +BenchmarkSubgroupThroughput-14 1693683 707.6 ns/op 1695.94 MB/s 1344 B/op 2 allocs/op +BenchmarkSubgroupThroughput-14 1708935 706.1 ns/op 1699.55 MB/s 1344 B/op 2 allocs/op +BenchmarkSubgroupThroughput-14 1700211 707.2 ns/op 1696.76 MB/s 1344 B/op 2 allocs/op +BenchmarkSubgroupThroughput-14 1696519 711.4 ns/op 1686.86 MB/s 1344 B/op 2 allocs/op +BenchmarkSubgroupThroughput-14 1688952 710.8 ns/op 1688.12 MB/s 1344 B/op 2 allocs/op +BenchmarkSubgroupThroughput-14 1683288 707.8 ns/op 1695.40 MB/s 1344 B/op 2 allocs/op +BenchmarkSubgroupThroughput-14 1686952 708.7 ns/op 1693.16 MB/s 1344 B/op 2 allocs/op PASS -ok github.com/floatdrop/moq-go/pkg/moqt/session 45.030s +ok github.com/floatdrop/moq-go/pkg/moqt/session 44.093s goos: darwin goarch: arm64 pkg: github.com/floatdrop/moq-go/pkg/relay cpu: Apple M3 Max -BenchmarkFanout1to1-14 898633 1266 ns/op 948.06 MB/s 2869 B/op 5 allocs/op -BenchmarkFanout1to1-14 860376 1207 ns/op 993.96 MB/s 2862 B/op 5 allocs/op -BenchmarkFanout1to1-14 959228 1223 ns/op 981.42 MB/s 2893 B/op 5 allocs/op -BenchmarkFanout1to1-14 889719 1278 ns/op 939.02 MB/s 2866 B/op 5 allocs/op -BenchmarkFanout1to1-14 962023 1214 ns/op 988.56 MB/s 2894 B/op 5 allocs/op -BenchmarkFanout1to1-14 956028 1353 ns/op 887.19 MB/s 2893 B/op 5 allocs/op -BenchmarkFanout1to1-14 905635 1261 ns/op 951.47 MB/s 2873 B/op 5 allocs/op -BenchmarkFanout1to1-14 938260 1244 ns/op 964.25 MB/s 2889 B/op 5 allocs/op -BenchmarkFanout1to1-14 917647 1323 ns/op 907.19 MB/s 2879 B/op 5 allocs/op -BenchmarkFanout1to1-14 907959 1275 ns/op 941.23 MB/s 2873 B/op 5 allocs/op -BenchmarkFanout1toN-14 52665 22469 ns/op 53.41 MB/s 87482 B/op 130 allocs/op -BenchmarkFanout1toN-14 56452 21384 ns/op 56.12 MB/s 87462 B/op 130 allocs/op -BenchmarkFanout1toN-14 67162 20963 ns/op 57.24 MB/s 87563 B/op 131 allocs/op -BenchmarkFanout1toN-14 62376 20043 ns/op 59.87 MB/s 87558 B/op 131 allocs/op -BenchmarkFanout1toN-14 59774 20787 ns/op 57.73 MB/s 87534 B/op 130 allocs/op -BenchmarkFanout1toN-14 55854 20681 ns/op 58.02 MB/s 87537 B/op 131 allocs/op -BenchmarkFanout1toN-14 53128 20693 ns/op 57.99 MB/s 87478 B/op 130 allocs/op -BenchmarkFanout1toN-14 56359 20464 ns/op 58.64 MB/s 87535 B/op 131 allocs/op -BenchmarkFanout1toN-14 58746 19187 ns/op 62.54 MB/s 87530 B/op 130 allocs/op -BenchmarkFanout1toN-14 53722 20397 ns/op 58.83 MB/s 87532 B/op 131 allocs/op -BenchmarkFetchFromCache-14 41262 27233 ns/op 13973 B/op 115 allocs/op -BenchmarkFetchFromCache-14 41314 27282 ns/op 13972 B/op 115 allocs/op -BenchmarkFetchFromCache-14 41426 27333 ns/op 13971 B/op 115 allocs/op -BenchmarkFetchFromCache-14 41150 27767 ns/op 13974 B/op 115 allocs/op -BenchmarkFetchFromCache-14 41404 27512 ns/op 13971 B/op 115 allocs/op -BenchmarkFetchFromCache-14 42044 27738 ns/op 13966 B/op 115 allocs/op -BenchmarkFetchFromCache-14 41155 27972 ns/op 13974 B/op 115 allocs/op -BenchmarkFetchFromCache-14 41773 27677 ns/op 13968 B/op 115 allocs/op -BenchmarkFetchFromCache-14 41553 27270 ns/op 13971 B/op 115 allocs/op -BenchmarkFetchFromCache-14 41246 27555 ns/op 13973 B/op 115 allocs/op +BenchmarkFanout1to1-14 985026 1259 ns/op 952.78 MB/s 2893 B/op 5 allocs/op +BenchmarkFanout1to1-14 896488 1262 ns/op 950.97 MB/s 2868 B/op 5 allocs/op +BenchmarkFanout1to1-14 942044 1264 ns/op 949.06 MB/s 2889 B/op 5 allocs/op +BenchmarkFanout1to1-14 989154 1211 ns/op 990.82 MB/s 2893 B/op 5 allocs/op +BenchmarkFanout1to1-14 957668 1294 ns/op 927.27 MB/s 2893 B/op 5 allocs/op +BenchmarkFanout1to1-14 940278 1228 ns/op 977.27 MB/s 2889 B/op 5 allocs/op +BenchmarkFanout1to1-14 984890 1228 ns/op 977.07 MB/s 2893 B/op 5 allocs/op +BenchmarkFanout1to1-14 898436 1260 ns/op 952.07 MB/s 2868 B/op 5 allocs/op +BenchmarkFanout1to1-14 939318 1271 ns/op 944.11 MB/s 2889 B/op 5 allocs/op +BenchmarkFanout1to1-14 971491 1227 ns/op 977.73 MB/s 2894 B/op 5 allocs/op +BenchmarkFanout1toN-14 52401 23275 ns/op 51.56 MB/s 87537 B/op 131 allocs/op +BenchmarkFanout1toN-14 50312 23282 ns/op 51.54 MB/s 87538 B/op 131 allocs/op +BenchmarkFanout1toN-14 54192 23635 ns/op 50.77 MB/s 87536 B/op 131 allocs/op +BenchmarkFanout1toN-14 52334 24156 ns/op 49.68 MB/s 87439 B/op 130 allocs/op +BenchmarkFanout1toN-14 50044 26108 ns/op 45.96 MB/s 87539 B/op 131 allocs/op +BenchmarkFanout1toN-14 52208 27354 ns/op 43.87 MB/s 87538 B/op 131 allocs/op +BenchmarkFanout1toN-14 51846 27513 ns/op 43.62 MB/s 87495 B/op 130 allocs/op +BenchmarkFanout1toN-14 52090 28744 ns/op 41.75 MB/s 87310 B/op 130 allocs/op +BenchmarkFanout1toN-14 40635 29200 ns/op 41.10 MB/s 87553 B/op 131 allocs/op +BenchmarkFanout1toN-14 46783 26725 ns/op 44.90 MB/s 87544 B/op 131 allocs/op +BenchmarkFetchFromCache-14 31200 33846 ns/op 15944 B/op 134 allocs/op +BenchmarkFetchFromCache-14 39711 28115 ns/op 15941 B/op 134 allocs/op +BenchmarkFetchFromCache-14 42495 27355 ns/op 15917 B/op 134 allocs/op +BenchmarkFetchFromCache-14 41182 27905 ns/op 15928 B/op 134 allocs/op +BenchmarkFetchFromCache-14 44734 27020 ns/op 15901 B/op 134 allocs/op +BenchmarkFetchFromCache-14 42722 26652 ns/op 15914 B/op 134 allocs/op +BenchmarkFetchFromCache-14 47140 26200 ns/op 15963 B/op 134 allocs/op +BenchmarkFetchFromCache-14 40920 28001 ns/op 15931 B/op 134 allocs/op +BenchmarkFetchFromCache-14 40345 28342 ns/op 15934 B/op 134 allocs/op +BenchmarkFetchFromCache-14 42310 27394 ns/op 15919 B/op 134 allocs/op PASS -ok github.com/floatdrop/moq-go/pkg/relay 50.658s +ok github.com/floatdrop/moq-go/pkg/relay 53.087s goos: darwin goarch: arm64 pkg: github.com/floatdrop/moq-go/pkg/relay/cache cpu: Apple M3 Max -BenchmarkCachePut-14 11294546 90.69 ns/op 13232.02 MB/s 128 B/op 1 allocs/op -BenchmarkCachePut-14 13648668 94.44 ns/op 12706.54 MB/s 128 B/op 1 allocs/op -BenchmarkCachePut-14 13651656 93.06 ns/op 12894.96 MB/s 128 B/op 1 allocs/op -BenchmarkCachePut-14 13678226 93.40 ns/op 12847.80 MB/s 128 B/op 1 allocs/op -BenchmarkCachePut-14 13592079 95.02 ns/op 12628.62 MB/s 128 B/op 1 allocs/op -BenchmarkCachePut-14 13739094 94.42 ns/op 12709.25 MB/s 128 B/op 1 allocs/op -BenchmarkCachePut-14 13589673 93.49 ns/op 12835.60 MB/s 128 B/op 1 allocs/op -BenchmarkCachePut-14 13581414 94.55 ns/op 12692.18 MB/s 128 B/op 1 allocs/op -BenchmarkCachePut-14 13495414 93.81 ns/op 12791.14 MB/s 128 B/op 1 allocs/op -BenchmarkCachePut-14 13823119 94.27 ns/op 12729.17 MB/s 128 B/op 1 allocs/op -BenchmarkCacheGet/hit-14 100000000 10.31 ns/op 0 B/op 0 allocs/op -BenchmarkCacheGet/hit-14 100000000 10.31 ns/op 0 B/op 0 allocs/op -BenchmarkCacheGet/hit-14 100000000 10.32 ns/op 0 B/op 0 allocs/op -BenchmarkCacheGet/hit-14 100000000 10.34 ns/op 0 B/op 0 allocs/op -BenchmarkCacheGet/hit-14 100000000 10.37 ns/op 0 B/op 0 allocs/op -BenchmarkCacheGet/hit-14 100000000 10.36 ns/op 0 B/op 0 allocs/op -BenchmarkCacheGet/hit-14 100000000 10.34 ns/op 0 B/op 0 allocs/op -BenchmarkCacheGet/hit-14 100000000 10.35 ns/op 0 B/op 0 allocs/op -BenchmarkCacheGet/hit-14 100000000 10.35 ns/op 0 B/op 0 allocs/op -BenchmarkCacheGet/hit-14 100000000 10.33 ns/op 0 B/op 0 allocs/op -BenchmarkCacheGet/miss-14 134774263 8.894 ns/op 0 B/op 0 allocs/op -BenchmarkCacheGet/miss-14 134727828 8.944 ns/op 0 B/op 0 allocs/op -BenchmarkCacheGet/miss-14 133648142 8.921 ns/op 0 B/op 0 allocs/op -BenchmarkCacheGet/miss-14 134807925 8.914 ns/op 0 B/op 0 allocs/op -BenchmarkCacheGet/miss-14 131447199 9.045 ns/op 0 B/op 0 allocs/op -BenchmarkCacheGet/miss-14 133133810 8.918 ns/op 0 B/op 0 allocs/op -BenchmarkCacheGet/miss-14 134967640 8.880 ns/op 0 B/op 0 allocs/op -BenchmarkCacheGet/miss-14 135017545 8.876 ns/op 0 B/op 0 allocs/op -BenchmarkCacheGet/miss-14 134952032 8.878 ns/op 0 B/op 0 allocs/op -BenchmarkCacheGet/miss-14 135080308 8.926 ns/op 0 B/op 0 allocs/op -BenchmarkCacheGetRange-14 183966 6628 ns/op 248 B/op 5 allocs/op -BenchmarkCacheGetRange-14 178975 6645 ns/op 248 B/op 5 allocs/op -BenchmarkCacheGetRange-14 178850 6522 ns/op 248 B/op 5 allocs/op -BenchmarkCacheGetRange-14 180696 6657 ns/op 248 B/op 5 allocs/op -BenchmarkCacheGetRange-14 178816 6551 ns/op 248 B/op 5 allocs/op -BenchmarkCacheGetRange-14 180018 6256 ns/op 248 B/op 5 allocs/op -BenchmarkCacheGetRange-14 194346 6201 ns/op 248 B/op 5 allocs/op -BenchmarkCacheGetRange-14 192378 6202 ns/op 248 B/op 5 allocs/op -BenchmarkCacheGetRange-14 182545 6271 ns/op 248 B/op 5 allocs/op -BenchmarkCacheGetRange-14 186987 6584 ns/op 248 B/op 5 allocs/op +BenchmarkCachePut-14 12880845 94.78 ns/op 12661.15 MB/s 128 B/op 1 allocs/op +BenchmarkCachePut-14 13495149 95.18 ns/op 12608.19 MB/s 128 B/op 1 allocs/op +BenchmarkCachePut-14 13533465 97.41 ns/op 12319.20 MB/s 128 B/op 1 allocs/op +BenchmarkCachePut-14 13440752 94.95 ns/op 12637.57 MB/s 128 B/op 1 allocs/op +BenchmarkCachePut-14 13358361 95.52 ns/op 12562.57 MB/s 128 B/op 1 allocs/op +BenchmarkCachePut-14 13524986 95.03 ns/op 12628.05 MB/s 128 B/op 1 allocs/op +BenchmarkCachePut-14 13463120 96.44 ns/op 12442.35 MB/s 128 B/op 1 allocs/op +BenchmarkCachePut-14 13303695 97.57 ns/op 12298.55 MB/s 128 B/op 1 allocs/op +BenchmarkCachePut-14 13112991 95.03 ns/op 12627.04 MB/s 128 B/op 1 allocs/op +BenchmarkCachePut-14 13613532 95.37 ns/op 12582.07 MB/s 128 B/op 1 allocs/op +BenchmarkCacheGet/hit-14 49046488 24.46 ns/op 0 B/op 0 allocs/op +BenchmarkCacheGet/hit-14 49346838 24.35 ns/op 0 B/op 0 allocs/op +BenchmarkCacheGet/hit-14 48838143 24.41 ns/op 0 B/op 0 allocs/op +BenchmarkCacheGet/hit-14 47935924 24.44 ns/op 0 B/op 0 allocs/op +BenchmarkCacheGet/hit-14 49455643 24.48 ns/op 0 B/op 0 allocs/op +BenchmarkCacheGet/hit-14 48351352 24.44 ns/op 0 B/op 0 allocs/op +BenchmarkCacheGet/hit-14 49052084 24.58 ns/op 0 B/op 0 allocs/op +BenchmarkCacheGet/hit-14 48562843 24.55 ns/op 0 B/op 0 allocs/op +BenchmarkCacheGet/hit-14 48361744 24.71 ns/op 0 B/op 0 allocs/op +BenchmarkCacheGet/hit-14 48410276 24.40 ns/op 0 B/op 0 allocs/op +BenchmarkCacheGet/miss-14 138417697 8.678 ns/op 0 B/op 0 allocs/op +BenchmarkCacheGet/miss-14 138472322 8.667 ns/op 0 B/op 0 allocs/op +BenchmarkCacheGet/miss-14 138527684 8.675 ns/op 0 B/op 0 allocs/op +BenchmarkCacheGet/miss-14 138707583 8.668 ns/op 0 B/op 0 allocs/op +BenchmarkCacheGet/miss-14 138453564 8.691 ns/op 0 B/op 0 allocs/op +BenchmarkCacheGet/miss-14 137730447 8.732 ns/op 0 B/op 0 allocs/op +BenchmarkCacheGet/miss-14 137706548 8.661 ns/op 0 B/op 0 allocs/op +BenchmarkCacheGet/miss-14 138344384 8.727 ns/op 0 B/op 0 allocs/op +BenchmarkCacheGet/miss-14 137116112 8.736 ns/op 0 B/op 0 allocs/op +BenchmarkCacheGet/miss-14 137240683 8.676 ns/op 0 B/op 0 allocs/op +BenchmarkCacheGetRange-14 177231 6540 ns/op 248 B/op 5 allocs/op +BenchmarkCacheGetRange-14 184693 6639 ns/op 248 B/op 5 allocs/op +BenchmarkCacheGetRange-14 183218 6589 ns/op 248 B/op 5 allocs/op +BenchmarkCacheGetRange-14 183766 6506 ns/op 248 B/op 5 allocs/op +BenchmarkCacheGetRange-14 183292 6575 ns/op 248 B/op 5 allocs/op +BenchmarkCacheGetRange-14 184878 6553 ns/op 248 B/op 5 allocs/op +BenchmarkCacheGetRange-14 184818 6625 ns/op 248 B/op 5 allocs/op +BenchmarkCacheGetRange-14 182187 6539 ns/op 248 B/op 5 allocs/op +BenchmarkCacheGetRange-14 185605 6527 ns/op 248 B/op 5 allocs/op +BenchmarkCacheGetRange-14 181827 6503 ns/op 248 B/op 5 allocs/op PASS -ok github.com/floatdrop/moq-go/pkg/relay/cache 57.385s +ok github.com/floatdrop/moq-go/pkg/relay/cache 59.062s diff --git a/pkg/moqt/session/goaway.go b/pkg/moqt/session/goaway.go index 6f569ad8..987d5a83 100644 --- a/pkg/moqt/session/goaway.go +++ b/pkg/moqt/session/goaway.go @@ -78,6 +78,15 @@ func (s *Session) SendGoaway(timeout time.Duration, newURI string) error { return s.sendControl(msg) } +// GoawaySent reports whether [Session.SendGoaway] was called: the session is +// draining, and §10.4 says its sender "SHOULD avoid initiating requests unless +// required by migration". +func (s *Session) GoawaySent() bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.goawaySent +} + // handleGoaway records a received GOAWAY and notifies any waiter on // GoawayReceived. §10.4: a second GOAWAY on the same control stream MUST // terminate the session with PROTOCOL_VIOLATION. diff --git a/pkg/moqt/session/request.go b/pkg/moqt/session/request.go index 86d688a1..271c87d1 100644 --- a/pkg/moqt/session/request.go +++ b/pkg/moqt/session/request.go @@ -944,7 +944,7 @@ func (r *Request) AcceptSubscribe(ok *message.SubscribeOK) (*Publication, error) // Track Properties that fail validation (see // [WithKnownMandatoryTrackProperties]) are rejected with REQUEST_ERROR — // UNSUPPORTED_EXTENSION for an unknown Mandatory Track Property (§2.5.1), -// MALFORMED_TRACK for ones that do not parse — and the error returned. A +// INTERNAL_ERROR for ones that do not parse — and the error returned. A // session-fatal value (§12.5, §12.6) closed the session in AcceptRequest. An // alias collision closes the session with DUPLICATE_TRACK_ALIAS and returns // *ErrDuplicateTrackAlias (§11.1). diff --git a/pkg/moqt/session/track_properties.go b/pkg/moqt/session/track_properties.go index f15ec151..581c2294 100644 --- a/pkg/moqt/session/track_properties.go +++ b/pkg/moqt/session/track_properties.go @@ -35,9 +35,8 @@ func (e *ErrUnsupportedMandatoryTrackProperty) Error() string { // ErrMalformedTrackProperties is wrapped by the error [ValidateTrackProperties] // returns when raw Track Properties do not parse: a Key-Value-Pair that -// "cannot be parsed" makes the track malformed (§12.7, §2.4.2). Answering -// with MALFORMED_TRACK, which §10.6 defines only for FETCH, is this package's -// choice. +// "cannot be parsed" makes the track malformed (§12.7, §2.4.2). A request is +// refused with INTERNAL_ERROR (see [TrackPropertiesRejectCode]). var ErrMalformedTrackProperties = errors.New("moqt/session: malformed track properties") // ErrTrackPropertiesNotAllowed is returned, and nothing sent, when asked to @@ -124,10 +123,12 @@ func (s *Session) checkTrackPropertyValues(raw []byte, context string) error { } // TrackPropertiesRejectCode is the REQUEST_ERROR code for a Track Properties -// validation error: UNSUPPORTED_EXTENSION (§2.5.1) or MALFORMED_TRACK. +// validation error on a PUBLISH or SUBSCRIBE: UNSUPPORTED_EXTENSION for an +// unknown Mandatory Track Property (§2.5.1), else INTERNAL_ERROR. +// MALFORMED_TRACK is defined only "In response to a FETCH" (§10.6.2). func TrackPropertiesRejectCode(err error) moqt.RequestErrorCode { if _, ok := errors.AsType[*ErrUnsupportedMandatoryTrackProperty](err); ok { return moqt.RequestUnsupportedExtension } - return moqt.RequestMalformedTrack + return moqt.RequestInternalError } diff --git a/pkg/moqt/session/track_status_test.go b/pkg/moqt/session/track_status_test.go index 65efbb9d..f1519866 100644 --- a/pkg/moqt/session/track_status_test.go +++ b/pkg/moqt/session/track_status_test.go @@ -190,7 +190,8 @@ func TestTrackStatusFollowupClosesSession(t *testing.T) { // TestAcceptPublishTrackPropertiesRejected: a PUBLISH with an unknown // Mandatory Track Property is refused with UNSUPPORTED_EXTENSION (§2.5.1), and -// unparseable Track Properties with MALFORMED_TRACK. +// unparseable Track Properties with INTERNAL_ERROR (§10.6.2 defines +// MALFORMED_TRACK only for FETCH). func TestAcceptPublishTrackPropertiesRejected(t *testing.T) { for _, tc := range []struct { name string @@ -200,7 +201,7 @@ func TestAcceptPublishTrackPropertiesRejected(t *testing.T) { {"unknown mandatory", message.AppendTrackProperties([]wire.KVPair{ {Type: message.MandatoryTrackPropertyMin, IntVal: 1}, }), moqt.RequestUnsupportedExtension}, - {"malformed", []byte{0x01}, moqt.RequestMalformedTrack}, + {"malformed", []byte{0x01}, moqt.RequestInternalError}, } { t.Run(tc.name, func(t *testing.T) { client, server := openPair(t, diff --git a/pkg/relay/cache/cache.go b/pkg/relay/cache/cache.go index 07ee9000..e2975f34 100644 --- a/pkg/relay/cache/cache.go +++ b/pkg/relay/cache/cache.go @@ -71,6 +71,11 @@ type CachedObject struct { MaxCacheDuration time.Duration HasMaxCacheDuration bool + // Stitched marks an Object read from an upstream FETCH for one response + // rather than stored: ReceivedAt is when it was read, and only a positive + // MaxCacheDuration bounds it, not the relay's TTL (see [ObjectCache.Expired]). + Stitched bool + // EndOfUnknownRange marks this element as a §11.4.4.2 End of Unknown // Range (0x10C) FETCH marker rather than a stored object: every Location // from the previous element in the response stream (exclusive) through @@ -258,12 +263,20 @@ func (c *ObjectCache) notExpiredLocked(obj *CachedObject) bool { return c.maxAge <= 0 || age <= c.maxAge } -// Expired reports whether obj, taken from this cache, may no longer be -// served (§12.3: "MUST NOT start forwarding"). Elements the cache did not -// store (range markers, Objects stitched from upstream) never expire. +// Expired reports whether obj, in a FETCH or fill response on this cache's +// track, may no longer be served (§12.3: "MUST NOT start forwarding"): one +// taken from this cache within its own MAX_CACHE_DURATION and the relay's TTL +// (see notExpiredLocked). Range markers never expire. An +// Object stitched from an upstream FETCH expires only past its own +// MAX_CACHE_DURATION ("any individual Object received through this +// subscription or fetch"); a present 0 sets no limit on it, since it is passed +// through rather than served from the cache (interpretation). func (c *ObjectCache) Expired(obj *CachedObject) bool { - if obj.ReceivedAt.IsZero() { + switch { + case obj.ReceivedAt.IsZero(): return false + case obj.Stitched: + return obj.MaxCacheDuration > 0 && time.Since(obj.ReceivedAt) > obj.MaxCacheDuration } c.mu.RLock() defer c.mu.RUnlock() diff --git a/pkg/relay/cache_test.go b/pkg/relay/cache_test.go index c4d586c2..c980ce5f 100644 --- a/pkg/relay/cache_test.go +++ b/pkg/relay/cache_test.go @@ -16,7 +16,8 @@ import ( // The relay's per-track cache as seen by FETCH: size-based eviction, and // MAX_CACHE_DURATION (§12.3), after which the relay must not start forwarding -// an Object, from the cache or from a live subscriber's queue. +// an Object, from the cache, from a live subscriber's queue, or from an upstream +// FETCH it passes through. // TestFetch_CacheEvictionUnderLoad: past MaxCacheSize the cache evicts the // oldest Objects, so a FETCH of the early range returns fewer Objects than it @@ -334,3 +335,81 @@ func TestRelay_MaxCacheDurationExpiresDuringFetch(t *testing.T) { ) } } + +// TestRelay_MaxCacheDurationBoundsStitchedObject: an Object received through +// an upstream FETCH is bound by that FETCH's MAX_CACHE_DURATION (§12.3: "any +// individual Object received through this subscription or fetch"). One the +// upstream sent, then held its stream open past the duration, is not +// forwarded but marked unknown. A present 0, which the cache reads as "never +// serve from the cache", sets no limit on an Object passed through. +func TestRelay_MaxCacheDurationBoundsStitchedObject(t *testing.T) { + t.Parallel() + const hold = 150 * time.Millisecond + for _, tc := range []struct { + name string + millis uint64 + want fetchElem + }{ + {"expired", 50, unknownAt(0, 1)}, + {"zero", 0, obj(0, 1)}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + upSess, teardown := connectRelay(t, relay.Config{}) + t.Cleanup(teardown) + if _, err := upSess.PublishNamespace( + t.Context(), + &message.PublishNamespace{Namespace: ns("video")}, + ); err != nil { + t.Fatalf("PublishNamespace: %v", err) + } + go func() { + for { + req, err := upSess.AcceptRequest(t.Context()) + if err != nil { + return + } + switch m := req.First.(type) { + case *message.Subscribe: + if req.Reply(&message.SubscribeOK{TrackAlias: 42}) != nil { + return + } + // The live stream misses Object 1. + publishCam1Group(t, upSess, 42, true, cam1Object{0, 0, nil}, cam1Object{0, 2, nil}) + case *message.Fetch: + if req.Reply(&message.FetchOK{ + EndLocation: fetchOKEnd(m), + TrackProperties: message.AppendTrackProperties( + trackProp(message.PropertyMaxCacheDuration, tc.millis)), + }) != nil { + return + } + out, err := upSess.OpenFetchStream(message.FetchHeader{RequestID: m.RequestID}) + if err != nil { + return + } + _ = out.WriteObject(&message.FetchObject{ + SerializationFlags: message.FetchFlagGroupIDDelta | message.FetchFlagObjectIDDelta | + message.FetchFlagPriority | uint64(message.FetchSubgroupIDExplicit), + GroupIDDelta: 0, ObjectIDDelta: 1, ObjectPayload: []byte("x"), + }) + time.Sleep(hold) + _ = out.Close() + } + } + }() + live := dialAnotherClient(t, upSess) + subscribeCam1(t, live) + go drainAll(t.Context(), live) + fc := dialAnotherClient(t, upSess) + waitRelayLargest(t, fc, ns("video"), []byte("cam1"), 0, 2) + + got := fetchCam1Range(t, fc, message.Location{}, message.Location{Group: 0, Object: 2}, + message.GroupOrderAscending) + want := []fetchElem{obj(0, 0), tc.want, obj(0, 2)} + if !slices.Equal(got, want) { + t.Fatalf("FETCH elements %v, want %v", got, want) + } + }) + } +} diff --git a/pkg/relay/drain_requests_test.go b/pkg/relay/drain_requests_test.go new file mode 100644 index 00000000..7baf42af --- /dev/null +++ b/pkg/relay/drain_requests_test.go @@ -0,0 +1,77 @@ +package relay_test + +import ( + "context" + "testing" + "time" + + "github.com/floatdrop/moq-go/pkg/moqt" + "github.com/floatdrop/moq-go/pkg/moqt/message" + "github.com/floatdrop/moq-go/pkg/moqt/session" + "github.com/floatdrop/moq-go/pkg/relay" +) + +// TestRelay_NoRequestsToPeerSentGoaway: once the relay has sent a publisher +// GOAWAY it avoids initiating requests to it (§10.4: "the sender SHOULD avoid +// initiating requests unless required by migration"), so a SUBSCRIBE that +// needs that publisher is refused with GOING_AWAY rather than sent upstream. +func TestRelay_NoRequestsToPeerSentGoaway(t *testing.T) { + t.Parallel() + l := newPipeListener() + r := relay.New(l, relay.Config{GoawayTimeout: 5 * time.Second}) + go func() { _ = r.Start(t.Context()) }() + dial := func() *session.Session { + conn, err := l.Dial() + if err != nil { + t.Fatalf("Dial: %v", err) + } + s, err := session.Client(t.Context(), conn) + if err != nil { + t.Fatalf("session.Client: %v", err) + } + return s + } + pubSess, subSess := dial(), dial() + stopped := make(chan struct{}) + defer func() { + _ = pubSess.Close(moqt.SessionNoError, "done") + _ = subSess.Close(moqt.SessionNoError, "done") + <-stopped + }() + + video := ns("video") + if _, err := pubSess.PublishNamespace(t.Context(), &message.PublishNamespace{Namespace: video}); err != nil { + t.Fatalf("PublishNamespace: %v", err) + } + subscribes := make(chan *session.Request, 4) + go func() { + for { + req, err := pubSess.AcceptRequest(t.Context()) + if err != nil { + return + } + subscribes <- req + _ = req.Reply(&message.SubscribeOK{TrackAlias: 1}) + } + }() + + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = r.Stop(ctx) + close(stopped) + }() + select { + case <-pubSess.GoawayReceived(): + case <-time.After(2 * time.Second): + t.Fatal("the publisher never got the relay's GOAWAY") + } + + _, err := subSess.Subscribe(t.Context(), &message.Subscribe{Namespace: video, Name: []byte("cam1")}) + select { + case req := <-subscribes: + t.Fatalf("the relay sent %s to a publisher it had sent GOAWAY", req.First.Type()) + default: + } + requireRejectedWithCode(t, err, moqt.RequestGoingAway) +} diff --git a/pkg/relay/export_test.go b/pkg/relay/export_test.go index bb2f1a5e..0d978d84 100644 --- a/pkg/relay/export_test.go +++ b/pkg/relay/export_test.go @@ -20,3 +20,31 @@ func SetTestHookEarlyStreamWaiting(hook func(alias uint64)) (restore func()) { testHookEarlyStreamWaiting.Store(&hook) return func() { testHookEarlyStreamWaiting.Store(prev) } } + +// SetTestHookBeforeDownstreamRegistered installs hook, to be called once a +// SUBSCRIBE has an upstream for its track and before its downstream is +// registered, and returns a function restoring the previous value. See +// [testHookBeforeDownstreamRegistered]. +func SetTestHookBeforeDownstreamRegistered(hook func(track.FullTrackName)) (restore func()) { + prev := testHookBeforeDownstreamRegistered.Load() + testHookBeforeDownstreamRegistered.Store(&hook) + return func() { testHookBeforeDownstreamRegistered.Store(prev) } +} + +// SetTestHookBeforeFill installs hook, to be called as a fill is about to be +// evaluated, and returns a function restoring the previous value. See +// [testHookBeforeFill]. +func SetTestHookBeforeFill(hook func(track.FullTrackName)) (restore func()) { + prev := testHookBeforeFill.Load() + testHookBeforeFill.Store(&hook) + return func() { testHookBeforeFill.Store(prev) } +} + +// SetTestHookBeforeForwardClaim installs hook, to be called as a forward of a +// track to a SUBSCRIBE_TRACKS holder is about to claim it, and returns a +// function restoring the previous value. See [testHookBeforeForwardClaim]. +func SetTestHookBeforeForwardClaim(hook func(track.FullTrackName)) (restore func()) { + prev := testHookBeforeForwardClaim.Load() + testHookBeforeForwardClaim.Store(&hook) + return func() { testHookBeforeForwardClaim.Store(prev) } +} diff --git a/pkg/relay/fetch_holes_test.go b/pkg/relay/fetch_holes_test.go index 51b2a925..73661665 100644 --- a/pkg/relay/fetch_holes_test.go +++ b/pkg/relay/fetch_holes_test.go @@ -394,3 +394,87 @@ func TestFetch_FillTimeoutBoundsUpstreamRead(t *testing.T) { t.Fatalf("FETCH elements %v, want %v", got, want) } } + +// TestFetch_CancelResetsStreams: a requester that cancels a FETCH while the +// relay still waits on the upstream for a hole has the data stream and the +// request stream reset with CANCELLED (§5.2: "It MUST reset the bidi request +// stream and unidirectional data stream associated with the FETCH"), not +// served once FILL_TIMEOUT runs out. +func TestFetch_CancelResetsStreams(t *testing.T) { + t.Parallel() + l := newPipeListener() + resets := make(chan streamReset, 16) + l.resetsFor = resetsOn(3, resets) // the upstream is 1, the live subscriber 2 + upSess, teardown := connectRelayOn(t, relay.Config{}, l) + t.Cleanup(teardown) + if _, err := upSess.PublishNamespace(t.Context(), &message.PublishNamespace{Namespace: ns("video")}); err != nil { + t.Fatalf("PublishNamespace: %v", err) + } + go func() { + for { + req, err := upSess.AcceptRequest(t.Context()) + if err != nil { + return + } + switch m := req.First.(type) { + case *message.Subscribe: + if req.Reply(&message.SubscribeOK{TrackAlias: 42}) != nil { + return + } + // The live stream misses Object 1. + publishCam1Group(t, upSess, 42, true, cam1Object{0, 0, nil}, cam1Object{0, 2, nil}) + case *message.Fetch: + if req.Reply(&message.FetchOK{EndLocation: fetchOKEnd(m)}) != nil { + return + } + out, err := upSess.OpenFetchStream(message.FetchHeader{RequestID: m.RequestID}) + if err != nil { + return + } + // Nothing more: the stream stays open. + t.Cleanup(func() { out.Cancel(moqt.StreamResetCancelled) }) + } + } + }() + live := dialAnotherClient(t, upSess) + subscribeCam1(t, live) + go drainAll(t.Context(), live) + fc := dialAnotherClient(t, upSess) + waitRelayLargest(t, fc, ns("video"), []byte("cam1"), 0, 2) + + fr, err := fc.Fetch(t.Context(), &message.Fetch{ + Namespace: ns("video"), Name: []byte("cam1"), + Parameters: message.Parameters{ + fetchRangeFilter(message.Location{}, message.Location{Group: 0, Object: 2}), + message.FillTimeoutParam(5 * time.Second), + }, + }) + if err != nil { + t.Fatalf("Fetch: %v", err) + } + if _, err := fc.AcceptDataStream(t.Context()); err != nil { + t.Fatalf("AcceptDataStream: %v", err) + } + _ = fr.Close() + + var uni, bidi bool + deadline := time.After(500 * time.Millisecond) + for !uni || !bidi { + select { + case r := <-resets: + if r.code != moqt.StreamResetCancelled { + t.Fatalf("the relay reset a stream (%v) with %v, want CANCELLED", r.stream, r.code) + } + switch r.stream { + case fetchStreamReset: + uni = true + case requestStreamReset: + bidi = true + case fetchStreamStop: + } + case <-deadline: + t.Fatalf("within 500ms of the cancel: data stream reset %t, request stream reset %t; want both", + uni, bidi) + } + } +} diff --git a/pkg/relay/fill_snapshot_test.go b/pkg/relay/fill_snapshot_test.go new file mode 100644 index 00000000..86dc8e50 --- /dev/null +++ b/pkg/relay/fill_snapshot_test.go @@ -0,0 +1,155 @@ +package relay_test + +import ( + "context" + "testing" + "time" + + "github.com/floatdrop/moq-go/pkg/moqt/message" + "github.com/floatdrop/moq-go/pkg/moqt/session" + "github.com/floatdrop/moq-go/pkg/moqt/track" + "github.com/floatdrop/moq-go/pkg/relay" +) + +// publishDuringFill has the fill of track name, as it is about to be +// evaluated, wait for pubSess to publish Group group Object 0 on alias and +// the relay to see it (probe polls TRACK_STATUS for it). +func publishDuringFill(t *testing.T, pubSess, probe *session.Session, name string, alias, group uint64) { + t.Helper() + restore := relay.SetTestHookBeforeFill(func(n track.FullTrackName) { + if string(n.Name) != name { + return + } + sendObjects(pubSess, alias, group, 1) + for range 200 { + ts, err := probe.TrackStatus( + context.Background(), + &message.TrackStatus{Namespace: ns("video"), Name: []byte(name)}, + ) + if err == nil { + p, ok := ts.OK.Parameters.Find(message.ParamLargestObject) + _ = ts.Close() + if ok && p.Group == group { + return + } + } + time.Sleep(10 * time.Millisecond) + } + t.Errorf("the relay never saw Group %d of %s", group, name) + }) + t.Cleanup(restore) +} + +// fillStream returns the fill fetch stream sess accepts within d, skipping the +// live subgroups, or nil. +func fillStream(t *testing.T, sess *session.Session, d time.Duration) *session.IncomingFetchStream { + t.Helper() + deadline := time.Now().Add(d) + for time.Until(deadline) > 0 { + ds, ok := tryAcceptDataStream(t, sess, time.Until(deadline)) + if !ok { + return nil + } + if fs, ok := ds.(*session.IncomingFetchStream); ok { + return fs + } + } + return nil +} + +// TestFill_EndsAtTheLargestObjectReported: the fill paired with a Next Object +// filter "the publisher will end at Largest Object" (§5.1.3), the one its +// SUBSCRIBE_OK reported. An Object arriving before the fill is evaluated goes +// out live only, and with no Largest reported there is nothing to fill. +func TestFill_EndsAtTheLargestObjectReported(t *testing.T) { + t.Run("SUBSCRIBE_OK reported none", func(t *testing.T) { + pubSess, teardown := connectRelay(t, relay.Config{}) + t.Cleanup(teardown) + publishVideoTrack(t, pubSess, "fillsnap-none", 7) + publishDuringFill(t, pubSess, dialAnotherClient(t, pubSess), "fillsnap-none", 7, 0) + + subSess := dialAnotherClient(t, pubSess) + sub, err := subSess.Subscribe(t.Context(), &message.Subscribe{ + Namespace: ns("video"), Name: []byte("fillsnap-none"), Parameters: fillWholeTrack, + }) + if err != nil { + t.Fatalf("Subscribe: %v", err) + } + t.Cleanup(func() { _ = sub.Close() }) + if _, ok := sub.OK.Parameters.Find(message.ParamLargestObject); ok { + t.Fatal("SUBSCRIBE_OK reported a LARGEST_OBJECT before any Object") + } + if fs := fillStream(t, subSess, 500*time.Millisecond); fs != nil { + t.Fatalf("a fill opened for Objects published after SUBSCRIBE_OK: %v", + decodeFetchStream(t, fs, message.GroupOrderAscending)) + } + }) + t.Run("SUBSCRIBE_OK reported {0, 0}", func(t *testing.T) { + pubSess, teardown := connectRelay(t, relay.Config{}) + t.Cleanup(teardown) + publishVideoTrack(t, pubSess, "fillsnap-some", 7) + probe := dialAnotherClient(t, pubSess) + sendObjects(pubSess, 7, 0, 1) + waitRelayLargest(t, probe, ns("video"), []byte("fillsnap-some"), 0, 0) + publishDuringFill(t, pubSess, probe, "fillsnap-some", 7, 1) + + subSess := dialAnotherClient(t, pubSess) + sub, err := subSess.Subscribe(t.Context(), &message.Subscribe{ + Namespace: ns("video"), Name: []byte("fillsnap-some"), Parameters: fillWholeTrack, + }) + if err != nil { + t.Fatalf("Subscribe: %v", err) + } + t.Cleanup(func() { _ = sub.Close() }) + fs := fillStream(t, subSess, 2*time.Second) + if fs == nil { + t.Fatal("no fill fetch stream") + } + objs := decodeFetchStream(t, fs, message.GroupOrderAscending) + if len(objs) == 0 { + t.Fatal("empty fill") + } + if last := objs[len(objs)-1]; last.group != 0 || last.object != 0 { + t.Fatalf("fill ended at {%d, %d}, want the reported {0, 0}", last.group, last.object) + } + }) + t.Run("REQUEST_UPDATE_OK reported {0, 0}", func(t *testing.T) { + pubSess, teardown := connectRelay(t, relay.Config{}) + t.Cleanup(teardown) + publishVideoTrack(t, pubSess, "fillsnap-update", 7) + probe := dialAnotherClient(t, pubSess) + sendObjects(pubSess, 7, 0, 1) + waitRelayLargest(t, probe, ns("video"), []byte("fillsnap-update"), 0, 0) + + subSess := dialAnotherClient(t, pubSess) + sub, err := subSess.Subscribe(t.Context(), &message.Subscribe{ + Namespace: ns("video"), Name: []byte("fillsnap-update"), + Parameters: message.Parameters{message.NextObjectFilter()}, + }) + if err != nil { + t.Fatalf("Subscribe: %v", err) + } + t.Cleanup(func() { _ = sub.Close() }) + publishDuringFill(t, pubSess, probe, "fillsnap-update", 7, 1) + ok, err := sub.Update(t.Context(), message.Parameters{ + message.FillParametersParam(message.Parameters{message.UnfilteredFilter()}), + }) + if err != nil { + t.Fatalf("Update: %v", err) + } + if p, found := ok.Parameters.Find(message.ParamLargestObject); !found || p.Group != 0 || p.Object != 0 { + t.Fatalf("REQUEST_UPDATE_OK LARGEST_OBJECT = %+v (found %v), want {0, 0}", p, found) + } + fs := fillStream(t, subSess, 2*time.Second) + if fs == nil { + t.Fatal("no fill fetch stream") + } + objs := decodeFetchStream(t, fs, message.GroupOrderAscending) + if len(objs) == 0 { + t.Fatal("empty fill") + } + if last := objs[len(objs)-1]; last.group != 0 || last.object != 0 { + t.Fatalf("fill ended at {%d, %d}, want the reported {0, 0}", last.group, last.object) + } + }) +} diff --git a/pkg/relay/handler_fanout.go b/pkg/relay/handler_fanout.go index 3f5f844f..96068c51 100644 --- a/pkg/relay/handler_fanout.go +++ b/pkg/relay/handler_fanout.go @@ -39,6 +39,11 @@ type fwdObject struct { // Objects the subscriber's filters rejected between (see // [subgroupWriter.admit]). follows bool + + // pubTimeouts is [subgroupWriterSet.pubTimeouts] when this Object was + // published: nil until the Subgroup's first Object was forwarded. Never + // written through. + pubTimeouts *message.DeliveryTimeouts } // inboundPos is an Object's place on its inbound subgroup stream: the stream, @@ -69,7 +74,9 @@ type subgroupWriterSet struct { // for a higher ID is wrong, whether or not a given subscriber got the // lower one. A new set starts from the ledger's // ([registry.TrackEntry.LowestForwarded]), so it holds across contributors - // within the ledger's window. + // within the ledger's window; for the same reason + // [subgroupWriterSet.outcome] measures a clean contributor's coverage + // from it. lowest uint64 forwarded bool // runLo and runHi are the lowest and highest Object IDs forwarded @@ -78,11 +85,26 @@ type subgroupWriterSet struct { runLo, runHi uint64 hasRun, unbroken bool - // sawClean records that some contributor ended cleanly, so the merged - // stream FINs even if a peer reset; resetCode is used only when every - // contributor reset. + // cleanFrom is the lowest Object ID from which a contributor that ended + // cleanly delivered every Object, if sawClean: 0 for one whose stream + // starts the Subgroup (§11.4.2 FIRST_OBJECT), else its first Object's + // (see [subgroupWriterSet.outcome]). resetCode is a reset contributor's + // code, used when none ended cleanly. + cleanFrom uint64 sawClean bool resetCode moqt.StreamResetCode + + // pubTimeouts points at firstTimeouts once the Subgroup's first Object + // was forwarded: the publisher's §8 delivery timeouts for the Subgroup, + // "the Object Property when present on the first object of the subgroup, + // and the Track Property otherwise" (§12.1, §12.2). A replay stream (a + // joiner's, or one reopened after a gap) starts past that Object, so its + // session cannot find the override itself (see + // [session.OutgoingSubgroupStream.WriteObjectReceivedAt]). Set once, and + // handed to writers on every later [fwdObject]. While nil, as when the + // relay never saw the first Object, writers keep the Track's. + pubTimeouts *message.DeliveryTimeouts + firstTimeouts message.DeliveryTimeouts } // claimFirst records that the Object at objectID is forwarded and reports @@ -106,6 +128,53 @@ func (s *subgroupWriterSet) claimFirst(objectID uint64, claimed bool) bool { return claimed && lowest } +// leave records how one contributor ended: reset with code, or cleanly after +// delivering every Object from coverFrom on. covers is false for a replay +// stream that ended before its first Object, which vouches for none. Callers +// hold sg.Mu. +func (s *subgroupWriterSet) leave(reset bool, code moqt.StreamResetCode, covers bool, coverFrom uint64) { + switch { + case reset: + s.resetCode = code + case covers && (!s.sawClean || coverFrom < s.cleanFrom): + s.cleanFrom, s.sawClean = coverFrom, true + } +} + +// outcome reports whether the merged streams end with a reset, and with which +// code, once every contributor has left. §11.4.3: "If a sender closes the +// stream before delivering all such objects to the QUIC stream, it MUST reset +// the stream." A clean contributor delivered every Object from cleanFrom on, +// so they FIN only if every Object forwarded below cleanFrom was followed by +// the next one up to it: an unbroken run from the lowest one forwarded +// (subgroupWriterSet.lowest) reaching cleanFrom - 1. Otherwise a reset +// contributor may have held Objects between that nobody forwarded, and they +// reset with CANCELLED. +// +// Deviation: lowest also counts what the ledger saw forwarded through an +// earlier set of the Subgroup, released when its last contributor left, whose +// run is forgotten; so a contributor arriving after that resets unless it +// covers from lowest, even if it did continue the earlier run, where §11.4.3 +// says a sender that "has delivered all objects in a Subgroup ... MUST close +// the stream with a FIN". +// +// Interpretation: Objects below the lowest one forwarded count as before the +// Start Location, as for a joiner (see subgroupWriter.incomplete), so a lone +// replay upstream's FIN still FINs. The run is broken, and the streams reset, +// whenever Object IDs are forwarded out of order or are not consecutive (the +// Group split across Subgroups): the relay cannot tell a skipped ID from one +// that does not exist. Callers hold sg.Mu. +func (s *subgroupWriterSet) outcome() (reset bool, code moqt.StreamResetCode) { + switch { + case !s.sawClean: + return true, s.resetCode + case s.cleanFrom <= s.lowest, + s.unbroken && s.runLo == s.lowest && s.runHi >= s.cleanFrom-1: + return false, 0 + } + return true, moqt.StreamResetCancelled +} + // admitAgedOut reports whether an Object at objectID of the Subgroup hdr // names, which [registry.TrackEntry.ClaimDelivered] returned as claim, may be // forwarded. Only a [registry.ClaimAgedOut] one may not: with its Group out of @@ -327,23 +396,21 @@ func (h *sessionHandler) runFanout(ctx context.Context, stream *session.Incoming var ( inboundReset bool inboundResetCode = moqt.StreamResetCancelled + // See [subgroupWriterSet.leave]. + covers = !hdr.ReplayingSubgroup + coverFrom uint64 ) defer func() { // Record the outcome before releasing, so the last contributor decides // FIN vs reset over all of them. sg.Mu.Lock() - if inboundReset { - set.resetCode = inboundResetCode - } else { - set.sawClean = true - } + set.leave(inboundReset, inboundResetCode, covers, coverFrom) last := entry.ReleaseSubgroup(sgKey) if !last { sg.Mu.Unlock() return // other upstreams still feed this Subgroup — leave writers up. } - reset := !set.sawClean - code := set.resetCode + reset, code := set.outcome() ws := make([]*subgroupWriter, 0, len(set.writers)) for _, w := range set.writers { if w == nil { @@ -406,6 +473,9 @@ func (h *sessionHandler) runFanout(ctx context.Context, stream *session.Incoming firstObj = false pos.seq++ objectID := stream.ObjectID() // resolved by ReadObject (§11.4.2) + if !covers { + coverFrom, covers = objectID, true + } // Whether or not this copy wins the dedup claim below; the next // iteration acts on it, so it can be set now. @@ -482,24 +552,47 @@ func (h *sessionHandler) runFanout(ctx context.Context, stream *session.Incoming h.openWriterForSub(ctx, set.hdr, sub, set.writers, entry.DeliveryTimeouts(), ref) } - first := set.claimFirst(objectID, isTrueFirst) + set.forward(entry, pos, hdr, objectID, obj, isTrueFirst, liveMaxAge) + sg.Mu.Unlock() + } +} - // §5.1.2 filters run before enqueue, so a miss takes no queue slot. - for _, w := range set.writers { - if w == nil { - continue - } - if take, follows := w.admit(pos, hdr, objectID, obj.Properties); take { - w.publish(fwdObject{ - obj: obj, - absID: objectID, - first: first, - maxCacheAge: liveMaxAge, - follows: follows, - }) - } +// forward hands obj, the Object at objectID of the Subgroup hdr names, read at +// pos, to every writer whose subscriber takes it. claimed reports that its +// contributor claims it starts the Subgroup (see [subgroupWriterSet.claimFirst]); +// maxCacheAge is [fwdObject.maxCacheAge]. Callers hold sg.Mu. +func (s *subgroupWriterSet) forward( + entry *registry.TrackEntry, + pos inboundPos, + hdr message.SubgroupHeader, + objectID uint64, + obj *message.SubgroupObject, + claimed bool, + maxCacheAge time.Duration, +) { + first := s.claimFirst(objectID, claimed) + // §8: the first Object's Properties settle the publisher's timeouts for + // the Subgroup. + if first && s.pubTimeouts == nil { + s.firstTimeouts = entry.DeliveryTimeouts().ApplyObjectProperties(obj.Properties) + s.pubTimeouts = &s.firstTimeouts + } + + // §5.1.2 filters run before enqueue, so a miss takes no queue slot. + for _, w := range s.writers { + if w == nil { + continue + } + if take, follows := w.admit(pos, hdr, objectID, obj.Properties); take { + w.publish(fwdObject{ + obj: obj, + absID: objectID, + first: first, + maxCacheAge: maxCacheAge, + follows: follows, + pubTimeouts: s.pubTimeouts, + }) } - sg.Mu.Unlock() } } @@ -633,7 +726,8 @@ type subgroupWriter struct { maxDropsBeforeReset int maxLag time.Duration // pubTimeouts and subTimeouts are the §8 delivery-timeout halves, resolved - // per outbound stream; zero disables a dimension. + // per outbound stream; zero disables a dimension. run replaces + // pubTimeouts with [fwdObject.pubTimeouts] once that is set. pubTimeouts message.DeliveryTimeouts subTimeouts message.DeliveryTimeouts @@ -917,6 +1011,11 @@ func (w *subgroupWriter) run() { continue } + // §8: the Subgroup's timeouts, for the streams reopen opens below. + if fwd.pubTimeouts != nil { + w.pubTimeouts = *fwd.pubTimeouts + } + cause, stale := w.reopenCause(fwd, prevID, hasWritten, dropped) // Lazy first open, off sg.Mu (see openWriterForSub). diff --git a/pkg/relay/handler_fanout_multipub_test.go b/pkg/relay/handler_fanout_multipub_test.go index 1b690198..79a9df01 100644 --- a/pkg/relay/handler_fanout_multipub_test.go +++ b/pkg/relay/handler_fanout_multipub_test.go @@ -264,6 +264,118 @@ func TestFanout_MultiPublisher_FailoverContinuesFromSurvivor(t *testing.T) { } } +// TestFanout_MultiPublisher_SurvivorFINsOnlyWhatItCovers: a merged Subgroup +// FINs only when every Object in it was delivered (§11.4.3: "If a sender +// closes the stream before delivering all such objects to the QUIC stream, it +// MUST reset the stream"). A survivor's replay stream that FINs vouches only +// for the Objects from its own first one on, so the relay FINs when the +// Objects before those were forwarded, and resets when a peer that reset never +// delivered some of them. +func TestFanout_MultiPublisher_SurvivorFINsOnlyWhatItCovers(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + // a is what A writes on a stream that starts the Subgroup; b is what B + // writes on a replay stream. A resets, then B FINs. aLeavesFirst: A + // resets before B opens, so the relay has dropped the Subgroup's + // writers in between. + a, b []uint64 + aLeavesFirst bool + fin bool + }{ + {"replay continues the run", []uint64{0, 1}, []uint64{2, 3}, false, true}, + {"replay starts past undelivered Objects", []uint64{0, 1}, []uint64{4}, false, false}, + // Decided: without consecutive IDs the relay cannot tell whether an + // Object between was skipped (a Group split across Subgroups), so it + // resets. + {"Object IDs not consecutive", []uint64{0, 2}, []uint64{3}, false, false}, + {"replay after A left starts past undelivered Objects", []uint64{0, 1}, []uint64{4}, true, false}, + // Decided: once A's streams are gone the relay knows only the lowest + // Object forwarded, not which ones followed it, so even a replay + // that continues the run resets. + {"replay after A left continues the run", []uint64{0, 1}, []uint64{2, 3}, true, false}, + // A replay covering from that lowest Object still FINs; its copies + // of 0 and 1 are redundant (§9.3), so only 2 and 3 are forwarded. + {"replay after A left covers from the lowest Object", []uint64{0, 1}, []uint64{0, 1, 2, 3}, true, true}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + pubA, teardown := connectRelay(t, relay.Config{}) + defer teardown() + pubB := dialAnotherClient(t, pubA) + subSess := dialAnotherClient(t, pubA) + aPub := publishVideoTrack(t, pubA, "cam1", 1) + bPub := publishVideoTrack(t, pubB, "cam1", 2) + subscribeCam1(t, subSess) + + events := make(chan objEvent, 32) + go readSubgroups(t.Context(), subSess, events) + // await reads the next Object, skipping the end of a stream the + // relay reset to reopen after a gap (§11.4.3). + await := func(want uint64) { + t.Helper() + for { + select { + case ev := <-events: + if ev.err != nil { + continue + } + if ev.absID != want { + t.Fatalf("received Object %d, want %d", ev.absID, want) + } + return + case <-time.After(2 * time.Second): + t.Fatalf("Object %d not forwarded", want) + } + } + } + + hdr := message.SubgroupHeader{SubgroupIDMode: message.SubgroupIDExplicit} + a, err := aPub.OpenSubgroup(hdr) + if err != nil { + t.Fatalf("A OpenSubgroup: %v", err) + } + for _, id := range tc.a { + if err := a.WriteObjectAt(id, &message.SubgroupObject{Payload: []byte{byte(id)}}); err != nil { + t.Fatalf("A WriteObjectAt %d: %v", id, err) + } + await(id) + } + if tc.aLeavesFirst { + a.Cancel(moqt.StreamResetCancelled) + if end := awaitStreamEnd(t, events); errors.Is(end.err, io.EOF) { + t.Fatal("A's reset reached the subscriber as a FIN") + } + } + hdr.ReplayingSubgroup = true + b, err := bPub.OpenSubgroup(hdr) + if err != nil { + t.Fatalf("B OpenSubgroup: %v", err) + } + for _, id := range tc.b { + // The same Object as A's, if A sent it: §9.1 forbids another Payload. + if err := b.WriteObjectAt(id, &message.SubgroupObject{Payload: []byte{byte(id)}}); err != nil { + t.Fatalf("B WriteObjectAt %d: %v", id, err) + } + if !slices.Contains(tc.a, id) { // a redundant copy is not forwarded + await(id) + } + } + + if !tc.aLeavesFirst { + a.Cancel(moqt.StreamResetCancelled) + } + if err := b.Close(); err != nil { + t.Fatalf("B Close: %v", err) + } + end := awaitStreamEnd(t, events) + if fin := errors.Is(end.err, io.EOF); fin != tc.fin { + t.Fatalf("subscriber's last stream ended with %v; want FIN %v", end.err, tc.fin) + } + }) + } +} + // TestFanout_MultiPublisher_MergesDisjointObjects: two publishers contributing // different Objects of one track deliver each exactly once. func TestFanout_MultiPublisher_MergesDisjointObjects(t *testing.T) { diff --git a/pkg/relay/handler_fanout_timeout_test.go b/pkg/relay/handler_fanout_timeout_test.go index 7fffcad7..100622dc 100644 --- a/pkg/relay/handler_fanout_timeout_test.go +++ b/pkg/relay/handler_fanout_timeout_test.go @@ -4,6 +4,7 @@ import ( "testing" "time" + "github.com/floatdrop/moq-go/pkg/moqt" "github.com/floatdrop/moq-go/pkg/moqt/message" "github.com/floatdrop/moq-go/pkg/moqt/session" "github.com/floatdrop/moq-go/pkg/moqt/wire" @@ -210,3 +211,130 @@ func TestFanout_NoDeliveryTimeoutLeavesStalledSubscriberAlone(t *testing.T) { "configured; nothing should have cut the stream short", got, objects) } } + +// TestFanout_ReplayStreamKeepsFirstObjectDeliveryTimeout: an +// OBJECT_DELIVERY_TIMEOUT on a Subgroup's first Object overrides the Track +// for the whole Subgroup (§8: "the publisher's value is the Object Property +// when present on the first object of the subgroup"; §12.2), including on a +// replay stream that starts past that Object: a subscriber that joined after +// it, or a stream reopened after a gap (§11.4.3). +func TestFanout_ReplayStreamKeepsFirstObjectDeliveryTimeout(t *testing.T) { + const timeout = 100 * time.Millisecond + props := message.AppendTrackProperties([]wire.KVPair{{ + Type: message.PropertyObjectDeliveryTimeout, IntVal: uint64(timeout / time.Millisecond), + }}) + // Enough Objects behind the stall that the timeout, if applied, cuts the + // stream short. + const objects = 6 + + t.Run("joiner", func(t *testing.T) { + pubSess, teardown := connectRelay(t, relay.Config{}) + defer teardown() + pub := publishVideoTrack(t, pubSess, "cam1", 1) // no Track-level timeout + + // early proves the relay forwarded Object 0 before late subscribes. + early := dialAnotherClient(t, pubSess) + subscribeCam1(t, early) + events := make(chan objEvent, 2*objects) + go readSubgroups(t.Context(), early, events) + + sg, err := pub.OpenSubgroup(message.SubgroupHeader{ + SubgroupIDMode: message.SubgroupIDExplicit, Properties: true, + }) + if err != nil { + t.Fatalf("OpenSubgroup: %v", err) + } + if err := sg.WriteObjectAt(0, &message.SubgroupObject{Properties: props, Payload: []byte("0")}); err != nil { + t.Fatalf("WriteObjectAt 0: %v", err) + } + if id := awaitObject(t, events); id != 0 { + t.Fatalf("early subscriber got Object %d, want 0", id) + } + + late := dialAnotherClient(t, pubSess) + subscribeCam1(t, late) + go func() { + for id := uint64(1); id <= objects; id++ { + if sg.WriteObjectAt(id, &message.SubgroupObject{Payload: []byte("x")}) != nil { + return + } + } + _ = sg.Close() + }() + + in := acceptSubgroup(t, late) + if !in.Header.ReplayingSubgroup { + t.Fatal("late subscriber's stream claims FIRST_OBJECT; want a replay stream") + } + time.Sleep(3 * timeout) + if got := countUntilEnd(in, 2*time.Second); got >= objects { + t.Fatalf("stalled joiner received all %d objects; its replay stream "+ + "lost the first Object's OBJECT_DELIVERY_TIMEOUT", got) + } + }) + + t.Run("gap reopen", func(t *testing.T) { + pubSess, teardown := connectRelay(t, relay.Config{}) + defer teardown() + pub := publishVideoTrack(t, pubSess, "cam1", 1) // no Track-level timeout + subSess := dialAnotherClient(t, pubSess) + subscribeCam1(t, subSess) + + hdr := message.SubgroupHeader{SubgroupIDMode: message.SubgroupIDExplicit, Properties: true} + first, err := pub.OpenSubgroup(hdr) + if err != nil { + t.Fatalf("OpenSubgroup: %v", err) + } + if err := first.WriteObjectAt(0, &message.SubgroupObject{Properties: props, Payload: []byte("0")}); err != nil { + t.Fatalf("WriteObjectAt 0: %v", err) + } + in := acceptSubgroup(t, subSess) + if _, err := in.ReadObject(); err != nil { + t.Fatalf("Object 0: %v", err) + } + + // A second stream of the same Subgroup resumes at Object 2: Object 1 + // is not known to be skipped, so the relay resets the subscriber's + // stream and reopens a replay one (§11.4.3). + hdr.ReplayingSubgroup = true + replay, err := pub.OpenSubgroup(hdr) + if err != nil { + t.Fatalf("OpenSubgroup (replay): %v", err) + } + go func() { + // A FIN would end the Subgroup at Object 0 (§2.4.2), so the first + // stream resets, leaving the replay stream's FIN to end it. + defer first.Cancel(moqt.StreamResetCancelled) + for id := uint64(2); id < 2+objects; id++ { + if replay.WriteObjectAt(id, &message.SubgroupObject{Payload: []byte("x")}) != nil { + return + } + } + _ = replay.Close() + }() + + if got := countUntilEnd(in, 2*time.Second); got != 0 { + t.Fatalf("first stream carried %d more Objects, want it reset at the gap", got) + } + reopened := acceptSubgroup(t, subSess) + time.Sleep(3 * timeout) + if got := countUntilEnd(reopened, 2*time.Second); got >= objects { + t.Fatalf("stalled subscriber received all %d objects after the gap; "+ + "the reopened stream lost the first Object's OBJECT_DELIVERY_TIMEOUT", got) + } + }) +} + +// acceptSubgroup returns sess's next data stream, which must be a subgroup. +func acceptSubgroup(t *testing.T, sess *session.Session) *session.IncomingSubgroupStream { + t.Helper() + ds, err := sess.AcceptDataStream(t.Context()) + if err != nil { + t.Fatalf("AcceptDataStream: %v", err) + } + sg, ok := ds.(*session.IncomingSubgroupStream) + if !ok { + t.Fatalf("AcceptDataStream returned %T, want *session.IncomingSubgroupStream", ds) + } + return sg +} diff --git a/pkg/relay/handler_fetch.go b/pkg/relay/handler_fetch.go index a507d448..cd92c2e2 100644 --- a/pkg/relay/handler_fetch.go +++ b/pkg/relay/handler_fetch.go @@ -116,11 +116,17 @@ func (h *sessionHandler) handleFetch(ctx context.Context, req *session.Request, // independent of which objects we end up streaming, so reply FETCH_OK // before doing any (possibly slow) upstream stitching. endLocation := capFetchEndLocation(filter, largest) + // §10.14: End Of Track is "1 if all Objects have been published on this + // Track, and the End Location is the final Object in the Track". The + // END_OF_TRACK Object raised the watermark, so a FETCH running past it + // ends there. + trackEnd, ended := entry.TrackEnd() var properties []byte if includeProperties(msg.Parameters) { // §10.2.21 properties = entry.GetProperties() } if err := req.Reply(&message.FetchOK{ + EndOfTrack: ended && endLocation == trackEnd, EndLocation: endLocation, TrackProperties: properties, }); err != nil { @@ -166,38 +172,40 @@ func (h *sessionHandler) fetchRangeFilters( return rf, true } -// readFetchUpdates is the follow-up dispatch loop for an established FETCH: -// REQUEST_UPDATE (§10.9) routes to [sessionHandler.handleFetchUpdate]; any -// other follow-up is ignored. On the requester's FIN the relay FINs back -// (§3.3.2). -func (h *sessionHandler) readFetchUpdates(ctx context.Context, req *session.Request) { +// readFetchUpdates is the follow-up dispatch loop for an established FETCH +// whose data stream is out: REQUEST_UPDATE (§10.9) routes to +// [sessionHandler.handleFetchUpdate]; any other follow-up is ignored. On the +// requester's FIN the relay FINs back (§3.3.2). +func (h *sessionHandler) readFetchUpdates(ctx context.Context, req *session.Request, out *session.OutgoingFetchStream) { updates := h.sess.NewRequestUpdateLimiter() fin := readRequestStream(ctx, h.sess, req.Stream, func(m message.Message) bool { if h.isPeerStateNotify(m) { return false } - if upd, ok := m.(*message.RequestUpdate); ok { - // §10.2.1: out-of-scope parameters are session-fatal. - if h.sess.CheckPeerParams(message.ScopeUpdateFetch, upd) != nil { - return false - } - // §10.1: the update consumes a Request ID; a parity or - // duplicate violation is session-fatal. - if !h.handleFollowupRequestID(ctx, upd) { - return false - } - // §10.3.1.7: enforce the per-stream MAX_REQUEST_UPDATES limit. - if !h.handleRequestUpdateLimit(ctx, updates) { - return false - } - // §10.2.2: an update may REGISTER/DELETE token aliases; - // a cache fault there is session-fatal. - if _, ok := h.handleFollowupTokens(ctx, upd); !ok { - return false - } - h.handleFetchUpdate(ctx, req) - updates.Responded() + upd, ok := m.(*message.RequestUpdate) + if !ok { + return true + } + // §10.2.1: out-of-scope parameters are session-fatal. + if h.sess.CheckPeerParams(message.ScopeUpdateFetch, upd) != nil { + return false + } + // §10.1: the update consumes a Request ID; a parity or duplicate + // violation is session-fatal. + if !h.handleFollowupRequestID(ctx, upd) { + return false + } + // §10.3.1.7: enforce the per-stream MAX_REQUEST_UPDATES limit. + if !h.handleRequestUpdateLimit(ctx, updates) { + return false + } + // §10.2.2: an update may REGISTER/DELETE token aliases; a cache + // fault there is session-fatal. + toks, ok := h.handleFollowupTokens(ctx, upd) + if !ok || !h.handleFetchUpdate(ctx, req, out, toks) { + return false } + updates.Responded() return true }) if fin { @@ -207,12 +215,31 @@ func (h *sessionHandler) readFetchUpdates(ctx context.Context, req *session.Requ // handleFetchUpdate answers a REQUEST_UPDATE (§10.9) to an in-flight FETCH // with REQUEST_OK: the in-scope parameters have nothing to change on a -// finished snapshot. -func (h *sessionHandler) handleFetchUpdate(ctx context.Context, req *session.Request) { +// finished snapshot. An update whose tokens (toks) the TokenVerifier denies +// fails, and false is returned: the FETCH is over. +func (h *sessionHandler) handleFetchUpdate( + ctx context.Context, + req *session.Request, + out *session.OutgoingFetchStream, + toks []session.ResolvedToken, +) bool { + if rej := h.refuseUpdateTokens(ctx, toks); rej != nil { + _ = req.RejectError(rej.ErrorCode, rej.ErrorReason) + // §10.9.1: "When a REQUEST_UPDATE fails for a FETCH, the publisher + // MUST reset the FETCH data stream." It was FINed already, so this + // only aborts delivery of what the requester has not acknowledged. + code := moqt.StreamResetCancelled + if rej.ErrorCode == moqt.RequestExpiredAuthToken { + code = moqt.StreamResetExpiredAuthToken // §3.3.4 + } + out.Cancel(code) + return false + } if err := req.Reply(&message.RequestOK{}); err != nil { h.log.LogAttrs(ctx, slog.LevelDebug, "FETCH REQUEST_UPDATE_OK write failed", slog.String("err", err.Error())) } + return true } // fetchGroupOrder is a FETCH's GROUP_ORDER (§10.2.8): Ascending when omitted. @@ -281,7 +308,9 @@ func (h *sessionHandler) stitchedFetchObjects( return fetchElements(cached, unknown, nil, order), nil } span := registry.LocRange{Lo: unknown[0].Lo, Hi: unknown[len(unknown)-1].Hi} + done := h.tracks.BeginFetch(up.Session, fullName.Key()) ans, refusal := h.fetchUpstreamRange(ctx, up, fullName, span, order, fillTimeout) + done() if errors.Is(refusal, session.ErrMalformedTrack) { h.endMalformedTrack(ctx, entry, up.Session, refusal) } @@ -333,20 +362,27 @@ func intersect(a, b []registry.LocRange) []registry.LocRange { return out } -// pickFetchUpstream returns an Established, fetch-capable upstream on a -// different session the relay can issue a stitch FETCH to, or nil. +// pickFetchUpstream returns an Established, fetch-capable upstream the relay +// can issue a stitch FETCH to, or nil. // // Only upstreams the relay reached via an on-demand SUBSCRIBE (a relay/origin, // marked FetchCapable in subscribeUpstream) are eligible: a directly-connected // leaf publisher pushes live objects and is not expected to answer FETCH, so -// stitching to it would only stall. Skipping the requester's own session -// avoids a self-loop (mirrors subscribeUpstream's guard). +// stitching to it would only stall. The requester's own session is eligible +// like any other, as FETCH follows SUBSCRIBE's matching rules (§9.5) and a +// self-subscription is "identical" to any other (§5.1). Deviation: not while +// a stitch FETCH for the track to it is in flight, since this request may be +// that FETCH routed back, and a second one would loop (§6.2); a concurrent +// FETCH looks the same. Either way the hole is marked unknown (§10.13). func (h *sessionHandler) pickFetchUpstream(entry *registry.TrackEntry) *registry.UpstreamSub { for _, u := range entry.CopyUpstream() { - if u.FetchCapable && u.IsEstablished() && u.Session != nil && u.Session != h.sess && - !peerSentGoaway(u.Session) { - return u + if !u.FetchCapable || !u.IsEstablished() || u.Session == nil || goingAway(u.Session) { + continue } + if u.Session == h.sess && h.tracks.FetchPending(u.Session, entry.FullName.Key()) { + continue + } + return u } return nil } @@ -454,8 +490,21 @@ func (h *sessionHandler) fetchUpstreamRange( // for. fs.GroupOrder = order // §10.2.5: the budget covers the response too; when it runs out, what - // has arrived is kept and the rest reported Timed-Out. - defer context.AfterFunc(fctx, func() { fs.Cancel(moqt.StreamResetCancelled) })() + // has arrived is kept and the rest reported Timed-Out. When the track is + // found malformed, up is cancelled with MALFORMED_TRACK if it sent the + // Object (§2.4.2; see endMalformedTrack), and the caller resets the + // downstream stream. + defer context.AfterFunc(fctx, func() { + code := moqt.StreamResetCancelled + if mt, ok := errors.AsType[*malformedTrackCause](context.Cause(fctx)); ok && mt.src == up.Session { + code = moqt.StreamResetMalformedTrack + } + fs.Cancel(code) + })() + // §12.3: this FETCH's MAX_CACHE_DURATION bounds each Object it delivers + // (see [cache.ObjectCache.Expired]). Deviation: the age runs from when the + // relay read the Object whole, not from "the beginning of the Object". + maxAge, hasMaxAge := message.TrackMaxCacheDuration(fr.OK.TrackProperties) var prev *message.Location for { @@ -509,13 +558,17 @@ func (h *sessionHandler) fetchUpstreamRange( pref = cache.ForwardingDatagram } ans.objs = append(ans.objs, &cache.CachedObject{ - GroupID: obj.GroupID, - ObjectID: obj.ObjectID, - SubgroupID: obj.SubgroupID, - PublisherPriority: obj.PublisherPriority, - ForwardingPref: pref, - Properties: obj.Properties, - Payload: obj.Payload, + GroupID: obj.GroupID, + ObjectID: obj.ObjectID, + SubgroupID: obj.SubgroupID, + PublisherPriority: obj.PublisherPriority, + ForwardingPref: pref, + Properties: obj.Properties, + Payload: obj.Payload, + ReceivedAt: time.Now(), + MaxCacheDuration: maxAge, + HasMaxCacheDuration: hasMaxAge, + Stitched: true, }) } prev = &loc diff --git a/pkg/relay/handler_fetch_test.go b/pkg/relay/handler_fetch_test.go index 2c3c8064..83fb4499 100644 --- a/pkg/relay/handler_fetch_test.go +++ b/pkg/relay/handler_fetch_test.go @@ -629,6 +629,40 @@ func TestFetch_OKEndLocationCappedToWatermark(t *testing.T) { if ok.EndLocation != want { t.Fatalf("FETCH_OK.EndLocation = %+v, want %+v", ok.EndLocation, want) } + // §10.14: reaching Largest Object is not reaching the Track's end. + if ok.EndOfTrack { + t.Error("FETCH_OK End Of Track = true on a Track no END_OF_TRACK ended") + } +} + +// TestFetch_OKEndOfTrack: once an END_OF_TRACK Object ended the track, a FETCH +// whose End Location is that Object says End Of Track; one ending earlier does +// not (§10.14). +func TestFetch_OKEndOfTrack(t *testing.T) { + t.Parallel() + pubSess, _, publisherAlias := publishAndCache(t) + sendStreams(t, pubSess, publisherAlias, []testStream{ + {objects: []uint64{0, 1, 2}, status: message.ObjectStatusEndOfTrack}, + }) + fetchSess := dialAnotherClient(t, pubSess) + waitRelayLargest(t, fetchSess, ns("video"), []byte("cam1"), 0, 2) + + for _, tc := range []struct { + name string + end message.Location + want bool + }{ + {"whole track", message.Location{Group: 999, Object: math.MaxUint64}, true}, + {"up to the END_OF_TRACK Object", message.Location{Group: 0, Object: 2}, true}, + {"ending before it", message.Location{Group: 0, Object: 1}, false}, + } { + ok, _ := fetchAndDrain(t, fetchSess, ns("video"), []byte("cam1"), + message.Location{}, tc.end, message.GroupOrderAscending) + if ok.EndOfTrack != tc.want { + t.Errorf("%s: FETCH_OK End Of Track = %t, want %t (End Location %+v)", + tc.name, ok.EndOfTrack, tc.want, ok.EndLocation) + } + } } // TestSubscribe_FillOpensNoStreamOnEmptyTrack: on a track with no Objects the diff --git a/pkg/relay/handler_fill.go b/pkg/relay/handler_fill.go index d4a45df6..2ea17807 100644 --- a/pkg/relay/handler_fill.go +++ b/pkg/relay/handler_fill.go @@ -5,6 +5,7 @@ import ( "errors" "log/slog" "slices" + "sync/atomic" "time" "github.com/floatdrop/moq-go/pkg/moqt" @@ -14,11 +15,21 @@ import ( "github.com/floatdrop/moq-go/pkg/relay/internal/registry" ) +// testHookBeforeFill, when set by a test, runs as a fill is about to be +// evaluated, after the response that reported its Largest Object. +var testHookBeforeFill atomic.Pointer[func(track.FullTrackName)] + // maybeServeFill opens and serves a fill fetch stream for a subscription when // the SUBSCRIBE or REQUEST_UPDATE carried FILL_PARAMETERS (§5.1.3). // // requestID is the Request ID of the message that asked for the fill; the // FETCH_HEADER carries it, so one subscription can have several fills open. +// largest is the Largest Object the subscriber was told and the live filter +// is anchored on (the one SUBSCRIBE_OK or REQUEST_UPDATE_OK reported), and +// hasLargest whether there was one: the fill ends there (§5.1.3), so an Object +// arriving since goes out live only. For a forwarded PUBLISH that is the +// registration snapshot, not the PUBLISH's own LARGEST_OBJECT, which can be +// older: ending there would leave the Objects between neither live nor filled. // // A failure resets the fill stream, leaves the subscription unaffected, and // is returned for the log. AcceptRequest has closed the session on a malformed @@ -30,11 +41,16 @@ func (h *sessionHandler) maybeServeFill( fullName track.FullTrackName, requestID uint64, ps message.Parameters, + largest message.Location, + hasLargest bool, ) error { inner, requested, _ := message.FillParametersFromParam(ps) if !requested { return nil } + if hook := testHookBeforeFill.Load(); hook != nil { + (*hook)(fullName) + } // §5.1.3.1: from here a failure MUST open the fill stream and reset it. fail := func(err error) error { @@ -49,9 +65,8 @@ func (h *sessionHandler) maybeServeFill( } // The fill range is evaluated with Fetch rules (§5.1.2), so it never - // extends past Largest Object. With nothing published there is nothing to - // fill. - largest, hasLargest := entry.GetLargest() + // extends past the Largest Object reported. With nothing published then + // there is nothing to fill. if !hasLargest { return nil } @@ -104,6 +119,8 @@ func (h *sessionHandler) maybeServeFill( fillCtx, cancel := context.WithCancelCause(ctx) defer cancel(nil) defer context.AfterFunc(sub.Cancelled(), func() { cancel(errRequestCancelled) })() + // §2.4.2: a malformed track resets its fill fetch streams too. + defer entry.AddFetch(cancel)() h.serveFill(fillCtx, sub, requestID, entry, fullName, start, end, order, fillTimeout, rangeFilters) }) return nil diff --git a/pkg/relay/handler_forward.go b/pkg/relay/handler_forward.go index d015059f..b95700c1 100644 --- a/pkg/relay/handler_forward.go +++ b/pkg/relay/handler_forward.go @@ -4,22 +4,30 @@ import ( "context" "errors" "log/slog" + "slices" + "sync" + "sync/atomic" "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/internal/registry" ) +// testHookBeforeForwardClaim, when set by a test, runs as forwardTrack is +// about to claim the track, to hold a forward there while another one runs. +var testHookBeforeForwardClaim atomic.Pointer[func(track.FullTrackName)] + // forwardTrack is a SUBSCRIBE_TRACKS subscriber's [registry.SubscriberEntry.ForwardTrack]: // it sends the subscriber a PUBLISH for te (§6.1) with the current // SUBSCRIBE_TRACKS parameters (§10.20.1) and serves the resulting // subscription. At most one PUBLISH per track, and none for a track the -// subscriber publishes or already receives. +// subscriber publishes, already receives, or is SUBSCRIBing to. func (h *sessionHandler) forwardTrack(ctx context.Context) func(*registry.SubscriberEntry, *registry.TrackEntry) { return func(sub *registry.SubscriberEntry, te *registry.TrackEntry) { - if peerSentGoaway(h.sess) { - return // §10.4: no new PUBLISH to a peer that sent GOAWAY + if goingAway(h.sess) { + return // §10.4: no new PUBLISH on a session with a GOAWAY } fullName := te.FullName if !fullName.Namespace.HasPrefix(sub.Prefix()) { @@ -30,13 +38,29 @@ func (h *sessionHandler) forwardTrack(ctx context.Context) func(*registry.Subscr if !tp.RangeFilters.MatchesTrack(te.GetProperties()) { return } - // §6.1: "excluding tracks published by the subscriber". - if te.HasUpstreamOn(h.sess) || te.HasDownstreamOn(h.sess) { + // §6.1: "excluding tracks published by the subscriber". Relay policy, + // not §6.1: nor a track it receives on its own SUBSCRIBE. The in-flight + // check comes before HasDownstreamOn, as the SUBSCRIBE registers its + // downstream before it stops being in flight; and before the claim, so + // the held forward's replay does not find the claim still taken. + key := fullName.Key() + if te.HasUpstreamOn(h.sess) || h.holdForward(key, sub) { return } - // The claim covers a forward whose downstream is not registered yet. - key := fullName.Key() - if !sub.ClaimForward(key) { + if hook := testHookBeforeForwardClaim.Load(); hook != nil { + (*hook)(fullName) + } + // The claim covers a forward whose downstream is not registered yet, + // and is refused while a PUBLISH_SKIPPED holds for this upstream epoch. + epoch := te.UpstreamEpoch() + if !sub.ClaimForward(key, epoch) { + return + } + // Checked under the claim: a forward releases it only once its + // downstream is registered, so whichever forward claims second sees + // that downstream and sends no second PUBLISH. + if te.HasDownstreamOn(h.sess) { + sub.ReleaseForward(key) return } var properties []byte @@ -54,12 +78,12 @@ func (h *sessionHandler) forwardTrack(ctx context.Context) func(*registry.Subscr // §6.1: without bidi-stream credit, send PUBLISH_SKIPPED instead. stream, err := h.sess.OpenPublish(fwd) if err != nil { - sub.ReleaseForward(key) if errors.Is(err, session.ErrNoStreamCredit) { - h.emitPublishSkipped(ctx, sub, fullName) - return + h.emitPublishSkipped(ctx, sub, fullName, epoch) + } else { + h.log.LogAttrs(ctx, slog.LevelDebug, "PUBLISH forward failed", slog.String("err", err.Error())) } - h.log.LogAttrs(ctx, slog.LevelDebug, "PUBLISH forward failed", slog.String("err", err.Error())) + sub.ReleaseForward(key) return } h.relayGo(func() { @@ -119,7 +143,9 @@ func (h *sessionHandler) serveForwardedPublish( // §10.20.1: each forwarded subscription gets its own fill fetch stream, // named by the PUBLISH's Request ID (§10.1; §5.1.3 names only SUBSCRIBE // and REQUEST_UPDATE). - if err := h.maybeServeFill(ctx, sub, te, fullName, fwd.RequestID, params); err != nil { + // The registration snapshot, which the live filter is anchored on; see + // maybeServeFill. + if err := h.maybeServeFill(ctx, sub, te, fullName, fwd.RequestID, params, largest, has); err != nil { h.log.LogAttrs(ctx, slog.LevelDebug, "fill fetch stream not opened", slog.String("err", err.Error())) } @@ -129,3 +155,69 @@ func (h *sessionHandler) serveForwardedPublish( } h.readSubscribeUpdates(ctx, stream, sub, fullName, true) } + +// inflightSubscribe is a track's SUBSCRIBEs in flight on one session. +type inflightSubscribe struct { + n int + // held are the SUBSCRIBE_TRACKS entries whose forward of the track was + // held back meanwhile; see [sessionHandler.holdForward]. + held []*registry.SubscriberEntry +} + +// beginSubscribe marks a SUBSCRIBE for key in flight on this session until +// end, which may run more than once. From before it establishes an upstream +// until its downstream is registered, the track can have an upstream and no +// downstream here, so [sessionHandler.forwardTrack] would otherwise offer the +// SUBSCRIBE_TRACKS holders on this session the track it is SUBSCRIBing to. +// +// When the last SUBSCRIBE for key ends, the forwards held back are offered +// again: forwardTrack declines them if a SUBSCRIBE registered its downstream, +// and sends them if none did, since the track is then received no other way +// (§10.20). +func (h *sessionHandler) beginSubscribe(key track.Key) (end func()) { + h.subscribingMu.Lock() + defer h.subscribingMu.Unlock() + if h.subscribing == nil { + h.subscribing = make(map[track.Key]*inflightSubscribe) + } + f := h.subscribing[key] + if f == nil { + f = &inflightSubscribe{} + h.subscribing[key] = f + } + f.n++ + return sync.OnceFunc(func() { + h.subscribingMu.Lock() + f.n-- + var held []*registry.SubscriberEntry + if f.n == 0 { + delete(h.subscribing, key) + held = f.held + } + h.subscribingMu.Unlock() + if len(held) == 0 { + return + } + if te, ok := h.tracks.Get(key); ok && hasEstablishedUpstream(te) { + for _, sub := range held { + sub.ForwardTrack(sub, te) + } + } + }) +} + +// holdForward reports whether a SUBSCRIBE for key is in flight on this session +// (see [sessionHandler.beginSubscribe]), recording sub to be offered the track +// again once none is. +func (h *sessionHandler) holdForward(key track.Key, sub *registry.SubscriberEntry) bool { + h.subscribingMu.Lock() + defer h.subscribingMu.Unlock() + f := h.subscribing[key] + if f == nil { + return false + } + if !slices.Contains(f.held, sub) { + f.held = append(f.held, sub) + } + return true +} diff --git a/pkg/relay/handler_malformed.go b/pkg/relay/handler_malformed.go index d4b704ab..badbdf73 100644 --- a/pkg/relay/handler_malformed.go +++ b/pkg/relay/handler_malformed.go @@ -11,16 +11,20 @@ import ( // endMalformedTrack handles a malformed track (§2.4.2) detected in an Object // src sent on entry's track: every downstream subscription ends with -// PUBLISH_DONE MALFORMED_TRACK, and only the upstreams on src are cancelled, -// so a redundant publisher (§9.3) keeps serving. Callers never cache the -// Object. Open subgroup streams are reset now, since PUBLISH_DONE waits for -// them (§10.12). Downstream fetch streams are not reset. +// PUBLISH_DONE MALFORMED_TRACK, every downstream fetch stream is reset with +// MALFORMED_TRACK, and only the upstream subscriptions on src are cancelled, +// so a redundant publisher (§9.3) keeps serving. An upstream FETCH ends with +// the downstream fetch stream it fills: its data stream gets STOP_SENDING +// MALFORMED_TRACK when it is on src, else CANCELLED, and its request stream +// is cancelled with CANCELLED. Callers never cache the Object. Open subgroup +// streams are reset now, since PUBLISH_DONE waits for them (§10.12). // // Downstreams are terminated before the upstream is cancelled: the first // termination wins, and the upstream's teardown would use its own code. // -// Interpretation: the upstream cancel also uses MALFORMED_TRACK, which §3.3.4 -// defines for the downstream direction. +// Interpretation: the upstream subscription's cancel and the upstream FETCH +// data stream's STOP_SENDING also use MALFORMED_TRACK, which §3.3.4 defines +// for the downstream direction. func (h *sessionHandler) endMalformedTrack( ctx context.Context, entry *registry.TrackEntry, @@ -32,6 +36,9 @@ func (h *sessionHandler) endMalformedTrack( sub.TerminateWithPublishDone(moqt.PublishDoneMalformedTrack, "relay: malformed track") } h.resetWriters(entry, downstream) + // An upstream FETCH runs only under a downstream fetch stream, so this + // cancels it too; see fetchUpstreamRange. + entry.CancelFetches(&malformedTrackCause{src: src, err: cause}) cancelled := 0 for _, up := range entry.CopyUpstream() { @@ -49,6 +56,19 @@ func (h *sessionHandler) endMalformedTrack( slog.String("name", string(entry.FullName.Name)), slog.String("err", cause.Error())) } +// malformedTrackCause is what a fetch stream on a malformed track is cancelled +// with: the detection error, wrapping [session.ErrMalformedTrack], and the +// session that sent the Object, whose FETCH is cancelled (§2.4.2: "MUST +// cancel any corresponding subscription or fetches for that Track from that +// publisher"). +type malformedTrackCause struct { + src *session.Session + err error +} + +func (c *malformedTrackCause) Error() string { return c.err.Error() } +func (c *malformedTrackCause) Unwrap() error { return c.err } + // resetWriters resets the open subgroup writers of subs on entry with // MALFORMED_TRACK. The nil slot keeps the joiner scan from reopening one. func (h *sessionHandler) resetWriters(entry *registry.TrackEntry, subs []*registry.DownstreamSub) { diff --git a/pkg/relay/handler_namespace.go b/pkg/relay/handler_namespace.go index 6dd4ad00..a8584f01 100644 --- a/pkg/relay/handler_namespace.go +++ b/pkg/relay/handler_namespace.go @@ -54,11 +54,31 @@ func (h *sessionHandler) handlePublishNamespace( defer cancel() h.spawn(func() { h.subscribeExistingTracks(nsCtx, entry) }) - // This goroutine is the only writer on the stream after REQUEST_OK, so - // the acks write directly. - h.serveNamespaceFollowups(ctx, req, func(m message.Message) error { - return message.Marshal(req.Stream, m) - }, nil) + h.serveNamespaceFollowups(ctx, req, h.publishNamespaceUpdate(req)) +} + +// publishNamespaceUpdate answers a REQUEST_UPDATE on a PUBLISH_NAMESPACE with +// REQUEST_OK. One whose tokens the TokenVerifier denies fails, and the relay +// closes the stream: "When a REQUEST_UPDATE fails for a ... PUBLISH_NAMESPACE, +// the responder MUST close the bidi stream" (§10.9.1). This goroutine is the +// only writer on the stream after REQUEST_OK, so the replies write directly. +func (h *sessionHandler) publishNamespaceUpdate( + req *session.Request, +) func(context.Context, *message.RequestUpdate, []session.ResolvedToken) bool { + return func(ctx context.Context, _ *message.RequestUpdate, toks []session.ResolvedToken) bool { + if rej := h.refuseUpdateTokens(ctx, toks); rej != nil { + _ = req.RejectError(rej.ErrorCode, rej.ErrorReason) + return false + } + if err := req.Reply(&message.RequestOK{}); err != nil { + h.log.LogAttrs(ctx, slog.LevelDebug, "namespace REQUEST_UPDATE_OK write failed", + slog.String("err", err.Error())) + // Reset the read side so the peer learns reads stopped. + req.Stream.CancelRead(uint64(moqt.StreamResetInternalError)) + return false + } + return true + } } // subscribeExistingTracks SUBSCRIBEs pub for every existing track its @@ -89,7 +109,7 @@ func (h *sessionHandler) subscribeMissingPublishers( now := time.Now() entry.RetainRefusals(pubs, now) for _, pub := range pubs { - if pub.Session == h.sess || (!reused && pub.Seq <= seq) || + if (!reused && pub.Seq <= seq) || entry.HasUpstreamOn(pub.Session) || entry.Refused(pub, now) { continue } @@ -107,7 +127,9 @@ func (h *sessionHandler) subscribeMissingPublishers( // // Deviation (§9.5 does not qualify "each matching subscription"): skipped // while the track has no downstream, since the upstream would never be -// released, and while pub itself receives the track, which would echo it. +// released. A pub that itself receives the track is SUBSCRIBEd too (§5.1: +// self-subscriptions "are identical to subscriptions initiated by other +// endpoints"). func (h *sessionHandler) subscribeLatePublisher( ctx context.Context, fullName track.FullTrackName, @@ -115,8 +137,7 @@ func (h *sessionHandler) subscribeLatePublisher( ) { pub := pubEntry.Session e, ok := h.tracks.Get(fullName.Key()) - if !ok || !hasEstablishedUpstream(e) || len(e.CopyDownstream()) == 0 || e.HasDownstreamOn(pub) || - e.Refused(pubEntry, time.Now()) { + if !ok || !hasEstablishedUpstream(e) || len(e.CopyDownstream()) == 0 || e.Refused(pubEntry, time.Now()) { return } release, claimed := h.tracks.ClaimUpstream(pub, fullName.Key()) @@ -201,7 +222,7 @@ func (h *sessionHandler) handleSubscribeNamespace( h.spawn(entry.RunWriter) // Replies share the entry's queue, keeping their order with NAMESPACE. - h.serveNamespaceFollowups(ctx, req, enqueueReply(entry), h.namespaceUpdate(entry, &prefix, msg)) + h.serveNamespaceFollowups(ctx, req, h.namespaceUpdate(entry, &prefix, msg)) } // handleSubscribeTracks implements SUBSCRIBE_TRACKS (§6.1, §10.20): @@ -265,7 +286,7 @@ func (h *sessionHandler) handleSubscribeTracks( } // Replies share the entry's queue with PUBLISH_SKIPPED, so each // PUBLISH_SKIPPED suffix matches the prefix the subscriber last saw. - h.serveNamespaceFollowups(ctx, req, enqueueReply(entry), h.tracksUpdate(entry, &prefix, msg)) + h.serveNamespaceFollowups(ctx, req, h.tracksUpdate(entry, &prefix, msg)) } // subscribeTracksForwarding resolves a SUBSCRIBE_TRACKS's FORWARD (§10.2.18, @@ -284,13 +305,11 @@ func subscribeTracksForwarding(ps message.Parameters) (forward bool, groupOrder // serveNamespaceFollowups holds a namespace request stream open and answers // each REQUEST_UPDATE (§10.9), validating its Request ID (§10.1) and resolving -// its tokens. The subscriptions pass update, which authorizes and applies it -// and replies; with update nil (PUBLISH_NAMESPACE) write sends a plain -// REQUEST_OK. Other follow-ups are ignored. +// its tokens; update authorizes and applies it, and replies. Other follow-ups +// are ignored. func (h *sessionHandler) serveNamespaceFollowups( ctx context.Context, req *session.Request, - write func(message.Message) error, update func(context.Context, *message.RequestUpdate, []session.ResolvedToken) bool, ) { stream := req.Stream @@ -321,18 +340,7 @@ func (h *sessionHandler) serveNamespaceFollowups( return false } // false from update ends the request. - if update != nil { - if !update(ctx, upd, toks) { - return false - } - updates.Responded() - return true - } - if err := write(&message.RequestOK{}); err != nil { - h.log.LogAttrs(ctx, slog.LevelDebug, "namespace REQUEST_UPDATE_OK write failed", - slog.String("err", err.Error())) - // Reset the read side so the peer learns reads stopped. - stream.CancelRead(uint64(moqt.StreamResetInternalError)) + if !update(ctx, upd, toks) { return false } updates.Responded() @@ -345,15 +353,6 @@ func (h *sessionHandler) serveNamespaceFollowups( } } -// enqueueReply writes a namespace subscription's REQUEST_UPDATE replies -// through its queue, behind the NAMESPACE / NAMESPACE_DONE already queued. -func enqueueReply(e *registry.SubscriberEntry) func(message.Message) error { - return func(m message.Message) error { - e.Enqueue(m) - return nil - } -} - // updatePrefixParam reads a REQUEST_UPDATE's TRACK_NAMESPACE_PREFIX // (§10.2.20), if any. ok is false when it is malformed: the session is then // closed with PROTOCOL_VIOLATION. @@ -485,9 +484,8 @@ func (h *sessionHandler) refuseUpdate( prefixChanged bool, authorize func() error, ) *message.RequestError { - if err := h.sess.VerifyTokens(ctx, toks); err != nil { - code, reason := tokenDenial(err) - return &message.RequestError{ErrorCode: code, ErrorReason: reason} + if rej := h.refuseUpdateTokens(ctx, toks); rej != nil { + return rej } if !prefixChanged { return nil diff --git a/pkg/relay/handler_publish.go b/pkg/relay/handler_publish.go index cd26f353..3a1a344f 100644 --- a/pkg/relay/handler_publish.go +++ b/pkg/relay/handler_publish.go @@ -38,8 +38,9 @@ func (h *sessionHandler) handlePublish(ctx context.Context, req *session.Request slog.String("name", string(msg.Name)), slog.Uint64("alias", msg.TrackAlias)) - // §2.5.1: refuse an unknown Mandatory Track Property. MALFORMED_TRACK for - // unparseable Track Properties is this repo's choice; the draft is silent. + // §2.5.1: refuse an unknown Mandatory Track Property. Unparseable Track + // Properties are refused with INTERNAL_ERROR, since §10.6.2 defines + // MALFORMED_TRACK only for FETCH (see [session.TrackPropertiesRejectCode]). // AcceptRequest has closed the session on a session-fatal value (§12.5, // §12.6). if err := h.sess.CheckTrackProperties(msg.TrackProperties, "PUBLISH"); err != nil { @@ -177,15 +178,20 @@ func publishParamsForSubscriber(tp *registry.TracksParams, entry *registry.Track // emitPublishSkipped queues a PUBLISH_SKIPPED (§10.21) for fullName on sub's // SUBSCRIBE_TRACKS stream, the §6.1 response to an exhausted bidi-stream -// limit. The skip is scoped to this one PUBLISH, so nothing is recorded. +// limit, and records it at the track's upstream epoch: the skip is "scoped to +// a single PUBLISH", so only a new upstream offers the track again, not a +// prefix update that moves away and back (see +// [registry.SubscriberEntry.NoteSkipped]). func (h *sessionHandler) emitPublishSkipped( ctx context.Context, sub *registry.SubscriberEntry, fullName track.FullTrackName, + epoch uint64, ) { if !h.names.PublishSkipped(sub, fullName.Namespace, fullName.Name) { return // a TRACK_NAMESPACE_PREFIX update moved the subscription away } + sub.NoteSkipped(fullName.Key(), epoch) h.log.LogAttrs(ctx, slog.LevelDebug, "PUBLISH_SKIPPED queued", slog.String("name", string(fullName.Name))) } diff --git a/pkg/relay/handler_subscribe.go b/pkg/relay/handler_subscribe.go index 832e6103..969acc98 100644 --- a/pkg/relay/handler_subscribe.go +++ b/pkg/relay/handler_subscribe.go @@ -6,6 +6,8 @@ import ( "fmt" "log/slog" "slices" + "sync/atomic" + "time" "github.com/floatdrop/moq-go/pkg/moqt" "github.com/floatdrop/moq-go/pkg/moqt/message" @@ -14,10 +16,16 @@ import ( "github.com/floatdrop/moq-go/pkg/relay/internal/registry" ) +// testHookBeforeDownstreamRegistered, when set by a test, runs once a +// SUBSCRIBE has an upstream for its track and before its downstream is +// registered, to hold that window open. +var testHookBeforeDownstreamRegistered atomic.Pointer[func(track.FullTrackName)] + // handleSubscribe implements the SUBSCRIBE flow (§9.4, §10.7): authorize, // serve from an Established upstream or establish one on demand (see -// [sessionHandler.subscribeUpstream]), else reject with -// [moqt.RequestDoesNotExist]; then register a [registry.DownstreamSub], reply +// [sessionHandler.acquireUpstream]), else reject with +// [moqt.RequestDoesNotExist], or with [moqt.RequestTimeout] once a +// RENDEZVOUS_TIMEOUT hold expires (§10.2.6); then register a [registry.DownstreamSub], reply // SUBSCRIBE_OK, and serve the request stream until it ends. func (h *sessionHandler) handleSubscribe(ctx context.Context, req *session.Request, msg *message.Subscribe) { h.log.LogAttrs(ctx, slog.LevelDebug, "SUBSCRIBE received", @@ -31,8 +39,8 @@ func (h *sessionHandler) handleSubscribe(ctx context.Context, req *session.Reque fullName := track.FullTrackName{Namespace: msg.Namespace, Name: msg.Name} - // §10.2.19: a NEW_GROUP_REQUEST rides a new upstream SUBSCRIBE (rule 1), - // or is evaluated against an existing upstream below. + // §10.2.19: a NEW_GROUP_REQUEST rides a new upstream SUBSCRIBE (rule 1; + // see acquireUpstream), or is evaluated against an existing upstream below. newGroupReqParam, hasNewGroupReq := msg.Parameters.Find(message.ParamNewGroupRequest) // §11.1: outbound aliases are independent of the peer's inbound ones. @@ -53,45 +61,18 @@ func (h *sessionHandler) handleSubscribe(ctx context.Context, req *session.Reque reusedUpstream bool added bool ) - // Publishers that register after this point are picked up below, once the - // downstream is on the entry. - pubSeq := h.names.Seq() + // Publishers that register after acquireUpstream looked are picked up + // below, once the downstream is on the entry. + var pubSeq uint64 + // In flight until the downstream is registered; see + // [sessionHandler.beginSubscribe]. + settled := h.beginSubscribe(fullName.Key()) + defer settled() + rv := h.newRendezvous(msg.Parameters) for range 2 { - e, ok := h.tracks.Get(fullName.Key()) - if !ok || !hasEstablishedUpstream(e) { - h.log.LogAttrs(ctx, slog.LevelDebug, "SUBSCRIBE no established upstream, trying on-demand", - slog.Bool("entry_exists", ok)) - var extra message.Parameters - if hasNewGroupReq { - extra = message.Parameters{message.NewGroupRequestParam(newGroupReqParam.Varint)} - } - // §9.2: Forward=1 upstream only if some downstream forwards; sub - // is not on the entry yet, so it is checked directly. - wantForward := sub.ForwardState() == 1 || anyDownstreamForwards(e) - _, established, err := h.subscribeUpstream(ctx, fullName, extra, wantForward) - if err != nil { - h.log.LogAttrs(ctx, slog.LevelInfo, "SUBSCRIBE rejected: upstream subscribe failed", - slog.String("namespace", fmt.Sprintf("%v", msg.Namespace)), - slog.String("name", string(msg.Name)), - slog.Uint64("request_id", msg.RequestID), - slog.String("err", err.Error())) - rej := upstreamRejection(err) - rej.Reason = "relay: no upstream for track: " + err.Error() - _ = req.Reject(rej) - return - } - if !established { - h.log.LogAttrs(ctx, slog.LevelInfo, "SUBSCRIBE rejected: no publisher for namespace", - slog.String("namespace", fmt.Sprintf("%v", msg.Namespace)), - slog.String("name", string(msg.Name)), - slog.Uint64("request_id", msg.RequestID)) - _ = req.RejectError(moqt.RequestDoesNotExist, "relay: no publisher for namespace") - return - } - reusedUpstream = false - } else { - reusedUpstream = true - h.log.LogAttrs(ctx, slog.LevelDebug, "SUBSCRIBE serving from existing upstream") + var ok bool + if reusedUpstream, pubSeq, ok = h.acquireUpstream(ctx, req, msg, sub, rv); !ok { + return } // Before registration, so the first stream opened for it already @@ -99,6 +80,9 @@ func (h *sessionHandler) handleSubscribe(ctx context.Context, req *session.Reque if cur, ok := h.tracks.Get(fullName.Key()); ok { resolveGroupOrder(sub, cur) } + if hook := testHookBeforeDownstreamRegistered.Load(); hook != nil { + (*hook)(fullName) + } // Register and snapshot Largest atomically, so no object falls between // live delivery and the fill fetch stream. entry, snapshotLargest, snapshotHas, added = h.tracks.AddDownstreamSnapshotLargest(fullName, sub) @@ -106,6 +90,7 @@ func (h *sessionHandler) handleSubscribe(ctx context.Context, req *session.Reque break } } + settled() if !added { h.log.LogAttrs(ctx, slog.LevelDebug, "SUBSCRIBE rejected: upstream vanished during registration") _ = req.RejectError(moqt.RequestDoesNotExist, "relay: upstream vanished") @@ -115,7 +100,9 @@ func (h *sessionHandler) handleSubscribe(ctx context.Context, req *session.Reque // §9.5: "Relays MUST send SUBSCRIBE messages to all matching publishers". h.subscribeMissingPublishers(ctx, entry, reusedUpstream, pubSeq) // §10.20: a newly upstreamed track is offered to SUBSCRIBE_TRACKS holders; - // after registration, so this subscriber is not offered its own track. + // after registration, so this subscriber is not offered its own track. The + // forwards racing registration from other paths are stopped by + // beginSubscribe. if !reusedUpstream { h.forwardToTrackSubscribers(entry) } @@ -162,7 +149,8 @@ func (h *sessionHandler) handleSubscribe(ctx context.Context, req *session.Reque // §5.1.3: FILL_PARAMETERS asks for a fill fetch stream; AcceptRequest // has closed the session on a malformed one. - if err := h.maybeServeFill(ctx, sub, entry, fullName, msg.RequestID, msg.Parameters); err != nil { + if err := h.maybeServeFill(ctx, sub, entry, fullName, msg.RequestID, msg.Parameters, + snapshotLargest, snapshotHas); err != nil { h.log.LogAttrs(ctx, slog.LevelDebug, "fill fetch stream not opened", slog.String("err", err.Error())) } @@ -227,10 +215,11 @@ func (h *sessionHandler) readSubscribeUpdates( } // §10.2.2: an update may REGISTER/DELETE token aliases; // a cache fault there is session-fatal. - if _, ok := h.handleFollowupTokens(ctx, upd); !ok { + toks, ok := h.handleFollowupTokens(ctx, upd) + if !ok { return false } - h.handleSubscribeUpdate(ctx, sub, fullName, upd) + h.handleSubscribeUpdate(ctx, sub, fullName, upd, toks) updates.Responded() } return true @@ -241,32 +230,42 @@ func (h *sessionHandler) readSubscribeUpdates( } // handleSubscribeUpdate applies a REQUEST_UPDATE (§10.9) to a downstream -// subscription: present parameters override, omitted ones are kept. A -// malformed update gets REQUEST_ERROR and PUBLISH_DONE / UPDATE_FAILED. +// subscription: present parameters override, omitted ones are kept. An update +// whose tokens (toks) the TokenVerifier denies, or that is malformed, gets +// REQUEST_ERROR and PUBLISH_DONE / UPDATE_FAILED. func (h *sessionHandler) handleSubscribeUpdate( ctx context.Context, sub *registry.DownstreamSub, fullName track.FullTrackName, upd *message.RequestUpdate, + toks []session.ResolvedToken, ) { + // §10.9.1: REQUEST_ERROR, then PUBLISH_DONE / UPDATE_FAILED. Writes go + // through the sub's lock. + fail := func(rej *message.RequestError) { + h.log.LogAttrs(ctx, slog.LevelDebug, "REQUEST_UPDATE refused", + slog.String("err", rej.ErrorReason)) + _ = sub.WriteMessage(rej) + sub.TerminateWithPublishDone(moqt.PublishDoneUpdateFailed, rej.ErrorReason) + } + if rej := h.refuseUpdateTokens(ctx, toks); rej != nil { + fail(rej) + return + } prevForward := sub.ForwardState() if err := installSubscribeParams(sub, upd.Parameters); err != nil { - h.log.LogAttrs(ctx, slog.LevelDebug, "REQUEST_UPDATE range filter rejected", - slog.String("err", err.Error())) - // §10.9.1: REQUEST_ERROR, then PUBLISH_DONE / UPDATE_FAILED. Writes go - // through the sub's lock. - _ = sub.WriteMessage(&message.RequestError{ - ErrorCode: moqt.RequestInvalidFilter, - ErrorReason: err.Error(), - }) - sub.TerminateWithPublishDone(moqt.PublishDoneUpdateFailed, err.Error()) + fail(&message.RequestError{ErrorCode: moqt.RequestInvalidFilter, ErrorReason: err.Error()}) return } // §10.2.17: LARGEST_OBJECT in REQUEST_UPDATE_OK too. reply := &message.RequestOK{} + var ( + largest message.Location + hasLargest bool + ) if entry, ok := h.tracks.Get(fullName.Key()); ok { - if largest, has := entry.GetLargest(); has { + if largest, hasLargest = entry.GetLargest(); hasLargest { reply.Parameters = message.Parameters{message.LargestObjectParam(largest.Group, largest.Object)} } } @@ -289,7 +288,8 @@ func (h *sessionHandler) handleSubscribeUpdate( // §5.1.3: a further fill fetch stream, named by the REQUEST_UPDATE's own // Request ID; fills already in flight continue. if entry, ok := h.tracks.Get(fullName.Key()); ok { - if err := h.maybeServeFill(ctx, sub, entry, fullName, upd.RequestID, upd.Parameters); err != nil { + if err := h.maybeServeFill(ctx, sub, entry, fullName, upd.RequestID, upd.Parameters, + largest, hasLargest); err != nil { h.log.LogAttrs(ctx, slog.LevelDebug, "fill fetch stream not opened", slog.String("err", err.Error())) } @@ -357,6 +357,185 @@ func (h *sessionHandler) propagateForwardUpstream(ctx context.Context, fullName } } +// acquireUpstream makes sure msg's track has an Established upstream, reusing +// one or establishing one on demand (see [sessionHandler.subscribeUpstream]), +// and reports whether it reused one and the publisher Seq it looked at. When +// there is none it answers req with REQUEST_ERROR and reports ok=false, after +// holding the SUBSCRIBE for a publisher up to rv's deadline if rv is non-nil +// (§10.2.6). +func (h *sessionHandler) acquireUpstream( + ctx context.Context, + req *session.Request, + msg *message.Subscribe, + sub *registry.DownstreamSub, + rv *rendezvous, +) (reused bool, pubSeq uint64, ok bool) { + fullName := track.FullTrackName{Namespace: msg.Namespace, Name: msg.Name} + for { + // Begun before looking, so a publisher arriving meanwhile is not + // missed; it also cuts short an upstream relay's hold (see + // subscribeUpstream) once a publisher arrives here. + lookCtx := ctx + var look *holdLook + if rv != nil { + look = h.beginHoldLook(ctx, req, fullName) + lookCtx = look.ctx + } + pubSeq = h.names.Seq() + e, found := h.tracks.Get(fullName.Key()) + if found && hasEstablishedUpstream(e) { + look.end() + h.log.LogAttrs(ctx, slog.LevelDebug, "SUBSCRIBE serving from existing upstream") + return true, pubSeq, true + } + h.log.LogAttrs(ctx, slog.LevelDebug, "SUBSCRIBE no established upstream, trying on-demand", + slog.Bool("entry_exists", found)) + var extra message.Parameters + if p, ok := msg.Parameters.Find(message.ParamNewGroupRequest); ok { + extra = message.Parameters{message.NewGroupRequestParam(p.Varint)} + } + // §9.2: Forward=1 upstream only if some downstream forwards; sub + // is not on the entry yet, so it is checked directly. + wantForward := sub.ForwardState() == 1 || anyDownstreamForwards(e) + _, established, err := h.subscribeUpstream(lookCtx, fullName, extra, wantForward, rv) + if established { + look.end() + return false, pubSeq, true + } + // A relay draining this session holds nothing (§10.4). + if look != nil && !goingAway(h.sess) && (awaitsPublisher(err) || lookCtx.Err() != nil) { + arrived := look.wait(rv.deadline) + look.end() + if arrived { + continue + } + if ctx.Err() != nil || req.Stream.Context().Err() != nil { + return false, 0, false // the subscriber or the session is gone + } + h.log.LogAttrs(ctx, slog.LevelInfo, "SUBSCRIBE rejected: no publisher within RENDEZVOUS_TIMEOUT", + slog.String("namespace", fmt.Sprintf("%v", msg.Namespace)), + slog.String("name", string(msg.Name)), + slog.Uint64("request_id", msg.RequestID)) + // §10.2.6: "If the timeout expires without a publisher, the relay + // SHOULD respond with REQUEST_ERROR with error code TIMEOUT." + _ = req.RejectError(moqt.RequestTimeout, "relay: no publisher within RENDEZVOUS_TIMEOUT") + return false, 0, false + } + look.end() + if err != nil { + h.log.LogAttrs(ctx, slog.LevelInfo, "SUBSCRIBE rejected: upstream subscribe failed", + slog.String("namespace", fmt.Sprintf("%v", msg.Namespace)), + slog.String("name", string(msg.Name)), + slog.Uint64("request_id", msg.RequestID), + slog.String("err", err.Error())) + rej := upstreamRejection(err) + rej.Reason = "relay: no upstream for track: " + err.Error() + _ = req.Reject(rej) + return false, 0, false + } + h.log.LogAttrs(ctx, slog.LevelInfo, "SUBSCRIBE rejected: no publisher for namespace", + slog.String("namespace", fmt.Sprintf("%v", msg.Namespace)), + slog.String("name", string(msg.Name)), + slog.Uint64("request_id", msg.RequestID)) + // §10.2.6: without RENDEZVOUS_TIMEOUT, or with 0, "The relay MUST + // immediately return REQUEST_ERROR with error code DOES_NOT_EXIST". + _ = req.RejectError(moqt.RequestDoesNotExist, "relay: no publisher for namespace") + return false, 0, false + } +} + +// rendezvous is a SUBSCRIBE held for a publisher (§10.2.6). +type rendezvous struct { + deadline time.Time + // asked are the sessions tried for the track during the hold, those + // already serving it included; one that answered is not asked again when + // another publisher arrives. + asked map[*session.Session]bool +} + +// newRendezvous returns the hold ps's RENDEZVOUS_TIMEOUT asks for, capped at +// [Config.MaxRendezvousTimeout] ("The relay MAY use a shorter timeout than +// requested", §10.2.6), or nil for none: absent, 0, or a cap of 0. +func (h *sessionHandler) newRendezvous(ps message.Parameters) *rendezvous { + p, ok := ps.Find(message.ParamRendezvousTimeout) + if !ok || p.Varint == 0 || h.maxRendezvous <= 0 { + return nil + } + d := h.maxRendezvous + if p.Varint < uint64(d.Milliseconds()) { //nolint:gosec // G115: maxRendezvous is positive. + d = message.MillisecondTimeout(p.Varint) + } + return &rendezvous{deadline: time.Now().Add(d), asked: make(map[*session.Session]bool)} +} + +// errPublisherArrived ends a [holdLook] once a publisher arrives. +var errPublisherArrived = errors.New("relay: publisher arrived") + +// holdLook is one look for a publisher during a hold: ctx ends once a +// publisher arrives for the track (errPublisherArrived), or the subscriber +// cancels the SUBSCRIBE, or the session ends. +type holdLook struct { + ctx context.Context + cancel context.CancelCauseFunc + stopped func() +} + +func (h *sessionHandler) beginHoldLook( + ctx context.Context, + req *session.Request, + fullName track.FullTrackName, +) *holdLook { + trackArrived, stopTrack := h.tracks.AwaitUpstream(fullName.Key()) + nsArrived, stopNS := h.names.AwaitPublisher(fullName.Namespace) + lookCtx, cancel := context.WithCancelCause(ctx) + go func() { + select { + case <-trackArrived: + cancel(errPublisherArrived) + case <-nsArrived: + cancel(errPublisherArrived) + case <-req.Stream.Context().Done(): + cancel(nil) + case <-lookCtx.Done(): + } + }() + return &holdLook{ctx: lookCtx, cancel: cancel, stopped: func() { stopTrack(); stopNS() }} +} + +// wait blocks until the look ends or deadline passes, and reports whether a +// publisher arrived. +func (l *holdLook) wait(deadline time.Time) bool { + timer := time.NewTimer(time.Until(deadline)) + defer timer.Stop() + select { + case <-l.ctx.Done(): + case <-timer.C: + } + return errors.Is(context.Cause(l.ctx), errPublisherArrived) +} + +// end releases the look; a nil look is none. +func (l *holdLook) end() { + if l == nil { + return + } + l.cancel(nil) + l.stopped() +} + +// awaitsPublisher reports whether err, from [sessionHandler.subscribeUpstream], +// leaves the track without a current publisher: none matched (nil), or every +// one that did answered DOES_NOT_EXIST, or TIMEOUT for an upstream relay's own +// hold, or is draining (§10.4), since subscribeUpstream reports such an error +// only when no other kind occurred. +func awaitsPublisher(err error) bool { + if err == nil || errors.Is(err, errGoingAway) { + return true + } + rej, ok := errors.AsType[*session.RequestRejectedError](err) + return ok && (rej.Code == moqt.RequestDoesNotExist || rej.Code == moqt.RequestTimeout) +} + // subscribeUpstream subscribes fullName on every matching source (§9.5): // each local publisher of a covering namespace and each remote relay // Discovery resolves (capped by Config.UpstreamFanIn), skipping sessions @@ -369,9 +548,22 @@ func (h *sessionHandler) subscribeUpstream( fullName track.FullTrackName, extra message.Parameters, wantForward bool, + rv *rendezvous, ) (*registry.TrackEntry, bool, error) { - // Never subscribe twice on one session, nor on our own (a self-loop). - subscribed := map[*session.Session]bool{h.sess: true} + // Never subscribe twice on one session, nor, while rv holds the + // SUBSCRIBE, on one asked before. The requester's own session is a + // candidate like any other: "An endpoint MAY SUBSCRIBE to a Track it is + // publishing ... Such self-subscriptions are identical to subscriptions + // initiated by other endpoints" (§5.1). + subscribed := map[*session.Session]bool{} + remoteExtra := extra + if rv != nil { + subscribed = rv.asked + // §10.2.6: an upstream relay holds it for what is left of the budget. + if left := time.Until(rv.deadline); left > 0 { + remoteExtra = append(slices.Clip(extra), message.RendezvousTimeoutParam(left)) + } + } if entry, ok := h.tracks.Get(fullName.Key()); ok { for _, u := range entry.CopyUpstream() { subscribed[u.Session] = true @@ -383,23 +575,39 @@ func (h *sessionHandler) subscribeUpstream( anyEstab bool lastErr error ) - establish := func(sess *session.Session, src string) { - if subscribed[sess] { + establish := func(sess *session.Session, src string, params message.Parameters) { + if subscribed[sess] || ctx.Err() != nil { return } subscribed[sess] = true // even on failure: don't retry the same source here // Hold the claim when free, so a late-publisher SUBSCRIBE skips. Never // wait on another holder: its SUBSCRIBE may fail for its own reasons. - if release, claimed := h.tracks.ClaimUpstream(sess, fullName.Key()); claimed { + release, claimed := h.tracks.ClaimUpstream(sess, fullName.Key()) + if claimed { defer release() + } else if sess == h.sess { + // Deviation (§5.1 "identical"): while a SUBSCRIBE for the track + // to the requester is pending, this request may be that SUBSCRIBE + // routed back to the relay, and a second one to it would loop + // (§6.2). A genuine concurrent self-subscription looks the same + // and is declined too. + return } h.log.LogAttrs(ctx, slog.LevelDebug, "subscribeUpstream: issuing upstream SUBSCRIBE", slog.String("source", src)) - entry, _, err := h.subscribeUpstreamOnSession(ctx, sess, fullName, extra, wantForward) + entry, _, err := h.subscribeUpstreamOnSession(ctx, sess, fullName, params, wantForward) if err != nil { - // Keep going. A Track Properties refusal outranks other errors: - // §2.5.1 fixes its downstream code. - if !isTrackPropertiesErr(lastErr) { + if ctx.Err() != nil { + // A held SUBSCRIBE's look was cut short (see + // acquireUpstream): sess is asked again on the next. + delete(subscribed, sess) + return + } + // Keep going. A Track Properties refusal outranks other errors + // (§2.5.1 fixes its downstream code), and any error outranks + // one that only says the track has no publisher yet, so a + // held SUBSCRIBE ends on it (see awaitsPublisher). + if isTrackPropertiesErr(err) || (!isTrackPropertiesErr(lastErr) && awaitsPublisher(lastErr)) { lastErr = err } h.log.LogAttrs(ctx, slog.LevelDebug, "subscribeUpstream: candidate failed, continuing", @@ -417,12 +625,12 @@ func (h *sessionHandler) subscribeUpstream( slog.String("namespace", fmt.Sprintf("%v", fullName.Namespace)), slog.Int("publishers_found", len(publishers))) for _, pub := range publishers { - establish(pub.Session, "local-publisher") + establish(pub.Session, "local-publisher", extra) } remotes := h.upstreams.resolveUpstreams(ctx, fullName.Namespace) for _, remote := range remotes { - establish(remote, "discovery-remote") + establish(remote, "discovery-remote", remoteExtra) } if anyEstab { @@ -450,8 +658,8 @@ func (h *sessionHandler) subscribeUpstreamOnSession( extra message.Parameters, wantForward bool, ) (*registry.TrackEntry, *registry.UpstreamSub, error) { - if peerSentGoaway(sess) { - return nil, nil, errPeerGoingAway + if goingAway(sess) { + return nil, nil, errGoingAway } // §9.4: always Next Object (§5.1.2), so one upstream serves every // downstream filter; the fanout applies those. @@ -594,7 +802,11 @@ func installSubscribeParams(sub *registry.DownstreamSub, ps message.Parameters) if p, ok := ps.Find(message.ParamGroupOrder); ok { sub.SetGroupOrder(p.Byte) } - sub.SetIncludeProperties(includeProperties(ps)) + // §10.9: absent from a REQUEST_UPDATE (it cannot appear there, + // §10.2.21), INCLUDE_PROPERTIES "remains unchanged". + if p, ok := ps.Find(message.ParamIncludeProperties); ok { + sub.SetIncludeProperties(p.Byte != 0) + } // §10.2.3 / §10.2.4: each timeout separately, so an update of one does // not zero ("no timeout", §8) the other. @@ -631,9 +843,9 @@ func (h *sessionHandler) refuseSubscriptionParams(ctx context.Context, req *sess _ = req.RejectError(moqt.RequestInvalidFilter, err.Error()) } -// errPeerGoingAway reports a request the relay did not send because the peer -// sent GOAWAY (§10.4; see [peerSentGoaway]). -var errPeerGoingAway = errors.New("relay: peer sent GOAWAY; no new requests to it (§10.4)") +// errGoingAway reports a request the relay did not send because of a GOAWAY on +// the session, in either direction (§10.4; see [goingAway]). +var errGoingAway = errors.New("relay: GOAWAY on the session; no new requests on it (§10.4)") // includeProperties reports whether INCLUDE_PROPERTIES (§10.2.21) asks for // Track Properties: yes unless it is 0. @@ -647,17 +859,28 @@ func includeProperties(ps message.Parameters) bool { // // An unknown Mandatory Track Property is UNSUPPORTED_EXTENSION (§2.5.1); // unparseable Track Properties make the track malformed (§12.7, §2.4.2), and -// MALFORMED_TRACK answers them (an interpretation: §10.6 defines it for FETCH). An upstream REQUEST_ERROR code about the track -// or the publisher's load passes through with its Retry Interval (§10.6.2), -// MALFORMED_TRACK included though §10.6.2 scopes it to FETCH; one about the -// relay's own hop or its Next Object filter, or an unknown one, becomes -// INTERNAL_ERROR. If the relay ever combines downstream filters +// INTERNAL_ERROR answers them, since MALFORMED_TRACK is defined only "In +// response to a FETCH" (§10.6.2). An upstream REQUEST_ERROR code about the +// track or the publisher's load passes through with its Retry Interval +// (§10.6.2); one about the relay's own hop or its Next Object filter, one not +// defined for SUBSCRIBE (MALFORMED_TRACK among them), or an unknown one, +// becomes INTERNAL_ERROR. If the relay ever combines downstream filters // upstream (§9.4), INVALID_RANGE must pass through too. Any other failure // reads as DOES_NOT_EXIST. func upstreamRejection(err error) *session.RequestRejectedError { if isTrackPropertiesErr(err) { return &session.RequestRejectedError{Code: session.TrackPropertiesRejectCode(err)} } + if errors.Is(err, errGoingAway) { + // GOING_AWAY: "The endpoint has received a GOAWAY and MAY reject new + // requests" (§10.6.2); on the relay's own drain, it "has sent or + // received a GOAWAY" (§3.3.4). The publisher may return, here or + // elsewhere. + return &session.RequestRejectedError{ + Code: moqt.RequestGoingAway, + RetryInterval: retryIntervalAfter(time.Second), + } + } up, ok := errors.AsType[*session.RequestRejectedError](err) if !ok { return &session.RequestRejectedError{Code: moqt.RequestDoesNotExist} @@ -665,12 +888,13 @@ func upstreamRejection(err error) *session.RequestRejectedError { rej := &session.RequestRejectedError{Code: moqt.RequestInternalError, RetryInterval: up.RetryInterval} switch up.Code { case moqt.RequestDoesNotExist, moqt.RequestTimeout, moqt.RequestExcessiveLoad, - moqt.RequestMalformedTrack, moqt.RequestUnsupportedExtension: + moqt.RequestUnsupportedExtension: rej.Code = up.Code case moqt.RequestInternalError, moqt.RequestUnauthorized, moqt.RequestNotSupported, moqt.RequestMalformedAuthToken, moqt.RequestExpiredAuthToken, moqt.RequestGoingAway, moqt.RequestInvalidRange, moqt.RequestInvalidFilter, moqt.RequestRedirect, - moqt.RequestUninterested, moqt.RequestPrefixOverlap, moqt.RequestNamespaceTooLarge: + moqt.RequestMalformedTrack, moqt.RequestUninterested, moqt.RequestPrefixOverlap, + moqt.RequestNamespaceTooLarge: // about the relay's hop or request, or not a SUBSCRIBE answer at all } return rej diff --git a/pkg/relay/handler_track_status.go b/pkg/relay/handler_track_status.go index 99cf7c16..5f4cdaae 100644 --- a/pkg/relay/handler_track_status.go +++ b/pkg/relay/handler_track_status.go @@ -17,9 +17,10 @@ import ( // [message.TrackStatusOK]) carrying the same Track Properties block SUBSCRIBE_OK // would, plus §10.2.17 LARGEST_OBJECT when objects have been forwarded. // -// It answers from the track registry when metadata exists, falls back to an -// empty TRACK_STATUS_OK when only the namespace is advertised locally, and -// otherwise rejects with [moqt.RequestDoesNotExist]. +// It answers from the track registry when the track has an established +// upstream or metadata, falls back to an empty TRACK_STATUS_OK when only the +// namespace is advertised locally, and otherwise rejects with +// [moqt.RequestDoesNotExist]. func (h *sessionHandler) handleTrackStatus(ctx context.Context, req *session.Request, msg *message.TrackStatus) { if err := h.auth.AuthorizeTrackStatus(ctx, h.sess, msg); err != nil { h.rejectAuth(ctx, req, "TrackStatus", err) @@ -28,8 +29,10 @@ func (h *sessionHandler) handleTrackStatus(ctx context.Context, req *session.Req fullName := track.FullTrackName{Namespace: msg.Namespace, Name: msg.Name} entry, known := h.tracks.Get(fullName.Key()) - // Answer TRACK_STATUS_OK for any entry with metadata to surface: Properties - // or a §10.2.17 LargestObject watermark. Either field alone is useful. + // Answer TRACK_STATUS_OK for an entry with an established upstream, which + // SUBSCRIBE would accept (§10.15: "treats it identically as if it had + // received a SUBSCRIBE"), or with metadata to surface: Properties or a + // §10.2.17 LargestObject watermark. var ( largest message.Location hasLargest bool @@ -38,7 +41,7 @@ func (h *sessionHandler) handleTrackStatus(ctx context.Context, req *session.Req largest, hasLargest = entry.GetLargest() } hasProperties := known && len(entry.GetProperties()) > 0 - if known && (hasProperties || hasLargest) { + if known && (hasProperties || hasLargest || hasEstablishedUpstream(entry)) { reply := &message.TrackStatusOK{} // §10.2.21: INCLUDE_PROPERTIES=0 empties the Track Properties only. if hasProperties && includeProperties(msg.Parameters) { diff --git a/pkg/relay/harness_test.go b/pkg/relay/harness_test.go index 32c36ec3..ea5fe2d9 100644 --- a/pkg/relay/harness_test.go +++ b/pkg/relay/harness_test.go @@ -12,6 +12,7 @@ import ( "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" @@ -39,9 +40,172 @@ type pipeListener struct { // which reaches the relay's "write failed" branches. See [faultConn]. faultFor func(conn int) sessiontest.FaultFunc + // resetsFor, when non-nil, is consulted like faultFor; a non-nil channel + // receives the resets the relay sends on that conn (see [streamReset]), + // whose codes [sessiontest] otherwise drops. See [resetsOn]. + resetsFor func(conn int) chan<- streamReset + + // wrap, when non-nil, wraps each server-side conn, e.g. to observe the + // code the relay closes it with. + wrap func(session.Conn) session.Conn + dialled atomic.Int64 } +// streamReset is a reset the relay sent on a stream, with its §3.3.4 code. +type streamReset struct { + stream resetStream + code moqt.StreamResetCode +} + +// resetStream is the stream a [streamReset] was on. +type resetStream int + +const ( + // fetchStreamReset: a RESET_STREAM on a fetch stream the relay opened. + fetchStreamReset resetStream = iota + // requestStreamReset: a RESET_STREAM on a request stream the peer opened. + requestStreamReset + // fetchStreamStop: a STOP_SENDING on a fetch stream the peer opened. + fetchStreamStop +) + +func (k resetStream) String() string { + switch k { + case fetchStreamReset: + return "fetch stream reset" + case requestStreamReset: + return "request stream reset" + case fetchStreamStop: + return "fetch stream STOP_SENDING" + } + return fmt.Sprintf("resetStream(%d)", int(k)) +} + +// resetsOn builds a [pipeListener.resetsFor] recording the resets on the nth +// dialled conn only, counting from 1 like [faultConn]. +func resetsOn(n int, ch chan<- streamReset) func(int) chan<- streamReset { + return func(conn int) chan<- streamReset { + if conn == n { + return ch + } + return nil + } +} + +// resetRecordingConn reports the resets on the streams of the conn it wraps: +// the fetch streams the relay opens, and the request and fetch streams the +// peer opens. +type resetRecordingConn struct { + session.Conn + + resets chan<- streamReset +} + +func (c resetRecordingConn) OpenUniStream() (session.SendStream, error) { + s, err := c.Conn.OpenUniStream() + if err != nil { + return nil, err + } + w := &resetRecordingSend{SendStream: s, resets: c.resets} + w.typ.Store(-1) + return w, nil +} + +func (c resetRecordingConn) AcceptStream(ctx context.Context) (session.Stream, error) { + s, err := c.Conn.AcceptStream(ctx) + if err != nil { + return nil, err + } + return resetRecordingBidi{s, c.resets}, nil +} + +func (c resetRecordingConn) AcceptUniStream(ctx context.Context) (session.ReceiveStream, error) { + s, err := c.Conn.AcceptUniStream(ctx) + if err != nil { + return nil, err + } + r := &resetRecordingRecv{ReceiveStream: s, resets: c.resets} + r.typ.Store(-1) + return r, nil +} + +// streamType is a unidirectional stream's first byte, which is its one-byte +// stream type (§3.4), or -1 before any. +type streamType struct{ atomic.Int64 } + +func (t *streamType) see(p []byte) { + if len(p) > 0 { + t.CompareAndSwap(-1, int64(p[0])) + } +} + +func (t *streamType) isFetch() bool { + typ := t.Load() + return typ >= 0 && message.IsFetchHeaderType(uint64(typ)) +} + +// resetRecordingSend reports a RESET_STREAM on a fetch stream. +type resetRecordingSend struct { + session.SendStream + + resets chan<- streamReset + typ streamType +} + +func (s *resetRecordingSend) Write(p []byte) (int, error) { + s.typ.see(p) + return s.SendStream.Write(p) +} + +func (s *resetRecordingSend) CancelWrite(code uint64) { + if s.typ.isFetch() { + record(s.resets, streamReset{fetchStreamReset, moqt.StreamResetCode(code)}) + } + s.SendStream.CancelWrite(code) +} + +type resetRecordingBidi struct { + session.Stream + + resets chan<- streamReset +} + +func (s resetRecordingBidi) CancelWrite(code uint64) { + record(s.resets, streamReset{requestStreamReset, moqt.StreamResetCode(code)}) + s.Stream.CancelWrite(code) +} + +// resetRecordingRecv reports a STOP_SENDING on a fetch stream. +type resetRecordingRecv struct { + session.ReceiveStream + + resets chan<- streamReset + typ streamType +} + +func (s *resetRecordingRecv) Read(p []byte) (int, error) { + n, err := s.ReceiveStream.Read(p) + s.typ.see(p[:n]) + return n, err +} + +func (s *resetRecordingRecv) CancelRead(code uint64) { + if s.typ.isFetch() { + record(s.resets, streamReset{fetchStreamStop, moqt.StreamResetCode(code)}) + } + s.ReceiveStream.CancelRead(code) +} + +// record sends r without blocking the relay; a test reads the resets it +// expects well within the channel's buffer. +func record(ch chan<- streamReset, r streamReset) { + select { + case ch <- r: + default: + } +} + // faultConn builds a [pipeListener.faultFor] that faults only the nth dialled // conn, counting from 1: connectRelay's client is 1, each dialAnotherClient // the next. @@ -73,11 +237,20 @@ func (l *pipeListener) Dial() (session.Conn, error) { // to a SUBSCRIBE_TRACKS subscriber, the PUBLISH_SKIPPED (§10.21) trigger. func (l *pipeListener) DialWithLimits(clientBidi, serverBidi int) (session.Conn, error) { clientConn, serverConn := sessiontest.NewConnPairWithLimits(clientBidi, serverBidi) + n := int(l.dialled.Add(1)) if l.faultFor != nil { - if fault := l.faultFor(int(l.dialled.Add(1))); fault != nil { + if fault := l.faultFor(n); fault != nil { serverConn = sessiontest.Faulty(serverConn, fault) } } + if l.resetsFor != nil { + if ch := l.resetsFor(n); ch != nil { + serverConn = resetRecordingConn{serverConn, ch} + } + } + if l.wrap != nil { + serverConn = l.wrap(serverConn) + } select { case l.conns <- serverConn: return clientConn, nil diff --git a/pkg/relay/inbound_goaway_test.go b/pkg/relay/inbound_goaway_test.go index 6234e0ae..bdce6c07 100644 --- a/pkg/relay/inbound_goaway_test.go +++ b/pkg/relay/inbound_goaway_test.go @@ -45,7 +45,7 @@ func TestRelay_InboundGoawayLeavesSessionOpen(t *testing.T) { // TestRelay_NoUpstreamSubscribeToGoingAwayPublisher: a publisher that sent // GOAWAY gets no new SUBSCRIBE from the relay, so a subscriber to its -// namespace finds nothing to subscribe to. +// namespace is refused with GOING_AWAY. func TestRelay_NoUpstreamSubscribeToGoingAwayPublisher(t *testing.T) { t.Parallel() pubSess, teardown := connectRelay(t, relay.Config{}) @@ -77,9 +77,9 @@ func TestRelay_NoUpstreamSubscribeToGoingAwayPublisher(t *testing.T) { t.Fatalf("the relay sent %s to a publisher that had sent GOAWAY", r.First.Type()) default: } - if err == nil { - t.Fatal("Subscribe succeeded with the only publisher going away") - } + // GOING_AWAY: "The endpoint has received a GOAWAY and MAY reject new + // requests" (§10.6.2). + requireRejectedWithCode(t, err, moqt.RequestGoingAway) } // TestRelay_NoForwardedPublishToGoingAwayHolder: a SUBSCRIBE_TRACKS holder that diff --git a/pkg/relay/internal/registry/arrivals.go b/pkg/relay/internal/registry/arrivals.go new file mode 100644 index 00000000..c502bbba --- /dev/null +++ b/pkg/relay/internal/registry/arrivals.go @@ -0,0 +1,57 @@ +package registry + +import ( + "sync" + + "github.com/floatdrop/moq-go/pkg/moqt/wire" +) + +// arrivals wakes the waiters on a key once a publisher arrives for it. It is +// guarded by the owning registry's mutex. +type arrivals[K comparable] map[K]*arrival + +// arrival is the waiters on one key: ch closes when a publisher arrives. +type arrival struct { + ch chan struct{} + ns wire.TrackNamespace // for [arrivals.notifyCoveredLocked] + waiters int +} + +// waitLocked registers a waiter on k and returns its channel and the stop +// that unregisters it under mu, the owning registry's mutex. +func (a *arrivals[K]) waitLocked(k K, ns wire.TrackNamespace, mu sync.Locker) (<-chan struct{}, func()) { + if *a == nil { + *a = make(arrivals[K]) + } + w := (*a)[k] + if w == nil { + w = &arrival{ch: make(chan struct{}), ns: ns} + (*a)[k] = w + } + w.waiters++ + return w.ch, func() { + mu.Lock() + defer mu.Unlock() + if w.waiters--; w.waiters == 0 && (*a)[k] == w { + delete(*a, k) + } + } +} + +// notifyLocked wakes the waiters on k. +func (a arrivals[K]) notifyLocked(k K) { + if w := a[k]; w != nil { + close(w.ch) + delete(a, k) + } +} + +// notifyCoveredLocked wakes the waiters whose namespace ns is a prefix of. +func (a arrivals[K]) notifyCoveredLocked(ns wire.TrackNamespace) { + for k, w := range a { + if w.ns.HasPrefix(ns) { + close(w.ch) + delete(a, k) + } + } +} diff --git a/pkg/relay/internal/registry/arrivals_test.go b/pkg/relay/internal/registry/arrivals_test.go new file mode 100644 index 00000000..ef8dfd70 --- /dev/null +++ b/pkg/relay/internal/registry/arrivals_test.go @@ -0,0 +1,71 @@ +package registry_test + +import ( + "testing" + + "github.com/floatdrop/moq-go/pkg/relay/discovery" + "github.com/floatdrop/moq-go/pkg/relay/internal/registry" +) + +// closed reports whether ch is closed. +func closed(ch <-chan struct{}) bool { + select { + case <-ch: + return true + default: + return false + } +} + +// TestNamespaceRegistry_AwaitPublisher: a wait for a publisher of +// video/cam ends once a covering namespace gains a source, local or remote, +// whichever way Discovery reports the remote one, and not for another +// namespace or a narrower one. +func TestNamespaceRegistry_AwaitPublisher(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + arrive func(r *registry.NamespaceRegistry) + wakes bool + }{ + {"local covering", func(r *registry.NamespaceRegistry) { r.RegisterPublisher(ns("video"), nil, nil) }, true}, + {"local exact", func(r *registry.NamespaceRegistry) { r.RegisterPublisher(ns("video", "cam"), nil, nil) }, true}, + {"local other", func(r *registry.NamespaceRegistry) { r.RegisterPublisher(ns("audio"), nil, nil) }, false}, + {"local narrower", func(r *registry.NamespaceRegistry) { + r.RegisterPublisher(ns("video", "cam", "hd"), nil, nil) + }, false}, + {"remote event", func(r *registry.NamespaceRegistry) { r.RemoteNamespace(ns("video"), "relay-B", true) }, true}, + {"remote snapshot", func(r *registry.NamespaceRegistry) { + r.ReplaceRemote([]discovery.NamespaceInfo{{Prefix: ns("video"), RelayAddr: "relay-B"}}) + }, true}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + r := registry.NewNamespaceRegistry() + arrived, stop := r.AwaitPublisher(ns("video", "cam")) + defer stop() + tc.arrive(r) + if got := closed(arrived); got != tc.wakes { + t.Fatalf("woken = %v, want %v", got, tc.wakes) + } + }) + } +} + +// TestTrackRegistry_AwaitUpstream: a wait for a track's upstream ends once one +// is added for that track, and not for another. +func TestTrackRegistry_AwaitUpstream(t *testing.T) { + t.Parallel() + r := registry.NewTrackRegistry() + cam := newTestTrackName("cam") + arrived, stop := r.AwaitUpstream(cam.Key()) + defer stop() + r.AddUpstream(newTestTrackName("mic"), ®istry.UpstreamSub{ID: 1}) + if closed(arrived) { + t.Fatal("woken by another track's upstream") + } + r.AddUpstream(cam, ®istry.UpstreamSub{ID: 2}) + if !closed(arrived) { + t.Fatal("not woken by the track's upstream") + } +} diff --git a/pkg/relay/internal/registry/namespace.go b/pkg/relay/internal/registry/namespace.go index 8123de1f..89e47c68 100644 --- a/pkg/relay/internal/registry/namespace.go +++ b/pkg/relay/internal/registry/namespace.go @@ -100,6 +100,10 @@ type SubscriberEntry struct { fwdMu sync.Mutex forwarding map[track.Key]struct{} fwdClosed bool // the entry is unregistered: no more forwards + // skipped holds, for each track sent a PUBLISH_SKIPPED, the + // [TrackEntry.UpstreamEpoch] it was sent at; see + // [SubscriberEntry.NoteSkipped]. + skipped map[track.Key]uint64 // announced counts the sources of each announced namespace, by wire key // (see namespace_state.go). Guarded by the owning registry's mu. @@ -122,13 +126,19 @@ type SubscriberEntry struct { // ClaimForward reserves key while a forwarded PUBLISH for it is being opened // and registered, and reports whether it was free. It reports false once the -// entry is unregistered (§6.1: "MUST NOT send any further PUBLISH messages"). -func (e *SubscriberEntry) ClaimForward(key track.Key) bool { +// entry is unregistered (§6.1: "MUST NOT send any further PUBLISH messages"), +// and while the track was skipped at epoch, the [TrackEntry.UpstreamEpoch] +// the caller read, or a later one (see [SubscriberEntry.NoteSkipped]): a +// forward decided on an older view of the track is covered by the skip. +func (e *SubscriberEntry) ClaimForward(key track.Key, epoch uint64) bool { e.fwdMu.Lock() defer e.fwdMu.Unlock() if _, busy := e.forwarding[key]; busy || e.fwdClosed { return false } + if at, ok := e.skipped[key]; ok && at >= epoch { + return false + } if e.forwarding == nil { e.forwarding = make(map[track.Key]struct{}) } @@ -136,6 +146,20 @@ func (e *SubscriberEntry) ClaimForward(key track.Key) bool { return true } +// NoteSkipped records that a PUBLISH_SKIPPED was sent for key at upstream +// epoch: the relay "MUST NOT send a PUBLISH for a Track for a given +// SUBSCRIBE_TRACKS after PUBLISH_SKIPPED has been sent, scoped to a single +// PUBLISH" (§6.1), so the track is not offered again until a new upstream +// PUBLISH or SUBSCRIBE changes the epoch. Call it holding the key's claim. +func (e *SubscriberEntry) NoteSkipped(key track.Key, epoch uint64) { + e.fwdMu.Lock() + defer e.fwdMu.Unlock() + if e.skipped == nil { + e.skipped = make(map[track.Key]uint64) + } + e.skipped[key] = epoch +} + // ReleaseForward frees a key [SubscriberEntry.ClaimForward] reserved. func (e *SubscriberEntry) ReleaseForward(key track.Key) { e.fwdMu.Lock() @@ -208,6 +232,9 @@ type NamespaceRegistry struct { // seq is the Seq of the last registered publisher. Guarded by mu. seq uint64 + // arrivals are the waiters for a publisher of a namespace, by its wire + // key; see [NamespaceRegistry.AwaitPublisher]. Guarded by mu. + arrivals arrivals[string] // remote is the namespaces Discovery reports other relays advertise, by // wire key; see [NamespaceRegistry.RemoteNamespace]. Guarded by mu. @@ -280,10 +307,21 @@ func (r *NamespaceRegistry) RegisterPublisher( // [NamespaceRegistry.unpublishNamespaceFromDiscovery]. r.publishNamespaceToDiscovery(ns) } + r.arrivals.notifyCoveredLocked(ns) r.mu.Unlock() return entry } +// AwaitPublisher returns a channel closed once a publisher of a namespace +// covering ns next registers, or a remote relay newly advertises one, for a +// SUBSCRIBE held until its track has a publisher (§10.2.6), and the stop that +// ends the wait. +func (r *NamespaceRegistry) AwaitPublisher(ns wire.TrackNamespace) (arrived <-chan struct{}, stop func()) { + r.mu.Lock() + defer r.mu.Unlock() + return r.arrivals.waitLocked(namespaceWireKey(ns), ns, &r.mu) +} + // AnnouncePublisher makes entry a source of its namespace for // SUBSCRIBE_NAMESPACE subscribers, announcing the namespace to those that had // no source for it. Call it only once the publisher has its REQUEST_OK; diff --git a/pkg/relay/internal/registry/namespace_state.go b/pkg/relay/internal/registry/namespace_state.go index 59be5b66..d929dc45 100644 --- a/pkg/relay/internal/registry/namespace_state.go +++ b/pkg/relay/internal/registry/namespace_state.go @@ -98,6 +98,7 @@ func (r *NamespaceRegistry) RemoteNamespace(ns wire.TrackNamespace, relayAddr st } rn.relays[relayAddr] = struct{}{} r.addSourceLocked(ns) + r.arrivals.notifyCoveredLocked(ns) return } if rn == nil { @@ -321,6 +322,7 @@ func (r *NamespaceRegistry) ReplaceRemote(ads []discovery.NamespaceInfo) { if _, have := rn.relays[addr]; !have { rn.relays[addr] = struct{}{} r.addSourceLocked(rn.ns) + r.arrivals.notifyCoveredLocked(rn.ns) } } } diff --git a/pkg/relay/internal/registry/track_entry.go b/pkg/relay/internal/registry/track_entry.go index 9946e9ad..e8ae0aa7 100644 --- a/pkg/relay/internal/registry/track_entry.go +++ b/pkg/relay/internal/registry/track_entry.go @@ -1,11 +1,13 @@ package registry import ( + "context" "fmt" "maps" "math" "slices" "sync" + "sync/atomic" "time" "github.com/floatdrop/moq-go/pkg/moqt/message" @@ -86,6 +88,10 @@ type TrackEntry struct { // See the type-level comment above for why this is a slice. Upstream []*UpstreamSub + // upstreamEpoch identifies the latest upstream added; see + // [TrackEntry.UpstreamEpoch]. Guarded by mu. + upstreamEpoch uint64 + // Downstream is the set of subscriber subscriptions to fan out to. Downstream []*DownstreamSub @@ -94,6 +100,10 @@ type TrackEntry struct { // asked again (zero: never); see [TrackEntry.NoteRefusal]. Guarded by mu. refusals map[*PublisherEntry]time.Time + // fetches cancel the fetch streams the relay serves on this track, fill + // fetch streams included; see [TrackEntry.AddFetch]. Guarded by mu. + fetches map[*fetchCancel]struct{} + // downstreamGen counts appends to Downstream. The per-object fanout // (UpdateLargestAndDetectNew) snapshots it alongside its initial // CopyDownstream and skips the O(len(Downstream)) joiner scan on every @@ -507,6 +517,16 @@ func (e *TrackEntry) RecordDuplicate(o ObjectInfo) error { return nil } +// TrackEnd reports where the Track ends, if an END_OF_TRACK Object said so. +// §2.4.2: "The final Object in a Track is the Object with Status END_OF_TRACK +// or the last Object sent in a FETCH whose response indicated End of Track"; +// the relay does not record the FETCH half. +func (e *TrackEntry) TrackEnd() (message.Location, bool) { + e.deliveredMu.Lock() + defer e.deliveredMu.Unlock() + return e.trackEnd, e.hasTrackEnd +} + // LowestForwarded reports the lowest Object ID forwarded in Subgroup // (group, subgroup), if any, within the window. The writers of a Subgroup // forget it when its last contributor leaves; this keeps it for a later @@ -950,6 +970,38 @@ func (e *TrackEntry) GetProperties() []byte { return e.Properties } +// fetchCancel is one [TrackEntry.AddFetch] registration. +type fetchCancel struct{ cancel context.CancelCauseFunc } + +// AddFetch registers cancel as how to end a fetch stream the relay serves on +// this track until remove is called; see [TrackEntry.CancelFetches]. +func (e *TrackEntry) AddFetch(cancel context.CancelCauseFunc) (remove func()) { + f := &fetchCancel{cancel} + e.mu.Lock() + if e.fetches == nil { + e.fetches = make(map[*fetchCancel]struct{}) + } + e.fetches[f] = struct{}{} + e.mu.Unlock() + return func() { + e.mu.Lock() + delete(e.fetches, f) + e.mu.Unlock() + } +} + +// CancelFetches cancels every fetch stream registered with +// [TrackEntry.AddFetch] with cause: §2.4.2, a relay that detects a malformed +// track MUST "reset any fetch streams". +func (e *TrackEntry) CancelFetches(cause error) { + e.mu.RLock() + fs := slices.Collect(maps.Keys(e.fetches)) + e.mu.RUnlock() + for _, f := range fs { + f.cancel(cause) + } +} + // CopyUpstream returns a snapshot of the current upstream slice. Callers // that want to iterate without holding the entry lock for the whole // iteration use this so they don't have to coordinate with mutators. @@ -969,6 +1021,19 @@ func (e *TrackEntry) HasUpstreamOn(sess *session.Session) bool { return slices.ContainsFunc(e.Upstream, func(u *UpstreamSub) bool { return u.Session == sess }) } +// upstreamEpochs numbers upstream additions process-wide, so epochs only grow +// and never repeat on an entry created again for the same track. +var upstreamEpochs atomic.Uint64 + +// UpstreamEpoch identifies the latest upstream PUBLISH or SUBSCRIBE added to +// the track (see [TrackRegistry.AddUpstream]); it is never 0 once one was. +// A PUBLISH_SKIPPED holds until it grows (see [SubscriberEntry.ClaimForward]). +func (e *TrackEntry) UpstreamEpoch() uint64 { + e.mu.RLock() + defer e.mu.RUnlock() + return e.upstreamEpoch +} + // NoteRefusal records that pub refused a late-publisher SUBSCRIBE for this // track and may not be asked again before retryAt; a zero retryAt means not // while this entry and that registration both last. diff --git a/pkg/relay/internal/registry/track_registry.go b/pkg/relay/internal/registry/track_registry.go index 827074a3..b000e885 100644 --- a/pkg/relay/internal/registry/track_registry.go +++ b/pkg/relay/internal/registry/track_registry.go @@ -96,6 +96,12 @@ type TrackRegistry struct { // claims holds the upstream SUBSCRIBEs in flight, one per (session, // track); see [TrackRegistry.ClaimUpstream]. Guarded by mu. claims map[upstreamClaim]struct{} + // fetches counts the stitch FETCHes in flight, per (session, track); see + // [TrackRegistry.BeginFetch]. Guarded by mu. + fetches map[upstreamClaim]int + // arrivals are the waiters for a track's next upstream; see + // [TrackRegistry.AwaitUpstream]. Guarded by mu. + arrivals arrivals[track.Key] } type upstreamClaim struct { @@ -219,6 +225,32 @@ func (r *TrackRegistry) ClaimUpstream(sess *session.Session, key track.Key) (rel }, true } +// BeginFetch marks a stitch FETCH for key on sess as in flight until done; +// see [TrackRegistry.FetchPending]. +func (r *TrackRegistry) BeginFetch(sess *session.Session, key track.Key) (done func()) { + c := upstreamClaim{sess: sess, key: key} + r.mu.Lock() + defer r.mu.Unlock() + if r.fetches == nil { + r.fetches = make(map[upstreamClaim]int) + } + r.fetches[c]++ + return func() { + r.mu.Lock() + defer r.mu.Unlock() + if r.fetches[c]--; r.fetches[c] == 0 { + delete(r.fetches, c) + } + } +} + +// FetchPending reports whether a stitch FETCH for key on sess is in flight. +func (r *TrackRegistry) FetchPending(sess *session.Session, key track.Key) bool { + r.mu.RLock() + defer r.mu.RUnlock() + return r.fetches[upstreamClaim{sess: sess, key: key}] > 0 +} + // ReleaseIfUnsubscribed removes the on-demand upstream up from the entry for // fullName and tears it down if the entry has no downstream left. It covers // an upstream whose last downstream left during the SUBSCRIBE round trip, @@ -412,6 +444,7 @@ func (r *TrackRegistry) AddUpstream( defer entry.mu.Unlock() becameNonEmpty = len(entry.Upstream) == 0 entry.Upstream = append(entry.Upstream, sub) + entry.upstreamEpoch = upstreamEpochs.Add(1) if conf.setProperties && len(entry.Properties) == 0 { // Set Properties INSIDE the entry lock so the Discovery // publish below sees them. Skip if Properties were already @@ -423,9 +456,19 @@ func (r *TrackRegistry) AddUpstream( if becameNonEmpty { r.publishTrackToDiscovery(entry) } + r.arrivals.notifyLocked(fullName.Key()) return entry, becameNonEmpty } +// AwaitUpstream returns a channel closed once an upstream is next added for +// key, for a SUBSCRIBE held until its track has one (§10.2.6), and the stop +// that ends the wait. +func (r *TrackRegistry) AwaitUpstream(key track.Key) (arrived <-chan struct{}, stop func()) { + r.mu.Lock() + defer r.mu.Unlock() + return r.arrivals.waitLocked(key, nil, &r.mu) +} + // AddUpstreamOption tweaks an [TrackRegistry.AddUpstream] call. type AddUpstreamOption func(*addUpstreamConfig) diff --git a/pkg/relay/late_publisher_test.go b/pkg/relay/late_publisher_test.go index 4f1f15bc..9560cfa6 100644 --- a/pkg/relay/late_publisher_test.go +++ b/pkg/relay/late_publisher_test.go @@ -298,9 +298,11 @@ func TestRelay_PublishNamespaceDuringPendingSubscribe(t *testing.T) { awaitAcceptedSubscribe(t, lateSubs, "video/"+name) } -// TestRelay_LatePublishNamespaceSkipsItsOwnDownstream: a session receiving the -// track is not asked to publish it back. -func TestRelay_LatePublishNamespaceSkipsItsOwnDownstream(t *testing.T) { +// TestRelay_LatePublishNamespaceSubscribesItsOwnDownstream: a session receiving +// the track that then PUBLISH_NAMESPACEs its namespace is SUBSCRIBEd for it +// like any other covering publisher (§9.5); a self-subscription is "identical +// to subscriptions initiated by other endpoints" (§5.1). +func TestRelay_LatePublishNamespaceSubscribesItsOwnDownstream(t *testing.T) { t.Parallel() pubSess, _ := newCam1Publisher(t, nil) subSess := newCam1Subscriber(t, pubSess) @@ -311,7 +313,7 @@ func TestRelay_LatePublishNamespaceSkipsItsOwnDownstream(t *testing.T) { ); err != nil { t.Fatalf("PublishNamespace: %v", err) } - requireNoSubscribe(t, own) + awaitAcceptedSubscribe(t, own, "video/cam1") } // TestRelay_OverlappingPublishNamespacesSubscribeOnce: one session sends two diff --git a/pkg/relay/malformed_track_test.go b/pkg/relay/malformed_track_test.go index 8cd14b4a..27448860 100644 --- a/pkg/relay/malformed_track_test.go +++ b/pkg/relay/malformed_track_test.go @@ -4,6 +4,8 @@ import ( "errors" "fmt" "io" + "maps" + "slices" "sync" "sync/atomic" "testing" @@ -296,7 +298,7 @@ func TestRelay_MalformedObjectEndsTrackWithStreamsOpen(t *testing.T) { // TestRelay_PublishTrackPropertiesRejected: a PUBLISH with an unknown Mandatory // Track Property is UNSUPPORTED_EXTENSION (§2.5.1); one whose Track Properties -// do not parse is MALFORMED_TRACK. +// do not parse is INTERNAL_ERROR (MALFORMED_TRACK answers only a FETCH, §10.6.2). func TestRelay_PublishTrackPropertiesRejected(t *testing.T) { t.Parallel() for _, tc := range []struct { @@ -305,7 +307,7 @@ func TestRelay_PublishTrackPropertiesRejected(t *testing.T) { want moqt.RequestErrorCode }{ {"unknown mandatory", mandatoryProps(), moqt.RequestUnsupportedExtension}, - {"malformed", malformedProps, moqt.RequestMalformedTrack}, + {"malformed", malformedProps, moqt.RequestInternalError}, } { t.Run(tc.name, func(t *testing.T) { t.Parallel() @@ -351,7 +353,7 @@ func TestRelay_UpstreamSubscribeOKTrackPropertiesRejected(t *testing.T) { want moqt.RequestErrorCode }{ {"unknown mandatory", mandatoryProps(), moqt.RequestUnsupportedExtension}, - {"malformed", malformedProps, moqt.RequestMalformedTrack}, + {"malformed", malformedProps, moqt.RequestInternalError}, } { t.Run(tc.name, func(t *testing.T) { t.Parallel() @@ -419,37 +421,52 @@ func TestRelay_UpstreamMandatoryPropertyWinsOverOtherFailure(t *testing.T) { // FETCH_OK with an unknown Mandatory Track Property (§2.5.1), or Track // Properties that do not parse, resets the downstream fetch stream the relay // already answered; no Object reaches the subscriber, not even cached ones. +// The request stream is reset with the data stream's code (§3.3.3): +// INTERNAL_ERROR, or MALFORMED_TRACK for Properties that do not parse, which +// make the track malformed (§12.7, §2.4.2). func TestRelay_UpstreamFetchOKUnknownMandatoryPropertyResetsStream(t *testing.T) { t.Parallel() for _, tc := range []struct { name string props []byte + code moqt.StreamResetCode }{ - {"unknown Mandatory Track Property", mandatoryProps()}, - {"Track Properties that do not parse", malformedProps}, + {"unknown Mandatory Track Property", mandatoryProps(), moqt.StreamResetInternalError}, + {"Track Properties that do not parse", malformedProps, moqt.StreamResetMalformedTrack}, } { t.Run(tc.name, func(t *testing.T) { t.Parallel() - refusedFetchResetsStream(t, tc.props, nil) + refusedFetchResetsStream(t, tc.props, nil, tc.code, fetchStreamReset, requestStreamReset) }) } } // TestRelay_UpstreamFetchMalformedObjectResetsStream: an upstream FETCH -// response Object that makes the track malformed resets the downstream fetch -// stream (§2.4.2). +// response Object with a Mandatory Track Property as an Object Property makes +// the track malformed (§2.5.1, §2.4.2): the downstream FETCH's data stream and +// request stream are reset with MALFORMED_TRACK (§3.3.3). func TestRelay_UpstreamFetchMalformedObjectResetsStream(t *testing.T) { t.Parallel() - refusedFetchResetsStream(t, nil, mandatoryProps()) + refusedFetchResetsStream(t, nil, mandatoryProps(), + moqt.StreamResetMalformedTrack, fetchStreamReset, requestStreamReset) } // refusedFetchResetsStream requires a stitched FETCH to be reset when the // upstream answers it with FETCH_OK carrying upstreamProps and, if objProps is -// non-nil, one Object carrying objProps. The upstream misbehaves only once -// armed, so the FETCHes waiting for the live tail to be cached succeed. -func refusedFetchResetsStream(t *testing.T, upstreamProps, objProps []byte) { +// non-nil, one Object carrying objProps: the streams of kinds, each with code. +// The upstream misbehaves only once armed, so the FETCHes waiting for the live +// tail to be cached succeed. +func refusedFetchResetsStream( + t *testing.T, + upstreamProps, objProps []byte, + code moqt.StreamResetCode, + kinds ...resetStream, +) { var armed atomic.Bool - pubSess, teardown := connectRelay(t, relay.Config{}) + l := newPipeListener() + resets := make(chan streamReset, 64) + l.resetsFor = resetsOn(3, resets) // the upstream is 1, the live subscriber 2 + pubSess, teardown := connectRelayOn(t, relay.Config{}, l) defer teardown() video := ns("video") name := []byte("cam1") @@ -481,9 +498,13 @@ func refusedFetchResetsStream(t *testing.T, upstreamProps, objProps []byte) { } tailWritten() case *message.Fetch: + var props []byte + if armed.Load() { + props = upstreamProps + } _ = req.Reply(&message.FetchOK{ EndLocation: message.Location{Group: stitchLiveLo - 1}, - TrackProperties: upstreamProps, + TrackProperties: props, }) if objProps == nil || !armed.Load() { continue @@ -547,6 +568,7 @@ func refusedFetchResetsStream(t *testing.T, upstreamProps, objProps []byte) { case errors.Is(err, io.EOF): t.Fatal("the FETCH stream completed; want it reset over what the upstream sent") } + awaitResets(t, resets, code, kinds...) } // TestRelay_EndSignalsAgree: an END_OF_GROUP status at 2 and a FIN after Object @@ -591,3 +613,197 @@ func TestRelay_EndSignalsAgree(t *testing.T) { t.Fatal("Group 2 not forwarded: the track ended") } } + +// malformedPriorityStreams makes the track on alias malformed: two streams of +// Subgroup 0 of Group 1 with different Publisher Priorities (§2.4.2 item 1). +func malformedPriorityStreams(t *testing.T, pubSess *session.Session, alias uint64) { + t.Helper() + sendStreams(t, pubSess, alias, []testStream{ + {group: 1, priority: 1, objects: []uint64{0}, open: true}, + {group: 1, priority: 2, objects: []uint64{1}, open: true}, + }) +} + +// awaitResets waits up to 1s for a reset of each of kinds on the conn resets +// records, and requires each to carry code. A kind's first reset counts; the +// others are ignored. +func awaitResets(t *testing.T, resets <-chan streamReset, code moqt.StreamResetCode, kinds ...resetStream) { + t.Helper() + pending := map[resetStream]bool{} + for _, k := range kinds { + pending[k] = true + } + deadline := time.After(time.Second) + for len(pending) > 0 { + select { + case r := <-resets: + if !pending[r.stream] { + continue + } + if r.code != code { + t.Fatalf("%v with %#x, want %#x", r.stream, uint64(r.code), uint64(code)) + } + delete(pending, r.stream) + case <-deadline: + t.Fatalf("within 1s, no %v with %#x", slices.Collect(maps.Keys(pending)), uint64(code)) + } + } +} + +// TestRelay_MalformedTrackCancelsUpstreamFetch: a malformed live Object from +// the publisher an upstream FETCH is waiting on cancels that FETCH (§2.4.2: +// "MUST cancel any corresponding subscription or fetches for that Track from +// that publisher"), its data stream with MALFORMED_TRACK, and resets the +// downstream fetch stream it was filling with MALFORMED_TRACK, not after +// FILL_TIMEOUT. +func TestRelay_MalformedTrackCancelsUpstreamFetch(t *testing.T) { + t.Parallel() + l := newPipeListener() + upResets := make(chan streamReset, 16) + resets := make(chan streamReset, 16) + l.resetsFor = func(conn int) chan<- streamReset { + switch conn { + case 1: // the upstream + return upResets + case 3: // the fetcher; the live subscriber is 2 + return resets + } + return nil + } + upSess, teardown := connectRelayOn(t, relay.Config{}, l) + t.Cleanup(teardown) + if _, err := upSess.PublishNamespace(t.Context(), &message.PublishNamespace{Namespace: ns("video")}); err != nil { + t.Fatalf("PublishNamespace: %v", err) + } + fetched := make(chan *session.Request, 1) + go func() { + for { + req, err := upSess.AcceptRequest(t.Context()) + if err != nil { + return + } + switch m := req.First.(type) { + case *message.Subscribe: + if req.Reply(&message.SubscribeOK{TrackAlias: 42}) != nil { + return + } + // The live stream misses Object 1. + publishCam1Group(t, upSess, 42, true, cam1Object{0, 0, nil}, cam1Object{0, 2, nil}) + case *message.Fetch: + if req.Reply(&message.FetchOK{EndLocation: fetchOKEnd(m)}) != nil { + return + } + out, err := upSess.OpenFetchStream(message.FetchHeader{RequestID: m.RequestID}) + if err != nil { + return + } + // Nothing more: the stream stays open. + t.Cleanup(func() { out.Cancel(moqt.StreamResetCancelled) }) + fetched <- req + } + } + }() + live := dialAnotherClient(t, upSess) + subscribeCam1(t, live) + go drainAll(t.Context(), live) + fc := dialAnotherClient(t, upSess) + waitRelayLargest(t, fc, ns("video"), []byte("cam1"), 0, 2) + + fr, err := fc.Fetch(t.Context(), &message.Fetch{ + Namespace: ns("video"), Name: []byte("cam1"), + Parameters: message.Parameters{ + fetchRangeFilter(message.Location{}, message.Location{Group: 0, Object: 2}), + message.FillTimeoutParam(10 * time.Second), + }, + }) + if err != nil { + t.Fatalf("Fetch: %v", err) + } + defer fr.Close() + if _, err := fc.AcceptDataStream(t.Context()); err != nil { + t.Fatalf("AcceptDataStream: %v", err) + } + var upFetch *session.Request + select { + case upFetch = <-fetched: + case <-time.After(2 * time.Second): + t.Fatal("the relay did not FETCH the hole from the upstream") + } + + malformedPriorityStreams(t, upSess, 42) + select { + case <-upFetch.Stream.Context().Done(): + case <-time.After(time.Second): + t.Fatal("the relay kept its FETCH to the publisher of a malformed track") + } + awaitResets(t, upResets, moqt.StreamResetMalformedTrack, fetchStreamStop) + awaitResets(t, resets, moqt.StreamResetMalformedTrack, fetchStreamReset, requestStreamReset) +} + +// TestRelay_MalformedTrackResetsCachedFetch: a FETCH served from the cache is +// reset with MALFORMED_TRACK when the track is found malformed while its +// stream is written (§2.4.2: "reset any fetch streams with Status Code +// MALFORMED_TRACK"). +func TestRelay_MalformedTrackResetsCachedFetch(t *testing.T) { + t.Parallel() + l := newPipeListener() + resets := make(chan streamReset, 16) + l.resetsFor = resetsOn(3, resets) // the publisher is 1, the live subscriber 2 + pubSess, teardown := connectRelayOn(t, relay.Config{}, l) + t.Cleanup(teardown) + publishVideoTrack(t, pubSess, "cam1", 7) + live := dialAnotherClient(t, pubSess) + subscribeCam1(t, live) + go drainAll(t.Context(), live) + publishObjects(t, pubSess, 7, 0, 3) + fc := dialAnotherClient(t, pubSess) + waitRelayLargest(t, fc, ns("video"), []byte("cam1"), 0, 2) + + fr, err := fc.Fetch(t.Context(), &message.Fetch{ + Namespace: ns("video"), Name: []byte("cam1"), + Parameters: message.Parameters{fetchRangeFilter(message.Location{}, message.Location{Group: 0, Object: 2})}, + }) + if err != nil { + t.Fatalf("Fetch: %v", err) + } + defer fr.Close() + // Unread, the stream holds the relay's first Object write. + if _, err := fc.AcceptDataStream(t.Context()); err != nil { + t.Fatalf("AcceptDataStream: %v", err) + } + + malformedPriorityStreams(t, pubSess, 7) + awaitResets(t, resets, moqt.StreamResetMalformedTrack, fetchStreamReset, requestStreamReset) +} + +// TestRelay_MalformedTrackResetsFillStream: a fill fetch stream (§5.1.3) is a +// fetch stream too, reset with MALFORMED_TRACK when the track is found +// malformed while it is written (§2.4.2). +func TestRelay_MalformedTrackResetsFillStream(t *testing.T) { + t.Parallel() + l := newPipeListener() + resets := make(chan streamReset, 16) + l.resetsFor = resetsOn(3, resets) // the publisher is 1, the live subscriber 2 + pubSess, teardown := connectRelayOn(t, relay.Config{}, l) + t.Cleanup(teardown) + publishVideoTrack(t, pubSess, "cam1", 7) + live := dialAnotherClient(t, pubSess) + subscribeCam1(t, live) + go drainAll(t.Context(), live) + publishObjects(t, pubSess, 7, 0, 3) + subSess := dialAnotherClient(t, pubSess) + waitRelayLargest(t, subSess, ns("video"), []byte("cam1"), 0, 2) + + subscribeCam1(t, subSess, message.FillParametersParam(message.Parameters{message.UnfilteredFilter()})) + // Unread, the fill stream holds the relay's first Object write. + ds, err := subSess.AcceptDataStream(t.Context()) + if err != nil { + t.Fatalf("AcceptDataStream: %v", err) + } + if _, ok := ds.(*session.IncomingFetchStream); !ok { + t.Fatalf("AcceptDataStream = %T, want the fill fetch stream", ds) + } + + malformedPriorityStreams(t, pubSess, 7) + awaitResets(t, resets, moqt.StreamResetMalformedTrack, fetchStreamReset) +} diff --git a/pkg/relay/own_subscribe_forward_test.go b/pkg/relay/own_subscribe_forward_test.go new file mode 100644 index 00000000..16985790 --- /dev/null +++ b/pkg/relay/own_subscribe_forward_test.go @@ -0,0 +1,247 @@ +package relay_test + +import ( + "sync" + "testing" + "time" + + "github.com/floatdrop/moq-go/pkg/moqt" + "github.com/floatdrop/moq-go/pkg/moqt/message" + "github.com/floatdrop/moq-go/pkg/moqt/session" + "github.com/floatdrop/moq-go/pkg/moqt/track" + "github.com/floatdrop/moq-go/pkg/relay" +) + +// A SUBSCRIBE_TRACKS holder is not forwarded a PUBLISH for a track it +// SUBSCRIBEs to itself: it receives the track on that subscription. While the +// SUBSCRIBE is between establishing its upstream and registering its +// downstream, the track has an upstream but no downstream on the session, so +// every path that forwards must see the SUBSCRIBE in flight. +// +// The tests that hold a SUBSCRIBE there are not parallel: they install the +// process-wide hook. + +// parkOwnSubscribe holds the relay's SUBSCRIBE for name just before it +// registers its downstream, until release. parked is closed once it is held. +// Pass release to [ownSubscribeRelay], which runs it at cleanup ahead of the +// relay's teardown, since that joins the held handler. +func parkOwnSubscribe(t *testing.T, name string) (parked <-chan struct{}, release func()) { + t.Helper() + held := make(chan struct{}) + gate := make(chan struct{}) + var holdOnce sync.Once + restore := relay.SetTestHookBeforeDownstreamRegistered(func(n track.FullTrackName) { + if string(n.Name) == name { + holdOnce.Do(func() { close(held) }) + <-gate + } + }) + t.Cleanup(restore) + return held, sync.OnceFunc(func() { close(gate) }) +} + +// ownSubscribeRelay starts a relay with a video PUBLISH_NAMESPACE publisher +// answering every SUBSCRIBE, and a client on it whose forwarded PUBLISHes +// arrive on reqs. release runs before the relay's teardown. +func ownSubscribeRelay( + t *testing.T, + release func(), +) (pubSess, subSess *session.Session, reqs <-chan *session.Request) { + t.Helper() + pubSess, teardown := connectRelay(t, relay.Config{}) + t.Cleanup(teardown) + t.Cleanup(release) + publishNS(t, pubSess, "video") + go func() { + for { + r, err := pubSess.AcceptRequest(t.Context()) + if err != nil { + return + } + if _, ok := r.First.(*message.Subscribe); ok { + _ = r.Reply(&message.SubscribeOK{TrackAlias: 7}) + } + } + }() + subSess = dialAnotherClient(t, pubSess) + return pubSess, subSess, forwardedPublishes(t, subSess) +} + +// subscribeHeld SUBSCRIBEs sess to video/name, waits until the relay holds it +// at parked, and returns the channel its result arrives on. +func subscribeHeld(t *testing.T, sess *session.Session, name string, parked <-chan struct{}) <-chan error { + t.Helper() + done := make(chan error, 1) + go func() { + sub, err := sess.Subscribe(t.Context(), &message.Subscribe{Namespace: ns("video"), Name: []byte(name)}) + if err == nil { + t.Cleanup(func() { _ = sub.Close() }) + } + done <- err + }() + select { + case <-parked: + case <-time.After(2 * time.Second): + t.Fatal("the SUBSCRIBE never reached downstream registration") + } + return done +} + +// releaseSubscribe lets the held SUBSCRIBE finish, requires it to succeed, +// and requires that its own registration forwards nothing either. +func releaseSubscribe(t *testing.T, release func(), done <-chan error, reqs <-chan *session.Request) { + t.Helper() + release() + select { + case err := <-done: + if err != nil { + t.Fatalf("Subscribe: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("SUBSCRIBE never answered") + } + requireNoForward(t, reqs, "the session's own SUBSCRIBE") +} + +// TestSubscribeTracks_OwnSubscribeInFlight_ExistingTracks: a SUBSCRIBE_TRACKS +// sent while the session's SUBSCRIBE is in flight does not forward that track +// among the existing ones (§10.20). +func TestSubscribeTracks_OwnSubscribeInFlight_ExistingTracks(t *testing.T) { + const name = "own-window-existing" + parked, release := parkOwnSubscribe(t, name) + _, subSess, reqs := ownSubscribeRelay(t, release) + + done := subscribeHeld(t, subSess, name, parked) + subscribeTracks(t, subSess, ns("video")) + requireNoForward(t, reqs, "a SUBSCRIBE_TRACKS while the session's SUBSCRIBE is in flight") + releaseSubscribe(t, release, done, reqs) +} + +// TestSubscribeTracks_OwnSubscribeInFlight_PrefixUpdate: the same for a +// TRACK_NAMESPACE_PREFIX update that newly covers the track (§10.9.2). +func TestSubscribeTracks_OwnSubscribeInFlight_PrefixUpdate(t *testing.T) { + const name = "own-window-update" + parked, release := parkOwnSubscribe(t, name) + _, subSess, reqs := ownSubscribeRelay(t, release) + stream := subscribeTracks(t, subSess, ns("audio")) + + done := subscribeHeld(t, subSess, name, parked) + if _, err := subSess.UpdateRequest(t.Context(), stream, + message.Parameters{message.TrackNamespacePrefixParam(ns("video"))}); err != nil { + t.Fatalf("REQUEST_UPDATE: %v", err) + } + requireNoForward(t, reqs, "a prefix update while the session's SUBSCRIBE is in flight") + releaseSubscribe(t, release, done, reqs) +} + +// TestSubscribeTracks_OwnSubscribeInFlight_NewPublish: the same for a PUBLISH +// of the track from another session (§6.1). +func TestSubscribeTracks_OwnSubscribeInFlight_NewPublish(t *testing.T) { + const name = "own-window-publish" + parked, release := parkOwnSubscribe(t, name) + pubSess, subSess, reqs := ownSubscribeRelay(t, release) + subscribeTracks(t, subSess, ns("video")) + + done := subscribeHeld(t, subSess, name, parked) + publishVideoTrack(t, dialAnotherClient(t, pubSess), name, 9) + requireNoForward(t, reqs, "a PUBLISH while the session's SUBSCRIBE is in flight") + releaseSubscribe(t, release, done, reqs) +} + +// TestSubscribeTracks_OwnSubscribeFails_HeldForwardSent: a forward held back +// for the session's SUBSCRIBE is sent once that SUBSCRIBE fails, since the +// track is published "within matching namespaces" (§10.20) and the session +// receives it no other way. +func TestSubscribeTracks_OwnSubscribeFails_HeldForwardSent(t *testing.T) { + t.Parallel() + const name = "own-subscribe-fails" + pubSess, teardown := connectRelay(t, relay.Config{}) + defer teardown() + publishNS(t, pubSess, "video") + arrived, gate := make(chan struct{}), make(chan struct{}) + refuse := sync.OnceFunc(func() { close(gate) }) + defer refuse() // before the teardown, on a failure too + go func() { + r, err := pubSess.AcceptRequest(t.Context()) + if err != nil { + return + } + close(arrived) + <-gate + _ = r.RejectError(moqt.RequestDoesNotExist, "no such track") + }() + subSess := dialAnotherClient(t, pubSess) + reqs := forwardedPublishes(t, subSess) + subscribeTracks(t, subSess, ns("video")) + + done := make(chan error, 1) + go func() { + _, err := subSess.Subscribe(t.Context(), &message.Subscribe{Namespace: ns("video"), Name: []byte(name)}) + done <- err + }() + select { + case <-arrived: + case <-time.After(2 * time.Second): + t.Fatal("the relay never SUBSCRIBEd upstream") + } + publishVideoTrack(t, dialAnotherClient(t, pubSess), name, 9) + requireNoForward(t, reqs, "a PUBLISH while the session's SUBSCRIBE is in flight") + refuse() + select { + case err := <-done: + if err == nil { + t.Fatal("the SUBSCRIBE succeeded, want it refused") + } + case <-time.After(2 * time.Second): + t.Fatal("SUBSCRIBE never answered") + } + if got := publishName(awaitForwarded(t, reqs)); got != name { + t.Fatalf("forwarded %q, want %q", got, name) + } +} + +// TestSubscribeTracks_ConcurrentForwardsSendOnePublish: of two forwards of a +// track to one SUBSCRIBE_TRACKS holder, the one that loses the race to the +// other's registered downstream sends nothing; the holder gets one PUBLISH +// for the track (relay policy, see forwardTrack). Not parallel: it installs +// the process-wide hook. +func TestSubscribeTracks_ConcurrentForwardsSendOnePublish(t *testing.T) { + const name = "concurrent-forwards" + held, gate := make(chan struct{}), make(chan struct{}) + var holdOnce sync.Once + restore := relay.SetTestHookBeforeForwardClaim(func(n track.FullTrackName) { + if string(n.Name) != name { + return + } + first := false + holdOnce.Do(func() { first = true }) + if first { + close(held) + <-gate + } + }) + t.Cleanup(restore) + release := sync.OnceFunc(func() { close(gate) }) + + pubSess, teardown := connectRelay(t, relay.Config{}) + defer teardown() + defer release() // before the teardown, which joins the held handler + publishVideoTrack(t, pubSess, name, 7) + subSess := dialAnotherClient(t, pubSess) + reqs := forwardedPublishes(t, subSess) + + // The existing-tracks forward holds before its claim... + subscribeTracks(t, subSess, ns("video")) + select { + case <-held: + case <-time.After(2 * time.Second): + t.Fatal("the existing-tracks forward never reached its claim") + } + // ...while a second publisher's forward claims, registers its downstream + // (before the relay reads PUBLISH_OK) and releases the claim. + publishVideoTrack(t, dialAnotherClient(t, pubSess), name, 9) + acceptForwarded(t, awaitForwarded(t, reqs)) + + release() + requireNoForward(t, reqs, "the held forward of a track already forwarded") +} diff --git a/pkg/relay/publish_skipped_test.go b/pkg/relay/publish_skipped_test.go index 3434c79a..5d1175f0 100644 --- a/pkg/relay/publish_skipped_test.go +++ b/pkg/relay/publish_skipped_test.go @@ -122,3 +122,48 @@ func TestPublishSkipped_NotStickyAcrossRePublish(t *testing.T) { t.Fatalf("re-PUBLISH: got %T, want a second *message.PublishSkipped (skip is not sticky)", got2) } } + +// TestPublishSkipped_StickyAcrossPrefixMoveAndBack: the relay "MUST NOT send a +// PUBLISH for a Track for a given SUBSCRIBE_TRACKS after PUBLISH_SKIPPED has +// been sent, scoped to a single PUBLISH" (§6.1). A TRACK_NAMESPACE_PREFIX +// update that moves the subscription away and back is not a new PUBLISH, so +// the skipped track is neither offered nor skipped again. +func TestPublishSkipped_StickyAcrossPrefixMoveAndBack(t *testing.T) { + t.Parallel() + primary, teardown := connectRelay(t, relay.Config{}) + defer teardown() + + // No relay-side bidi credit: every forward becomes a PUBLISH_SKIPPED. + subSess := dialAnotherClientWithLimits(t, primary, -1 /*client*/, 0 /*server*/) + reqs := forwardedPublishes(t, subSess) + subStream, err := subSess.SubscribeTracks(t.Context(), &message.SubscribeTracks{ + TrackNamespacePrefix: ns("video"), + }) + if err != nil { + t.Fatalf("SubscribeTracks: %v", err) + } + defer subStream.Close() + + pubStream, err := dialAnotherClient(t, primary).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() + got := relaytest.ReadNextMessage(t, subStream, time.After(2*time.Second)) + if _, ok := got.(*message.PublishSkipped); !ok { + t.Fatalf("got %T, want *message.PublishSkipped", got) + } + + for _, prefix := range []string{"audio", "video"} { + if _, err := subSess.UpdateRequest(t.Context(), subStream.Stream, + message.Parameters{message.TrackNamespacePrefixParam(ns(prefix))}); err != nil { + t.Fatalf("REQUEST_UPDATE prefix %s: %v", prefix, err) + } + } + requireQuiet(t, streamMessages(t, subStream), "the prefix moved back to the skipped track") + requireNoForward(t, reqs, "the prefix moved back to the skipped track") +} diff --git a/pkg/relay/relay.go b/pkg/relay/relay.go index ab204eba..04d40963 100644 --- a/pkg/relay/relay.go +++ b/pkg/relay/relay.go @@ -177,6 +177,16 @@ type Config struct { // EXCESSIVE_LOAD. Zero (the default) means unlimited. MaxNamespaceRequestsPerSession int + // MaxRendezvousTimeout caps how long the relay holds a SUBSCRIBE that + // carries RENDEZVOUS_TIMEOUT for a track with no publisher, waiting for + // one to appear (§10.2.6: "The relay MAY use a shorter timeout than + // requested by the subscriber"). What is left of the hold is forwarded + // on the relay's upstream SUBSCRIBEs to other relays, so the cap bounds + // theirs too. Zero means: use the default of 30s. A negative value holds + // none: the SUBSCRIBE is answered DOES_NOT_EXIST at once, as if + // RENDEZVOUS_TIMEOUT were 0. + MaxRendezvousTimeout time.Duration + // Discovery is the cross-instance track + namespace advertisement // fabric. nil means "no discovery" — the relay still works as a // single-instance setup with no cross-relay routing. Single-process @@ -235,8 +245,9 @@ type Config struct { // resolved Config defaults; kept as constants so tests can reference them // without poking at private fields. const ( - defaultSendQueueSize = 64 - defaultMaxFanoutLag = 2 * time.Second + defaultSendQueueSize = 64 + defaultMaxFanoutLag = 2 * time.Second + defaultMaxRendezvousTimeout = 30 * time.Second ) // DefaultMaxFilterRanges is the MAX_FILTER_RANGES (§10.3.1.6) budget a relay @@ -342,6 +353,9 @@ func New(listener Listener, cfg Config) *Relay { if cfg.MaxFanoutLag <= 0 { cfg.MaxFanoutLag = defaultMaxFanoutLag } + if cfg.MaxRendezvousTimeout == 0 { + cfg.MaxRendezvousTimeout = defaultMaxRendezvousTimeout + } // Prepended, so it is the SETUP budget unless the caller states one — and // stated twice it is advertised twice, which is why [Config.MaxFilterRanges] // is the way to change it rather than another WithMaxFilterRanges here. @@ -619,6 +633,7 @@ func (r *Relay) serveSession(ctx context.Context, sess *session.Session, leg Leg r.cfg.Discovery, r.cfg.RelayAddr, r.cfg.SendQueueSize, r.cfg.MaxDropsBeforeReset, r.cfg.MaxFanoutLag, r.cfg.MaxSubscriptionsPerSession, r.cfg.MaxNamespaceRequestsPerSession, + max(r.cfg.MaxRendezvousTimeout, 0), r.handlers.Go, ) if err := handler.run(ctx); err != nil { @@ -688,6 +703,7 @@ func (r *Relay) Stop(ctx context.Context) error { // everything immediately. A relay-to-relay deployment may // want to include a New Session URI here — extend // SessionOptions or Config when that arrives. + goawaySent := make(map[*session.Session]bool, len(sessions)) if r.cfg.GoawayTimeout > 0 { for _, sess := range sessions { if err := sess.SendGoaway(r.cfg.GoawayTimeout, ""); err != nil { @@ -695,7 +711,9 @@ func (r *Relay) Stop(ctx context.Context) error { // or is closed is fine to skip. r.log.LogAttrs(ctx, slog.LevelDebug, "relay GOAWAY send skipped", slog.String("err", err.Error())) + continue } + goawaySent[sess] = true } } @@ -709,9 +727,11 @@ func (r *Relay) Stop(ctx context.Context) error { close(drained) }() + timedOut := false select { case <-drained: case <-time.After(r.cfg.GoawayTimeout): + timedOut = true r.log.LogAttrs(ctx, slog.LevelWarn, "relay GOAWAY drain timed out, force-closing sessions") case <-ctx.Done(): r.log.LogAttrs(ctx, slog.LevelWarn, "relay Stop ctx cancelled, force-closing sessions") @@ -726,13 +746,14 @@ func (r *Relay) Stop(ctx context.Context) error { r.upstreams.close() } - // 7. Force-close anything still standing. We use - // SessionGoawayTimeout (§10.4 / IANA §15.11.1): the - // relay sent GOAWAY and the peer didn't drain within - // GoawayTimeout. Closing an already-closed session is a - // no-op via Session's internal closeOnce. + // 7. Force-close anything still standing, with GOAWAY_TIMEOUT + // only where it is true: "the peer took too long to close the + // session in response to a GOAWAY" (§3.5). A session sent no + // GOAWAY, or cut short by ctx, is closed with NO_ERROR. Closing + // an already-closed session is a no-op via Session's internal + // closeOnce. for _, sess := range sessions { - _ = sess.Close(moqt.SessionGoawayTimeout, "relay shutdown") + _ = sess.Close(shutdownCloseCode(goawaySent[sess] && timedOut), "relay shutdown") } // 8. Wait for all handler goroutines to exit. This is @@ -775,17 +796,29 @@ func (r *Relay) addSession(s *session.Session, leg Leg) { // drain for one session: GOAWAY, wait for the peer to drain or the grace period // to elapse, then force-close. Spawned by addSession only during shutdown. func (r *Relay) drainStraggler(s *session.Session) { + goawayExpired := false if r.cfg.GoawayTimeout > 0 { - _ = s.SendGoaway(r.cfg.GoawayTimeout, "") + sent := s.SendGoaway(r.cfg.GoawayTimeout, "") == nil timer := time.NewTimer(r.cfg.GoawayTimeout) defer timer.Stop() select { case <-timer.C: + goawayExpired = sent case <-s.Done(): return // peer drained within the grace period } } - _ = s.Close(moqt.SessionGoawayTimeout, "relay shutdown") + _ = s.Close(shutdownCloseCode(goawayExpired), "relay shutdown") +} + +// shutdownCloseCode is the code a shutdown force-closes a session with: +// GOAWAY_TIMEOUT when the relay sent it a GOAWAY and the grace period ran out +// (§3.5), NO_ERROR otherwise. +func shutdownCloseCode(goawayExpired bool) moqt.SessionErrorCode { + if goawayExpired { + return moqt.SessionGoawayTimeout + } + return moqt.SessionNoError } func (r *Relay) removeSession(s *session.Session, leg Leg) { diff --git a/pkg/relay/relay_upstream_pool.go b/pkg/relay/relay_upstream_pool.go index f247f717..5ef0c5d9 100644 --- a/pkg/relay/relay_upstream_pool.go +++ b/pkg/relay/relay_upstream_pool.go @@ -178,7 +178,7 @@ func (p *upstreamPool) resolveUpstreams(ctx context.Context, ns wire.TrackNamesp p.metrics.UpstreamDialFailed(info.RelayAddr) continue // fall through to the next-ranked relay } - if peerSentGoaway(sess) { + if goingAway(sess) { // §10.4: a draining relay takes no new requests, so it must not // hold a fan-in slot. continue diff --git a/pkg/relay/rendezvous_test.go b/pkg/relay/rendezvous_test.go new file mode 100644 index 00000000..7e652cd5 --- /dev/null +++ b/pkg/relay/rendezvous_test.go @@ -0,0 +1,438 @@ +package relay_test + +import ( + "context" + "errors" + "fmt" + "sync/atomic" + "testing" + "time" + + "github.com/floatdrop/moq-go/pkg/moqt" + "github.com/floatdrop/moq-go/pkg/moqt/message" + "github.com/floatdrop/moq-go/pkg/moqt/session" + "github.com/floatdrop/moq-go/pkg/relay" + "github.com/floatdrop/moq-go/pkg/relay/discovery" +) + +// RENDEZVOUS_TIMEOUT (§10.2.6): a SUBSCRIBE for a track with no publisher is +// held for one to appear, up to the requested duration. + +// subscribeRendezvous sends a SUBSCRIBE for video/cam1 with RENDEZVOUS_TIMEOUT d from +// sess under ctx, and delivers its outcome. +func subscribeRendezvous(ctx context.Context, sess *session.Session, d time.Duration) <-chan error { + done := make(chan error, 1) + go func() { + sub, err := sess.Subscribe(ctx, &message.Subscribe{ + Namespace: ns("video"), Name: []byte("cam1"), + Parameters: message.Parameters{message.RendezvousTimeoutParam(d)}, + }) + if err == nil { + defer sub.Close() + } + done <- err + }() + return done +} + +// requireHeld fails if the SUBSCRIBE is answered within 200ms. +func requireHeld(t *testing.T, done <-chan error) { + t.Helper() + select { + case err := <-done: + t.Fatalf("SUBSCRIBE answered while no publisher is available: %v", err) + case <-time.After(200 * time.Millisecond): + } +} + +// awaitAnswer returns the SUBSCRIBE's outcome, failing after 2s. +func awaitAnswer(t *testing.T, done <-chan error) error { + t.Helper() + select { + case err := <-done: + return err + case <-time.After(2 * time.Second): + t.Fatal("held SUBSCRIBE never answered") + return nil + } +} + +// TestRendezvous_PublisherArrives: a held SUBSCRIBE "proceeds with the +// subscription normally" once a publisher appears, whether it PUBLISH_NAMESPACEs +// a covering namespace or PUBLISHes the track itself. +func TestRendezvous_PublisherArrives(t *testing.T) { + t.Parallel() + t.Run("PUBLISH_NAMESPACE", func(t *testing.T) { + t.Parallel() + subSess, teardown := connectRelay(t, relay.Config{}) + t.Cleanup(teardown) + done := subscribeRendezvous(t.Context(), subSess, 5*time.Second) + requireHeld(t, done) + + _, subs := publishNamespaceLate(t, subSess, ns("video")) + awaitAcceptedSubscribe(t, subs, "video/cam1") + if err := awaitAnswer(t, done); err != nil { + t.Fatalf("held SUBSCRIBE: %v", err) + } + }) + t.Run("PUBLISH", func(t *testing.T) { + t.Parallel() + subSess, teardown := connectRelay(t, relay.Config{}) + t.Cleanup(teardown) + done := subscribeRendezvous(t.Context(), subSess, 5*time.Second) + requireHeld(t, done) + + publishVideoTrack(t, dialAnotherClient(t, subSess), "cam1", 7) + if err := awaitAnswer(t, done); err != nil { + t.Fatalf("held SUBSCRIBE: %v", err) + } + }) +} + +// TestRendezvous_KeepsWaitingPastDoesNotExist: a namespace publisher that +// answers DOES_NOT_EXIST leaves the track without a publisher, so the +// SUBSCRIBE stays held, and is not asked again when another publisher of the +// namespace arrives and serves it. +func TestRendezvous_KeepsWaitingPastDoesNotExist(t *testing.T) { + t.Parallel() + subSess, teardown := connectRelay(t, relay.Config{}) + t.Cleanup(teardown) + refuser := dialAnotherClient(t, subSess) + publishNS(t, refuser, "video") + var ( + refused atomic.Int32 + forwarded atomic.Bool + ) + go func() { + for { + req, err := refuser.AcceptRequest(t.Context()) + if err != nil { + return + } + refused.Add(1) + // The budget goes only to upstream relays; see + // TestCrossRelay_RendezvousForwardsBudget. + if sub, ok := req.First.(*message.Subscribe); ok { + if _, has := sub.Parameters.Find(message.ParamRendezvousTimeout); has { + forwarded.Store(true) + } + } + _ = req.RejectError(moqt.RequestDoesNotExist, "not here") + } + }() + + done := subscribeRendezvous(t.Context(), subSess, 5*time.Second) + requireHeld(t, done) + if n := refused.Load(); n != 1 { + t.Fatalf("the refusing publisher got %d SUBSCRIBEs, want 1", n) + } + + _, subs := publishNamespaceLate(t, subSess, ns("video")) + awaitAcceptedSubscribe(t, subs, "video/cam1") + if err := awaitAnswer(t, done); err != nil { + t.Fatalf("held SUBSCRIBE: %v", err) + } + if n := refused.Load(); n != 1 { + t.Fatalf("the refusing publisher got %d SUBSCRIBEs, want 1", n) + } + if forwarded.Load() { + t.Fatal("a local publisher's SUBSCRIBE carried RENDEZVOUS_TIMEOUT") + } +} + +// TestRendezvous_OtherErrorEndsHold: a candidate failing with anything but +// DOES_NOT_EXIST ends the hold with its error, whichever order the candidates +// answer in, since the track may well have a publisher. +func TestRendezvous_OtherErrorEndsHold(t *testing.T) { + t.Parallel() + for _, codes := range [][2]moqt.RequestErrorCode{ + {moqt.RequestDoesNotExist, moqt.RequestInternalError}, + {moqt.RequestInternalError, moqt.RequestDoesNotExist}, + } { + t.Run(fmt.Sprintf("%#x then %#x", uint64(codes[0]), uint64(codes[1])), func(t *testing.T) { + t.Parallel() + subSess, teardown := connectRelay(t, relay.Config{}) + t.Cleanup(teardown) + for _, code := range codes { + pub := dialAnotherClient(t, subSess) + publishNS(t, pub, "video") + go func() { + for { + req, err := pub.AcceptRequest(t.Context()) + if err != nil { + return + } + _ = req.RejectError(code, "refused") + } + }() + } + done := subscribeRendezvous(t.Context(), subSess, 5*time.Second) + select { + case err := <-done: + requireRejectedWithCode(t, err, moqt.RequestInternalError) + case <-time.After(time.Second): + t.Fatal("SUBSCRIBE held, want INTERNAL_ERROR at once") + } + }) + } +} + +// TestRendezvous_DrainingPublisherKeepsHold: a namespace's only publisher +// having sent GOAWAY leaves the track without a current publisher (§10.4: the +// relay initiates no request to it), so the SUBSCRIBE is held until another +// arrives, rather than refused with GOING_AWAY. +func TestRendezvous_DrainingPublisherKeepsHold(t *testing.T) { + t.Parallel() + subSess, teardown := connectRelay(t, relay.Config{}) + t.Cleanup(teardown) + draining := dialAnotherClient(t, subSess) + publishNS(t, draining, "video") + go func() { + for { + req, err := draining.AcceptRequest(t.Context()) + if err != nil { + return + } + _ = req.RejectError(moqt.RequestDoesNotExist, "not yet draining") + } + }() + if err := draining.SendGoaway(0, ""); err != nil { + t.Fatalf("SendGoaway: %v", err) + } + // The relay has read the GOAWAY once a SUBSCRIBE without a hold is + // refused with GOING_AWAY rather than the publisher's DOES_NOT_EXIST. + waitFor(t, 2*time.Second, func() bool { + _, err := subSess.Subscribe(t.Context(), &message.Subscribe{Namespace: ns("video"), Name: []byte("cam1")}) + rej, ok := errors.AsType[*session.RequestRejectedError](err) + return ok && rej.Code == moqt.RequestGoingAway + }, "the relay never read the publisher's GOAWAY") + + done := subscribeRendezvous(t.Context(), subSess, 5*time.Second) + requireHeld(t, done) + publishVideoTrack(t, dialAnotherClient(t, subSess), "cam1", 7) + if err := awaitAnswer(t, done); err != nil { + t.Fatalf("held SUBSCRIBE: %v", err) + } +} + +// TestRendezvous_Expires: with no publisher by the deadline the relay answers +// REQUEST_ERROR TIMEOUT (§10.2.6), at the requested duration or at +// [relay.Config.MaxRendezvousTimeout] if shorter ("The relay MAY use a shorter +// timeout than requested"). +func TestRendezvous_Expires(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + cfg relay.Config + requested time.Duration + }{ + {"requested", relay.Config{}, 300 * time.Millisecond}, + {"capped", relay.Config{MaxRendezvousTimeout: 300 * time.Millisecond}, time.Hour}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + subSess, teardown := connectRelay(t, tc.cfg) + t.Cleanup(teardown) + start := time.Now() + done := subscribeRendezvous(t.Context(), subSess, tc.requested) + requireHeld(t, done) + requireRejectedWithCode(t, awaitAnswer(t, done), moqt.RequestTimeout) + if held := time.Since(start); held < 300*time.Millisecond { + t.Fatalf("answered after %v, want the 300ms hold", held) + } + }) + } +} + +// TestRendezvous_NoHold: RENDEZVOUS_TIMEOUT 0 "MUST immediately return +// REQUEST_ERROR with error code DOES_NOT_EXIST" (§10.2.6), as does any +// SUBSCRIBE on a relay configured to hold none. +func TestRendezvous_NoHold(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + cfg relay.Config + requested time.Duration + }{ + {"zero", relay.Config{}, 0}, + {"relay holds none", relay.Config{MaxRendezvousTimeout: -1}, 5 * time.Second}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + subSess, teardown := connectRelay(t, tc.cfg) + t.Cleanup(teardown) + done := subscribeRendezvous(t.Context(), subSess, tc.requested) + select { + case err := <-done: + requireRejectedWithCode(t, err, moqt.RequestDoesNotExist) + case <-time.After(200 * time.Millisecond): + t.Fatal("SUBSCRIBE held, want DOES_NOT_EXIST at once") + } + }) + } +} + +// TestRendezvous_CancelEndsHold: a subscriber cancelling its held SUBSCRIBE +// (§3.3.3) ends the hold, so a publisher arriving afterwards is not +// SUBSCRIBEd for it. +func TestRendezvous_CancelEndsHold(t *testing.T) { + t.Parallel() + subSess, teardown := connectRelay(t, relay.Config{}) + t.Cleanup(teardown) + ctx, cancel := context.WithCancel(t.Context()) + done := subscribeRendezvous(ctx, subSess, 5*time.Second) + requireHeld(t, done) + cancel() + <-done + time.Sleep(100 * time.Millisecond) // the relay reads the STOP_SENDING + + _, subs := publishNamespaceLate(t, subSess, ns("video")) + requireNoSubscribe(t, subs) +} + +// TestCrossRelay_RendezvousForwardsBudget: relay A forwards what is left of +// the hold on its upstream SUBSCRIBE, so relay B, whose namespace publisher +// has no such track yet, holds it too, and a PUBLISH on B then serves the +// subscriber on A. Without it B answers DOES_NOT_EXIST at once and A, having +// asked B, times out. +func TestCrossRelay_RendezvousForwardsBudget(t *testing.T) { + t.Parallel() + store := discovery.NewMemoryStore() + defer store.Close() + relayA, relayB := startRelayPair(t.Context(), store) + // Stopped before t.Context ends, which would end the sessions under Stop. + defer func() { relayA.stop(t); relayB.stop(t) }() + + pub := dialClient(t, relayB) + publishNS(t, pub, "video") + var refused atomic.Int32 + go func() { + for { + req, err := pub.AcceptRequest(t.Context()) + if err != nil { + return + } + refused.Add(1) + _ = req.RejectError(moqt.RequestDoesNotExist, "no cam1 yet") + } + }() + + done := subscribeRendezvous(t.Context(), dialClient(t, relayA), 5*time.Second) + requireHeld(t, done) + if refused.Load() == 0 { + t.Fatal("relay B never asked its namespace publisher") + } + + publishVideoTrack(t, pub, "cam1", 7) + if err := awaitAnswer(t, done); err != nil { + t.Fatalf("held SUBSCRIBE: %v", err) + } +} + +// TestCrossRelay_RendezvousRemoteNamespaceArrives: a namespace another relay +// comes to advertise through Discovery after the SUBSCRIBE arrived ends the +// hold, and the SUBSCRIBE goes there. +func TestCrossRelay_RendezvousRemoteNamespaceArrives(t *testing.T) { + t.Parallel() + store := discovery.NewMemoryStore() + defer store.Close() + relayA, relayB := startRelayPair(t.Context(), store) + // Stopped before t.Context ends, which would end the sessions under Stop. + defer func() { relayA.stop(t); relayB.stop(t) }() + + done := subscribeRendezvous(t.Context(), dialClient(t, relayA), 5*time.Second) + requireHeld(t, done) + + pub := dialClient(t, relayB) + subs := acceptSubscribes(t, pub) + publishNS(t, pub, "video") + awaitAcceptedSubscribe(t, subs, "video/cam1") + if err := awaitAnswer(t, done); err != nil { + t.Fatalf("held SUBSCRIBE: %v", err) + } +} + +// TestCrossRelay_RendezvousArrivalCutsRemoteHoldShort: a publisher arriving +// on relay A while A's upstream SUBSCRIBE is held on relay B serves the +// subscriber at once, rather than once B's hold runs out ("If a publisher +// becomes available within this time, the relay proceeds with the +// subscription normally", §10.2.6). +func TestCrossRelay_RendezvousArrivalCutsRemoteHoldShort(t *testing.T) { + t.Parallel() + store := discovery.NewMemoryStore() + defer store.Close() + relayA, relayB := startRelayPair(t.Context(), store) + // Stopped before t.Context ends, which would end the sessions under Stop. + defer func() { relayA.stop(t); relayB.stop(t) }() + + refuser := dialClient(t, relayB) + publishNS(t, refuser, "video") + var refused atomic.Int32 + go func() { + for { + req, err := refuser.AcceptRequest(t.Context()) + if err != nil { + return + } + refused.Add(1) + _ = req.RejectError(moqt.RequestDoesNotExist, "no cam1") + } + }() + + subSess := dialClient(t, relayA) + done := subscribeRendezvous(t.Context(), subSess, 5*time.Second) + requireHeld(t, done) + if refused.Load() == 0 { + t.Fatal("relay B never asked its namespace publisher") + } + + publishVideoTrack(t, dialClient(t, relayA), "cam1", 7) + if err := awaitAnswer(t, done); err != nil { + t.Fatalf("held SUBSCRIBE: %v", err) + } +} + +// TestCrossRelay_RendezvousReasksRemoteAfterCutShort: a publisher arriving on +// relay A that turns out not to have the track cuts A's upstream SUBSCRIBE +// held on relay B short, and A asks B again with what is left of the hold, so +// a PUBLISH on B afterwards still serves the subscriber. +func TestCrossRelay_RendezvousReasksRemoteAfterCutShort(t *testing.T) { + t.Parallel() + store := discovery.NewMemoryStore() + defer store.Close() + relayA, relayB := startRelayPair(t.Context(), store) + // Stopped before t.Context ends, which would end the sessions under Stop. + defer func() { relayA.stop(t); relayB.stop(t) }() + + refuse := func(sess *session.Session) *atomic.Int32 { + var n atomic.Int32 + go func() { + for { + req, err := sess.AcceptRequest(t.Context()) + if err != nil { + return + } + n.Add(1) + _ = req.RejectError(moqt.RequestDoesNotExist, "no cam1") + } + }() + return &n + } + pubB := dialClient(t, relayB) + publishNS(t, pubB, "video") + refusedB := refuse(pubB) + + done := subscribeRendezvous(t.Context(), dialClient(t, relayA), 5*time.Second) + requireHeld(t, done) + + pubA := dialClient(t, relayA) + refusedA := refuse(pubA) + publishNS(t, pubA, "video") + waitFor(t, 2*time.Second, func() bool { return refusedA.Load() == 1 && refusedB.Load() >= 2 }, + "relay B's publisher was not asked again after the local publisher refused") + + publishVideoTrack(t, pubB, "cam1", 7) + if err := awaitAnswer(t, done); err != nil { + t.Fatalf("held SUBSCRIBE: %v", err) + } +} diff --git a/pkg/relay/self_subscribe_test.go b/pkg/relay/self_subscribe_test.go new file mode 100644 index 00000000..8b89bba7 --- /dev/null +++ b/pkg/relay/self_subscribe_test.go @@ -0,0 +1,319 @@ +package relay_test + +import ( + "context" + "slices" + "sync/atomic" + "testing" + "time" + + "github.com/floatdrop/moq-go/pkg/moqt/message" + "github.com/floatdrop/moq-go/pkg/moqt/session" + "github.com/floatdrop/moq-go/pkg/relay" +) + +// answerSubscribes accepts SUBSCRIBEs on sess, answers each with alias and +// reports its track name. +func answerSubscribes(t *testing.T, sess *session.Session, alias uint64) <-chan string { + t.Helper() + names := make(chan string, 8) + go func() { + for { + req, err := sess.AcceptRequest(t.Context()) + if err != nil { + return + } + sub, ok := req.First.(*message.Subscribe) + if !ok { + continue + } + _ = req.Reply(&message.SubscribeOK{TrackAlias: alias}) + names <- string(sub.Name) + } + }() + return names +} + +// TestRelay_SelfSubscribeUnderOwnNamespace: "An endpoint MAY SUBSCRIBE to a +// Track it is publishing ... Such self-subscriptions are identical to +// subscriptions initiated by other endpoints" (§5.1). A client that +// PUBLISH_NAMESPACEd video and SUBSCRIBEs video/self is SUBSCRIBEd for it in +// turn, and its own Objects reach its subscription. +func TestRelay_SelfSubscribeUnderOwnNamespace(t *testing.T) { + t.Parallel() + sess, teardown := connectRelay(t, relay.Config{}) + defer teardown() + publishNS(t, sess, "video") + upstream := answerSubscribes(t, sess, 5) + + sub, err := sess.Subscribe(t.Context(), &message.Subscribe{Namespace: ns("video"), Name: []byte("self")}) + if err != nil { + t.Fatalf("Subscribe to its own track: %v", err) + } + t.Cleanup(func() { _ = sub.Close() }) + select { + case name := <-upstream: + if name != "self" { + t.Fatalf("relay SUBSCRIBEd %q, want self", name) + } + case <-time.After(2 * time.Second): + t.Fatal("the relay never SUBSCRIBEd the publishing client") + } + + go sendObjects(sess, 5, 0, 1) + ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) + defer cancel() + for { + ds, err := sess.AcceptDataStream(ctx) + if err != nil { + t.Fatalf("its own Objects never reached its subscription: %v", err) + } + if sg, ok := ds.(*session.IncomingSubgroupStream); ok && sg.Header.TrackAlias == sub.TrackAlias() { + return + } + } +} + +// TestRelay_SelfSubscribeAlongsideAnotherPublisher: with another publisher +// already serving the track, the subscribing client, which also publishes the +// namespace, is SUBSCRIBEd too, as every matching publisher is (§9.5). +func TestRelay_SelfSubscribeAlongsideAnotherPublisher(t *testing.T) { + t.Parallel() + other, teardown := connectRelay(t, relay.Config{}) + defer teardown() + publishNS(t, other, "video") + answerSubscribes(t, other, 9) + + self := dialAnotherClient(t, other) + publishNS(t, self, "video") + selfUpstream := answerSubscribes(t, self, 5) + + sub, err := self.Subscribe(t.Context(), &message.Subscribe{Namespace: ns("video"), Name: []byte("cam")}) + if err != nil { + t.Fatalf("Subscribe: %v", err) + } + t.Cleanup(func() { _ = sub.Close() }) + select { + case name := <-selfUpstream: + if name != "cam" { + t.Fatalf("relay SUBSCRIBEd %q, want cam", name) + } + case <-time.After(2 * time.Second): + t.Fatal("the relay never SUBSCRIBEd the subscribing client, a matching publisher") + } +} + +// TestRelay_SelfFetchStitchesFromOwnSession: a FETCH whose only fetch-capable +// upstream is the requester's own session asks that session for a cache hole, +// as it would ask any other (§5.1: self-subscriptions "are identical"), rather +// than marking the hole unknown. +func TestRelay_SelfFetchStitchesFromOwnSession(t *testing.T) { + t.Parallel() + upSess, teardown := connectRelay(t, relay.Config{}) + t.Cleanup(teardown) + publishNS(t, upSess, "video") + var asked atomic.Int32 + go func() { + for { + req, err := upSess.AcceptRequest(t.Context()) + if err != nil { + return + } + switch m := req.First.(type) { + case *message.Subscribe: + if req.Reply(&message.SubscribeOK{TrackAlias: 42}) != nil { + return + } + // The live stream misses Object 2. + publishCam1Group(t, upSess, 42, true, + cam1Object{0, 0, nil}, cam1Object{0, 1, nil}, cam1Object{0, 3, nil}) + case *message.Fetch: + asked.Add(1) + if req.Reply(&message.FetchOK{EndLocation: message.Location{Group: 0, Object: 3}}) != nil { + return + } + out, err := upSess.OpenFetchStream(message.FetchHeader{RequestID: m.RequestID}) + if err != nil { + return + } + _ = out.WriteObject(&message.FetchObject{ + SerializationFlags: message.FetchFlagGroupIDDelta | message.FetchFlagObjectIDDelta | + message.FetchFlagPriority | uint64(message.FetchSubgroupIDExplicit), + GroupIDDelta: 0, ObjectIDDelta: 2, ObjectPayload: []byte("x"), + }) + _ = out.Close() + } + } + }() + live := dialAnotherClient(t, upSess) + subscribeCam1(t, live) + go drainAll(t.Context(), live) + waitRelayLargest(t, dialAnotherClient(t, upSess), ns("video"), []byte("cam1"), 0, 3) + + got := fetchCam1Range(t, upSess, message.Location{}, message.Location{Group: 0, Object: 3}, + message.GroupOrderAscending) + if want := []fetchElem{obj(0, 0), obj(0, 1), obj(0, 2), obj(0, 3)}; !slices.Equal(got, want) { + t.Fatalf("FETCH elements %v, want %v", got, want) + } + if asked.Load() != 1 { + t.Fatalf("the relay asked the requester's own session %d times, want once", asked.Load()) + } +} + +// TestRelay_SelfSubscribeReusingAnUpstream: a client subscribing to a track +// the relay already receives from another publisher's PUBLISH, under a +// namespace the client itself published, is SUBSCRIBEd too, as every covering +// publisher missing from the track is (§9.5, §5.1). +func TestRelay_SelfSubscribeReusingAnUpstream(t *testing.T) { + t.Parallel() + pubSess, _ := newCam1Publisher(t, nil) + self := dialAnotherClient(t, pubSess) + publishNS(t, self, "video") // no subscriber yet, so no SUBSCRIBE for cam1 + selfUpstream := answerSubscribes(t, self, 5) + + subscribeCam1(t, self) // reuses the PUBLISH's upstream + select { + case name := <-selfUpstream: + if name != "cam1" { + t.Fatalf("relay SUBSCRIBEd %q, want cam1", name) + } + case <-time.After(2 * time.Second): + t.Fatal("the relay never SUBSCRIBEd the subscribing client, a covering publisher") + } +} + +// loopCap bounds the requests a naive peer relay routes back, so a loop the +// relay does not stop still ends. +const loopCap = 10 + +// TestRelay_SelfSubscribeLoopStopsAtSecondHop: a peer relay that +// PUBLISH_NAMESPACEd video and routes every SUBSCRIBE it gets back to the +// relay on the same session (§6.2: PUBLISH_NAMESPACE "does not protect against +// loops") gets one SUBSCRIBE: its routed-back SUBSCRIBE finds the relay's own +// still pending on that session and is not SUBSCRIBEd back again. +func TestRelay_SelfSubscribeLoopStopsAtSecondHop(t *testing.T) { + t.Parallel() + peer, teardown := connectRelay(t, relay.Config{}) + defer teardown() + publishNS(t, peer, "video") + var relaySubs atomic.Int32 + go func() { + for { + req, err := peer.AcceptRequest(t.Context()) + if err != nil { + return + } + sub, ok := req.First.(*message.Subscribe) + if !ok { + continue + } + n := relaySubs.Add(1) + go func() { + if n <= loopCap { // route it back before answering, as a naive relay would + if s, err := peer.Subscribe( + t.Context(), + &message.Subscribe{Namespace: sub.Namespace, Name: sub.Name}, + ); err == nil { + defer s.Close() + } + } + _ = req.Reply(&message.SubscribeOK{TrackAlias: uint64(n)}) + }() + } + }() + + sub, err := peer.Subscribe(t.Context(), &message.Subscribe{Namespace: ns("video"), Name: []byte("loop")}) + if err != nil { + t.Fatalf("Subscribe: %v", err) + } + t.Cleanup(func() { _ = sub.Close() }) + // Each reply waits for its routed-back SUBSCRIBE, so the count is final. + if n := relaySubs.Load(); n != 1 { + t.Fatalf("the relay sent the peer %d SUBSCRIBEs for one track, want 1", n) + } +} + +// TestRelay_SelfFetchLoopStopsAtSecondHop: the same peer, routing a stitch +// FETCH back for the hole the relay asked it about, gets one FETCH: the +// routed-back FETCH finds the relay's own still pending on that session and +// marks the hole unknown instead of asking the peer again. +func TestRelay_SelfFetchLoopStopsAtSecondHop(t *testing.T) { + t.Parallel() + peer, teardown := connectRelay(t, relay.Config{}) + t.Cleanup(teardown) + publishNS(t, peer, "video") + var relayFetches atomic.Int32 + go func() { + for { + req, err := peer.AcceptRequest(t.Context()) + if err != nil { + return + } + switch m := req.First.(type) { + case *message.Subscribe: + if req.Reply(&message.SubscribeOK{TrackAlias: 42}) != nil { + return + } + // The live stream misses Object 2. + publishCam1Group(t, peer, 42, true, + cam1Object{0, 0, nil}, cam1Object{0, 1, nil}, cam1Object{0, 3, nil}) + case *message.Fetch: + n := relayFetches.Add(1) + go func() { + if n <= loopCap { // route it back before answering + // Held open while the relay serves it, as a relay + // waiting on its upstream would. + if fr, err := peer.Fetch(t.Context(), &message.Fetch{ + Namespace: m.Namespace, Name: m.Name, Parameters: m.Parameters, + }); err == nil { + defer fr.Close() + time.Sleep(100 * time.Millisecond) + } + } + if req.Reply(&message.FetchOK{EndLocation: message.Location{Group: 0, Object: 3}}) != nil { + return + } + if out, err := peer.OpenFetchStream(message.FetchHeader{RequestID: m.RequestID}); err == nil { + _ = out.Close() + } + }() + } + } + }() + live := dialAnotherClient(t, peer) + subscribeCam1(t, live) + go drainAll(t.Context(), live) + waitRelayLargest(t, dialAnotherClient(t, peer), ns("video"), []byte("cam1"), 0, 3) + + // The peer reads every fetch stream the relay opens it concurrently, its + // routed-back FETCHes' included, as a relay would. + go func() { + for { + ds, err := peer.AcceptDataStream(t.Context()) + if err != nil { + return + } + if fs, ok := ds.(*session.IncomingFetchStream); ok { + go func() { + for { + if _, err := fs.ReadObject(); err != nil { + return + } + } + }() + } + } + }() + fr, err := peer.Fetch(t.Context(), &message.Fetch{ + Namespace: ns("video"), Name: []byte("cam1"), + Parameters: message.Parameters{fetchRangeFilter(message.Location{}, message.Location{Group: 0, Object: 3})}, + }) + if err != nil { + t.Fatalf("Fetch: %v", err) + } + defer fr.Close() + time.Sleep(time.Second) + if n := relayFetches.Load(); n != 1 { + t.Fatalf("the relay sent the peer %d stitch FETCHes for one hole, want 1", n) + } +} diff --git a/pkg/relay/session_handler.go b/pkg/relay/session_handler.go index 36daee47..8978c714 100644 --- a/pkg/relay/session_handler.go +++ b/pkg/relay/session_handler.go @@ -52,6 +52,8 @@ type sessionHandler struct { sendQueueSize int maxDropsBeforeReset int maxFanoutLag time.Duration + // maxRendezvous caps RENDEZVOUS_TIMEOUT (§10.2.6); 0 holds no SUBSCRIBE. + maxRendezvous time.Duration // limiter enforces the §13.1 / §13.7.1 per-session resource caps. limiter sessionLimiter @@ -63,6 +65,11 @@ type sessionHandler struct { // to be registered; see [sessionHandler.resolveInboundTrack]. earlyStreams atomic.Int32 + // subscribing holds, per track, this session's SUBSCRIBEs that have not + // registered their downstream yet; see [sessionHandler.beginSubscribe]. + subscribingMu sync.Mutex + subscribing map[track.Key]*inflightSubscribe + // relayGo runs fn on a RELAY-scoped goroutine (joined by Relay.Stop, // not by this handler's run). Used for work whose lifetime must outlive // this session — e.g. the reader of an on-demand upstream stream, which @@ -91,6 +98,7 @@ func newSessionHandler( maxFanoutLag time.Duration, maxSubsPerSession int, maxNamespaceReqsPerSession int, + maxRendezvous time.Duration, relayGo func(func()), ) *sessionHandler { return &sessionHandler{ @@ -108,6 +116,7 @@ func newSessionHandler( sendQueueSize: sendQueueSize, maxDropsBeforeReset: maxDropsBeforeReset, maxFanoutLag: maxFanoutLag, + maxRendezvous: maxRendezvous, limiter: sessionLimiter{maxSubs: maxSubsPerSession, maxNS: maxNamespaceReqsPerSession}, relayGo: relayGo, } @@ -141,7 +150,7 @@ func saveLargestLocation(entry *registry.TrackEntry, ps message.Parameters) { // logInboundGoaway records the peer's GOAWAY (§10.4). The relay does not close // the session: enforcing the Timeout is the sender's job. It only stops -// initiating requests to the peer (see [peerSentGoaway]). +// initiating requests to the peer (see [goingAway]). // // Deviation: the relay neither migrates its subscriptions to NewSessionURI nor // closes the session once none remain (§9.4.1, §3.6); downstream clients @@ -155,10 +164,12 @@ func (h *sessionHandler) logInboundGoaway(ctx context.Context) { slog.String("new_session_uri", string(g.NewSessionURI))) } -// peerSentGoaway reports whether sess's peer has sent GOAWAY. §10.4: an -// endpoint "SHOULD NOT initiate new requests to the peer"; every relay-initiated -// SUBSCRIBE, FETCH and PUBLISH checks this first. -func peerSentGoaway(sess *session.Session) bool { return sess.PeerGoaway() != nil } +// goingAway reports whether sess has a GOAWAY in either direction, so the +// relay initiates no new request on it (§10.4): having received one, an +// endpoint "SHOULD NOT initiate new requests to the peer"; having sent one, +// it "SHOULD avoid initiating requests unless required by migration". Every +// relay-initiated SUBSCRIBE, FETCH and PUBLISH checks this first. +func goingAway(sess *session.Session) bool { return sess.PeerGoaway() != nil || sess.GoawaySent() } // subIDCounter allocates process-globally unique subscription IDs. It MUST // be global, not per-handler: a TrackEntry aggregates subscriptions from @@ -412,11 +423,10 @@ func (h *sessionHandler) rejectAuth(ctx context.Context, req *session.Request, k // guess, since the relay cannot predict when a per-session cap frees up. const excessiveLoadRetry = time.Second -// excessiveLoadRetryInterval is the Retry Interval for an EXCESSIVE_LOAD -// rejection (§10.6.2): excessiveLoadRetry plus up to 50% jitter, encoded as -// milliseconds plus one. -func excessiveLoadRetryInterval() uint64 { - const ms = uint64(excessiveLoadRetry / time.Millisecond) +// retryIntervalAfter is a Retry Interval (§10.6.2) inviting a retry after d +// plus up to 50% jitter, encoded as milliseconds plus one. +func retryIntervalAfter(d time.Duration) uint64 { + ms := uint64(d / time.Millisecond) //nolint:gosec // G115: callers pass a positive constant duration. return ms + rand.Uint64N(ms/2) + 1 //nolint:gosec // G404: retry jitter, not a secret. } @@ -430,7 +440,7 @@ func (h *sessionHandler) rejectExcessiveLoad(ctx context.Context, req *session.R if err := req.Reject(&session.RequestRejectedError{ Code: moqt.RequestExcessiveLoad, Reason: "relay: " + what + " limit reached", - RetryInterval: excessiveLoadRetryInterval(), + RetryInterval: retryIntervalAfter(excessiveLoadRetry), }); err != nil && !errors.Is(err, context.Canceled) { h.log.LogAttrs(ctx, slog.LevelDebug, "relay EXCESSIVE_LOAD reject write failed", @@ -466,6 +476,18 @@ func tokenDenial(denyErr error) (moqt.RequestErrorCode, string) { return moqt.RequestUnauthorized, denyErr.Error() } +// refuseUpdateTokens is the REQUEST_ERROR refusing a REQUEST_UPDATE whose +// AUTHORIZATION_TOKENs the TokenVerifier denies, or nil: they authorize "the +// operation carrying the parameter" (§10.2.2) as an opener's do. +func (h *sessionHandler) refuseUpdateTokens(ctx context.Context, toks []session.ResolvedToken) *message.RequestError { + err := h.sess.VerifyTokens(ctx, toks) + if err == nil { + return nil + } + code, reason := tokenDenial(err) + return &message.RequestError{ErrorCode: code, ErrorReason: reason} +} + // handleFollowupRequestID validates a peer REQUEST_UPDATE's Request ID — // §10.1: an update consumes an ID from the sender's space, and the readers // that parse follow-ups directly bypass AcceptRequest's checking. A @@ -531,10 +553,14 @@ func (h *sessionHandler) handleFollowupTokens( var errRequestCancelled = errors.New("relay: request cancelled") // ctxResetCode is the §3.3.4 code for a stream reset because ctx ended: -// CANCELLED for a cancelled request, SESSION_CLOSED otherwise. +// CANCELLED for a cancelled request, MALFORMED_TRACK for a fetch stream of a +// malformed track (§2.4.2), SESSION_CLOSED otherwise. func ctxResetCode(ctx context.Context) moqt.StreamResetCode { - if errors.Is(context.Cause(ctx), errRequestCancelled) { + switch cause := context.Cause(ctx); { + case errors.Is(cause, errRequestCancelled): return moqt.StreamResetCancelled + case errors.Is(cause, session.ErrMalformedTrack): + return moqt.StreamResetMalformedTrack } return moqt.StreamResetSessionClosed } @@ -626,6 +652,12 @@ func awaitRequestEnd(ctx context.Context, stream session.Stream) { // serveFetchObjects is the response tail of the FETCH handler: stream the // stitched range, FIN, and park in the §10.9 follow-up loop until the // requester resets or FINs the request stream. kind tags log lines. +// +// A cancel before the FIN (the requester's STOP_SENDING, §3.3.3) resets both +// streams with CANCELLED. §5.2: the publisher "MUST reset the bidi request +// stream and unidirectional data stream associated with the FETCH". A data +// stream reset because the track must not be forwarded (§2.4.2, §2.5.1) ends +// the request too: the relay cancels it with the same code (§3.3.3). func (h *sessionHandler) serveFetchObjects( ctx context.Context, req *session.Request, @@ -638,13 +670,33 @@ func (h *sessionHandler) serveFetchObjects( fillTimeout time.Duration, rangeFilters *message.RangeFilterSet, ) { - ok := h.streamFetchRange(ctx, kind, nil, requestID, entry, fullName, + fetchCtx, cancel := context.WithCancelCause(ctx) + defer cancel(nil) + stop := context.AfterFunc(req.Stream.Context(), func() { cancel(errRequestCancelled) }) + // §2.4.2: a relay that detects a malformed track MUST "reset any fetch + // streams with Status Code MALFORMED_TRACK"; see endMalformedTrack. + remove := entry.AddFetch(cancel) + out, refused, code := h.streamFetchRange(fetchCtx, kind, nil, requestID, entry, fullName, start, end, order, fillTimeout, rangeFilters) - if !ok { + stop() + remove() + if out == nil { + // The requester's own signal, not fetchCtx's cause: a write can fail on + // its STOP_SENDING for the data stream before the cause is set. + switch { + case req.Stream.Context().Err() != nil: + code = moqt.StreamResetCancelled + case !refused: + return + } + // §3.3.3: "RESET_STREAM for a direction they are sending and + // STOP_SENDING for a direction they are receiving". + req.Stream.CancelRead(uint64(code)) + req.Stream.CancelWrite(uint64(code)) return } - h.readFetchUpdates(ctx, req) + h.readFetchUpdates(ctx, req, out) } // streamFetchRange opens a unidirectional fetch stream, writes the stitched @@ -653,8 +705,11 @@ func (h *sessionHandler) serveFetchObjects( // and in what happens afterwards — a FETCH parks in the §10.9 follow-up loop, // a fill is simply done. // -// It reports false when the stream could not be opened, the write failed, or -// the upstream refused the track (§2.5.1); the stream is then already reset. +// It returns the FINed stream, or nil when the stream could not be opened, the +// write failed, or the track must not be forwarded; the stream is then already +// reset. refused reports the last case, a malformed track (§2.4.2) or an +// upstream refusal (§2.5.1), and, when refused, code the data stream was reset +// with. func (h *sessionHandler) streamFetchRange( ctx context.Context, kind string, @@ -666,12 +721,12 @@ func (h *sessionHandler) streamFetchRange( order message.GroupOrder, fillTimeout time.Duration, rangeFilters *message.RangeFilterSet, -) bool { +) (_ *session.OutgoingFetchStream, refused bool, code moqt.StreamResetCode) { out, err := openFillOrFetchStream(h.sess, sub, requestID) if err != nil { h.log.LogAttrs(ctx, slog.LevelDebug, "OpenFetchStream failed", slog.String("kind", kind), slog.String("err", err.Error())) - return false + return nil, false, 0 } if sub != nil { // A fill stream's subscription holds its PUBLISH_DONE until the @@ -680,31 +735,39 @@ func (h *sessionHandler) streamFetchRange( } // ctx ending resets the stream rather than completing it: CANCELLED when // its cause is errRequestCancelled (a fill's cancelled subscription, - // §5.1.3.1), else SESSION_CLOSED (§3.3.4). + // §5.1.3.1, or a cancelled FETCH, §5.2), MALFORMED_TRACK for a malformed + // track (§2.4.2; callers register ctx's cancel with + // [registry.TrackEntry.AddFetch]), else SESSION_CLOSED (§3.3.4). cancelOut := func() { out.Cancel(ctxResetCode(ctx)) } unwatch := context.AfterFunc(ctx, cancelOut) defer unwatch() + // ctxEnded resets the stream for ctx, before the deferred StreamClosed + // (§10.12), and reports it. + ctxEnded := func() (*session.OutgoingFetchStream, bool, moqt.StreamResetCode) { + cancelOut() + c := ctxResetCode(ctx) + return nil, c == moqt.StreamResetMalformedTrack, c + } // Gather cached objects, asking an upstream about what the cache cannot // vouch for (§10.13). objs, refusal := h.stitchedFetchObjects(ctx, entry, fullName, start, end, order, fillTimeout) if ctx.Err() != nil { - cancelOut() // before the deferred StreamClosed (§10.12) - return false + return ctxEnded() } if refusal != nil { // §2.5.1: with FETCH_OK (or SUBSCRIBE_OK) already sent, only a // reset is left (an interpretation: no Object was forwarded yet). - // Unparseable Track Properties (§3.3.4) and a malformed upstream + // Unparseable Track Properties (§12.7, §2.4.2) and a malformed upstream // Object (§2.4.2) reset with MALFORMED_TRACK. - code := moqt.StreamResetInternalError + code = moqt.StreamResetInternalError if errors.Is(refusal, session.ErrMalformedTrackProperties) || errors.Is(refusal, session.ErrMalformedTrack) { code = moqt.StreamResetMalformedTrack } h.log.LogAttrs(ctx, slog.LevelDebug, "upstream FETCH refused", slog.String("kind", kind), slog.String("err", refusal.Error())) out.Cancel(code) - return false + return nil, true, code } // §5.1.4: drop objects that fail the request's Range Filters. §11.4.4.2 @@ -724,13 +787,15 @@ func (h *sessionHandler) streamFetchRange( if err != nil { h.log.LogAttrs(ctx, slog.LevelDebug, "fetch stream write failed", slog.String("kind", kind), slog.String("err", err.Error())) + if ctx.Err() != nil { + return ctxEnded() // ctx's reset failed the write; keep its code + } out.Cancel(moqt.StreamResetInternalError) - return false + return nil, false, 0 } if !unwatch() { - cancelOut() // ctx ended first; reset before the deferred StreamClosed - return false + return ctxEnded() // ctx ended first } _ = out.Close() - return true + return out, false, 0 } diff --git a/pkg/relay/session_upstream_test.go b/pkg/relay/session_upstream_test.go index 4724145d..917eb207 100644 --- a/pkg/relay/session_upstream_test.go +++ b/pkg/relay/session_upstream_test.go @@ -304,7 +304,8 @@ func TestSubscribe_UpstreamAliasReusableAfterTeardown(t *testing.T) { } // TestSubscribe_UpstreamRejects_PropagatesRejection: an upstream REQUEST_ERROR -// code about the track passes downstream; one about the relay's own hop becomes +// code about the track passes downstream; one about the relay's own hop, or not +// defined for SUBSCRIBE (MALFORMED_TRACK answers a FETCH), becomes // INTERNAL_ERROR (§10.6.2). The Retry Interval is kept either way. func TestSubscribe_UpstreamRejects_PropagatesRejection(t *testing.T) { t.Parallel() @@ -315,7 +316,7 @@ func TestSubscribe_UpstreamRejects_PropagatesRejection(t *testing.T) { {moqt.RequestDoesNotExist, moqt.RequestDoesNotExist, 0}, {moqt.RequestExcessiveLoad, moqt.RequestExcessiveLoad, 501}, {moqt.RequestTimeout, moqt.RequestTimeout, 1}, - {moqt.RequestMalformedTrack, moqt.RequestMalformedTrack, 0}, + {moqt.RequestMalformedTrack, moqt.RequestInternalError, 0}, {moqt.RequestUnauthorized, moqt.RequestInternalError, 0}, {moqt.RequestExpiredAuthToken, moqt.RequestInternalError, 2001}, {moqt.RequestGoingAway, moqt.RequestInternalError, 0}, diff --git a/pkg/relay/shutdown_close_code_test.go b/pkg/relay/shutdown_close_code_test.go new file mode 100644 index 00000000..5c1d70d7 --- /dev/null +++ b/pkg/relay/shutdown_close_code_test.go @@ -0,0 +1,93 @@ +package relay_test + +import ( + "context" + "testing" + "time" + + "github.com/floatdrop/moq-go/pkg/moqt" + "github.com/floatdrop/moq-go/pkg/moqt/message" + "github.com/floatdrop/moq-go/pkg/moqt/session" + "github.com/floatdrop/moq-go/pkg/relay" +) + +// closeCodeConn records the first code its session closes it with. +type closeCodeConn struct { + session.Conn + + codes chan uint64 +} + +func (c *closeCodeConn) CloseWithError(code uint64, reason string) error { + select { + case c.codes <- code: + default: + } + return c.Conn.CloseWithError(code, reason) +} + +// stopCloseCode starts a relay with goaway as its GoawayTimeout, connects a +// client that never leaves, stops the relay with a ctx cancelled after +// stopWithin, and returns the code the relay closed the client's session with. +func stopCloseCode(t *testing.T, goaway, stopWithin time.Duration) moqt.SessionErrorCode { + t.Helper() + l := newPipeListener() + codes := make(chan uint64, 1) + l.wrap = func(c session.Conn) session.Conn { return &closeCodeConn{Conn: c, codes: codes} } + r := relay.New(l, relay.Config{GoawayTimeout: goaway}) + go func() { _ = r.Start(t.Context()) }() + + conn, err := l.Dial() + if err != nil { + t.Fatalf("Dial: %v", err) + } + sess, err := session.Client(t.Context(), conn) + if err != nil { + t.Fatalf("session.Client: %v", err) + } + t.Cleanup(func() { _ = sess.Close(moqt.SessionNoError, "cleanup") }) + // A round trip: the relay registers a session before serving its + // requests, so Stop's snapshot now holds it rather than leaving it a + // straggler drained on its own schedule. + if _, err := sess.TrackStatus( + t.Context(), + &message.TrackStatus{Namespace: ns("none"), Name: []byte("x")}, + ); err == nil { + t.Fatal("TRACK_STATUS for an unknown track succeeded") + } + + ctx, cancel := context.WithTimeout(context.Background(), stopWithin) + defer cancel() + _ = r.Stop(ctx) + select { + case code := <-codes: + return moqt.SessionErrorCode(code) + case <-time.After(2 * time.Second): + t.Fatal("the relay never closed the session") + return 0 + } +} + +// TestStopClosesWithGoawayTimeoutOnlyAfterGoaway: GOAWAY_TIMEOUT means "the +// peer took too long to close the session in response to a GOAWAY" (§3.5), so +// the relay closes with it only when it sent a GOAWAY and the grace period +// ran out; otherwise NO_ERROR. +func TestStopClosesWithGoawayTimeoutOnlyAfterGoaway(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + goaway, stopWithin time.Duration + want moqt.SessionErrorCode + }{ + {"no GOAWAY configured", 0, 5 * time.Second, moqt.SessionNoError}, + {"GOAWAY grace period ran out", 50 * time.Millisecond, 5 * time.Second, moqt.SessionGoawayTimeout}, + {"Stop cancelled before the grace period", 5 * time.Second, 100 * time.Millisecond, moqt.SessionNoError}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := stopCloseCode(t, tc.goaway, tc.stopWithin); got != tc.want { + t.Errorf("closed with %#x, want %#x", uint64(got), uint64(tc.want)) + } + }) + } +} diff --git a/pkg/relay/shutdown_straggler_test.go b/pkg/relay/shutdown_straggler_test.go index 72675704..4f111a73 100644 --- a/pkg/relay/shutdown_straggler_test.go +++ b/pkg/relay/shutdown_straggler_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + "github.com/floatdrop/moq-go/pkg/moqt" "github.com/floatdrop/moq-go/pkg/moqt/session" "github.com/floatdrop/moq-go/pkg/moqt/session/sessiontest" ) @@ -21,59 +22,99 @@ func (stragglerListener) Accept(ctx context.Context) (session.Conn, error) { func (stragglerListener) Addr() net.Addr { return nil } func (stragglerListener) Close() error { return nil } +// stragglerCodeConn records the first code the relay closes it with. +type stragglerCodeConn struct { + session.Conn + + codes chan uint64 +} + +func (c *stragglerCodeConn) CloseWithError(code uint64, reason string) error { + select { + case c.codes <- code: + default: + } + return c.Conn.CloseWithError(code, reason) +} + // TestRelay_addSessionDrainsStraggler: a session registered after Stop took its // snapshot still goes through GOAWAY, grace and force-close, via -// addSession's drainStraggler. +// addSession's drainStraggler. It closes with GOAWAY_TIMEOUT only when it was +// sent a GOAWAY and the grace period ran out (§3.5), and NO_ERROR when no +// GOAWAY is configured. func TestRelay_addSessionDrainsStraggler(t *testing.T) { t.Parallel() - const grace = 150 * time.Millisecond + for _, tc := range []struct { + name string + grace time.Duration + want moqt.SessionErrorCode + }{ + {"grace period ran out", 150 * time.Millisecond, moqt.SessionGoawayTimeout}, + {"no GOAWAY configured", 0, moqt.SessionNoError}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + r := New(stragglerListener{}, Config{GoawayTimeout: tc.grace}) - r := New(stragglerListener{}, Config{GoawayTimeout: grace}) + // Establish a real session pair. The SETUP handshake is symmetric, + // so the client and server ends must run concurrently. + clientConn, rawServer := sessiontest.NewConnPair() + codes := make(chan uint64, 1) + serverConn := &stragglerCodeConn{Conn: rawServer, codes: codes} + type result struct { + s *session.Session + err error + } + clientCh := make(chan result, 1) + serverCh := make(chan result, 1) + go func() { s, err := session.Client(t.Context(), clientConn); clientCh <- result{s, err} }() + go func() { s, err := session.Server(t.Context(), serverConn); serverCh <- result{s, err} }() + cl, sv := <-clientCh, <-serverCh + if cl.err != nil || sv.err != nil { + t.Fatalf("handshake failed: client=%v server=%v", cl.err, sv.err) + } + clientSess, serverSess := cl.s, sv.s + defer func() { _ = clientSess.Close(0, "") }() - // Establish a real session pair. The SETUP handshake is symmetric, so the - // client and server ends must run concurrently. - clientConn, serverConn := sessiontest.NewConnPair() - type result struct { - s *session.Session - err error - } - clientCh := make(chan result, 1) - serverCh := make(chan result, 1) - go func() { s, err := session.Client(t.Context(), clientConn); clientCh <- result{s, err} }() - go func() { s, err := session.Server(t.Context(), serverConn); serverCh <- result{s, err} }() - cl, sv := <-clientCh, <-serverCh - if cl.err != nil || sv.err != nil { - t.Fatalf("handshake failed: client=%v server=%v", cl.err, sv.err) - } - clientSess, serverSess := cl.s, sv.s + // Simulate Stop having already begun: beginShutdown marks + // shuttingDown and snapshots the (still empty) session set. The + // straggler registers next. + if snap := r.beginShutdown(); len(snap) != 0 { + t.Fatalf("beginShutdown snapshot = %d sessions, want 0", len(snap)) + } - // Simulate Stop having already begun: beginShutdown marks shuttingDown and - // snapshots the (still empty) session set. The straggler registers next. - if snap := r.beginShutdown(); len(snap) != 0 { - t.Fatalf("beginShutdown snapshot = %d sessions, want 0", len(snap)) - } + // addSession must observe shuttingDown and take ownership of the + // drain. + r.addSession(serverSess, LegLocal) - // addSession must observe shuttingDown and take ownership of the drain. - r.addSession(serverSess, LegLocal) + // drainStraggler must GOAWAY the peer when a grace period is set... + if tc.grace > 0 { + select { + case <-clientSess.GoawayReceived(): + case <-time.After(2 * time.Second): + t.Fatal("client did not receive GOAWAY from straggler drain") + } + } - // drainStraggler must GOAWAY the peer... - select { - case <-clientSess.GoawayReceived(): - case <-time.After(2 * time.Second): - t.Fatal("client did not receive GOAWAY from straggler drain") - } + // ...and, because this client ignores the GOAWAY, force-close at + // the grace boundary so the session terminates. + select { + case <-serverSess.Done(): + case <-time.After(2 * time.Second): + t.Fatal("straggler session was not closed after the grace period") + } + select { + case code := <-codes: + if got := moqt.SessionErrorCode(code); got != tc.want { + t.Errorf("closed with %#x, want %#x", uint64(got), uint64(tc.want)) + } + case <-time.After(2 * time.Second): + t.Fatal("no close code recorded") + } - // ...and, because this client ignores the GOAWAY, force-close at the grace - // boundary so the session terminates. - select { - case <-serverSess.Done(): - case <-time.After(2 * time.Second): - t.Fatal("straggler session was not closed after the grace period") + // drainStraggler runs under r.handlers, so Stop's handlers.Wait + // joins it. It must return promptly now that the session is closed. + r.handlers.Wait() + }) } - - // drainStraggler runs under r.handlers, so Stop's handlers.Wait joins it. - // Wait here too: it must return promptly now that the session is closed. - r.handlers.Wait() - - _ = clientSess.Close(0, "") } diff --git a/pkg/relay/subscribe_tracks_test.go b/pkg/relay/subscribe_tracks_test.go index 76abfcfa..fdcfae9b 100644 --- a/pkg/relay/subscribe_tracks_test.go +++ b/pkg/relay/subscribe_tracks_test.go @@ -568,6 +568,32 @@ func TestIncludeProperties_SubscribeOK(t *testing.T) { } } +// TestIncludeProperties_SurvivesUpdate: a REQUEST_UPDATE cannot carry +// INCLUDE_PROPERTIES, so it leaves the subscription's 0 in force: "If a +// parameter previously set on the request is not present in REQUEST_UPDATE, +// its value remains unchanged" (§10.9). Subgroups after the update still carry +// the priority inline. +func TestIncludeProperties_SurvivesUpdate(t *testing.T) { + t.Parallel() + pubSess, alias := newCam1Publisher(t, priorityTrackProps()) + subSess := dialAnotherClient(t, pubSess) + sub := subscribeCam1(t, subSess, noProps()) + if _, err := sub.Update(t.Context(), message.Parameters{message.SubscriberPriorityParam(9)}); err != nil { + t.Fatalf("Update: %v", err) + } + + go sendObjects(pubSess, alias, 1, 1) + ds, ok := tryAcceptDataStream(t, subSess, 2*time.Second) + if !ok { + t.Fatal("no subgroup forwarded") + } + sg := ds.(*session.IncomingSubgroupStream) + if !sg.Header.InlinePriority || sg.Header.PublisherPriority != trackDefaultPriority { + t.Fatalf("forwarded header after the update inline=%v priority=%d, want inline priority %d", + sg.Header.InlinePriority, sg.Header.PublisherPriority, trackDefaultPriority) + } +} + // TestIncludeProperties_Datagram: forwarded datagrams carry the priority // explicitly. func TestIncludeProperties_Datagram(t *testing.T) { diff --git a/pkg/relay/track_status_test.go b/pkg/relay/track_status_test.go index 09adb58d..fc7bee10 100644 --- a/pkg/relay/track_status_test.go +++ b/pkg/relay/track_status_test.go @@ -74,6 +74,29 @@ func TestTrackStatus_OmitsLargestObjectBeforeAnyObjects(t *testing.T) { } } +// TestTrackStatus_AnswersPublishedTrackWithNothingYet: a PUBLISHed track +// with no Track Properties and no Objects is one SUBSCRIBE accepts, and the +// relay "treats [TRACK_STATUS] identically as if it had received a SUBSCRIBE" +// (§10.15), so it answers TRACK_STATUS_OK rather than DOES_NOT_EXIST. +func TestTrackStatus_AnswersPublishedTrackWithNothingYet(t *testing.T) { + t.Parallel() + pubSess, _ := newCam1Publisher(t, nil) + + querySess := dialAnotherClient(t, pubSess) + subscribeCam1(t, querySess) // SUBSCRIBE accepts the track + tsStream, err := querySess.TrackStatus( + t.Context(), + &message.TrackStatus{Namespace: ns("video"), Name: []byte("cam1")}, + ) + if err != nil { + t.Fatalf("TrackStatus: %v", err) + } + defer tsStream.Close() + if _, found := tsStream.OK.Parameters.Find(message.ParamLargestObject); found { + t.Error("TRACK_STATUS_OK carried LARGEST_OBJECT before any Object") + } +} + // TestRelay_TrackStatusRequestUpdateClosesSession: a REQUEST_UPDATE on a // TRACK_STATUS stream closes the session (§10.15, §10.9). func TestRelay_TrackStatusRequestUpdateClosesSession(t *testing.T) { diff --git a/pkg/relay/update_token_test.go b/pkg/relay/update_token_test.go new file mode 100644 index 00000000..92fae299 --- /dev/null +++ b/pkg/relay/update_token_test.go @@ -0,0 +1,117 @@ +package relay_test + +import ( + "context" + "testing" + "time" + + "github.com/floatdrop/moq-go/pkg/moqt" + "github.com/floatdrop/moq-go/pkg/moqt/message" + "github.com/floatdrop/moq-go/pkg/moqt/session" + "github.com/floatdrop/moq-go/pkg/relay" + "github.com/floatdrop/moq-go/pkg/relay/internal/relaytest" +) + +// A REQUEST_UPDATE's AUTHORIZATION_TOKEN "conveys information to authorize +// the sender to perform the operation carrying the parameter" (§10.2.2), so it +// goes through the TokenVerifier on every request an update can modify, not +// only the namespace subscriptions (see TestNamespaceUpdate_TokenVerified). A +// denial fails the update, with what §10.9.1 requires of a failed one. + +// badTokenRelay starts a relay whose TokenVerifier denies the token "bad" +// with EXPIRED_AUTH_TOKEN, returning a client session on it. +func badTokenRelay(t *testing.T) *session.Session { + t.Helper() + verifier := session.TokenVerifierFunc( + func(_ context.Context, _ *session.Session, tok session.ResolvedToken) error { + if string(tok.Value) == "bad" { + return session.DenyToken(moqt.RequestExpiredAuthToken, "token expired") + } + return nil + }) + sess, teardown := connectRelay(t, relay.Config{ + SessionOptions: []session.Option{session.WithTokenVerifier(verifier)}, + }) + t.Cleanup(teardown) + return sess +} + +// requireUpdateFailed requires PUBLISH_DONE UPDATE_FAILED next on stream +// (§10.9.1: "the publisher MUST also terminate the subscription"). +func requireUpdateFailed(t *testing.T, stream session.Stream) { + t.Helper() + next := relaytest.ReadNextMessage(t, stream, time.After(2*time.Second)) + pd, ok := next.(*message.PublishDone) + if !ok || pd.StatusCode != moqt.PublishDoneUpdateFailed { + t.Fatalf("got %T %+v, want PUBLISH_DONE UPDATE_FAILED", next, next) + } +} + +// TestRequestUpdate_TokenVerified_Subscribe: a denied update to a SUBSCRIBE +// gets the verifier's code, then PUBLISH_DONE UPDATE_FAILED. +func TestRequestUpdate_TokenVerified_Subscribe(t *testing.T) { + t.Parallel() + pubSess := badTokenRelay(t) + publishVideoTrack(t, pubSess, "cam1", 7) + sub := subscribeCam1(t, dialAnotherClient(t, pubSess)) + + _, err := sub.Update(t.Context(), message.Parameters{tokenParam("bad")}) + requireRejectedWithCode(t, err, moqt.RequestExpiredAuthToken) + requireUpdateFailed(t, sub) +} + +// TestRequestUpdate_TokenVerified_ForwardedPublish: the same for the +// subscription a SUBSCRIBE_TRACKS holder accepted from a forwarded PUBLISH, +// which it can modify like a SUBSCRIBE (§10.9). +func TestRequestUpdate_TokenVerified_ForwardedPublish(t *testing.T) { + t.Parallel() + subSess := badTokenRelay(t) + reqs := forwardedPublishes(t, subSess) + subscribeTracks(t, subSess, ns("video")) + publishVideoTrack(t, dialAnotherClient(t, subSess), "cam1", 7) + in := acceptForwarded(t, awaitForwarded(t, reqs)) + t.Cleanup(func() { _ = in.Close() }) + + _, err := in.Update(t.Context(), message.Parameters{tokenParam("bad")}) + requireRejectedWithCode(t, err, moqt.RequestExpiredAuthToken) + requireUpdateFailed(t, in) +} + +// TestRequestUpdate_TokenVerified_Fetch: a denied update to a FETCH gets the +// verifier's code and ends the request; its data stream is reset (§10.9.1), +// which the in-process transport cannot show once the stream was read to its +// FIN. +func TestRequestUpdate_TokenVerified_Fetch(t *testing.T) { + t.Parallel() + pubSess := badTokenRelay(t) + publishVideoTrack(t, pubSess, "cam1", 7) + liveSess := newCam1Subscriber(t, pubSess) + go drainAll(t.Context(), liveSess) + publishObjects(t, pubSess, 7, 0, 1) + waitRelayLargest(t, liveSess, ns("video"), []byte("cam1"), 0, 0) + + fetchSess := dialAnotherClient(t, pubSess) + fetch, err := fetchSess.Fetch(t.Context(), &message.Fetch{Namespace: ns("video"), Name: []byte("cam1")}) + if err != nil { + t.Fatalf("Fetch: %v", err) + } + t.Cleanup(func() { _ = fetch.Close() }) + go drainAll(t.Context(), fetchSess) + + _, err = fetch.Update(t.Context(), message.Parameters{tokenParam("bad")}) + requireRejectedWithCode(t, err, moqt.RequestExpiredAuthToken) + requireStreamEnds(t, streamMessages(t, fetch)) +} + +// TestRequestUpdate_TokenVerified_PublishNamespace: a denied update to a +// PUBLISH_NAMESPACE gets the verifier's code, and the relay closes the request +// stream (§10.9.1). +func TestRequestUpdate_TokenVerified_PublishNamespace(t *testing.T) { + t.Parallel() + pubSess := badTokenRelay(t) + p := publishNS(t, pubSess, "video") + + _, err := pubSess.UpdateRequest(t.Context(), p.Stream, message.Parameters{tokenParam("bad")}) + requireRejectedWithCode(t, err, moqt.RequestExpiredAuthToken) + requireStreamEnds(t, streamMessages(t, p.Stream)) +}