package loc
// EncodeAudioLevel packs an RFC 6464 audio level and voice-activity bit
// into the byte stored in LOC's AudioLevel property (§2.3.3.2).
//
// level is the magnitude in -dBov in the range [0, 127] (0 = loudest,
// 127 = silence). voiceActivity is the V flag from RFC 6464 §3. Bits
// above the 7-bit level range are clipped.
//
// Wire layout (MSB to LSB):
//
// V | L L L L L L L
// bit 7 bits 0-6
func EncodeAudioLevel(level uint8, voiceActivity bool) uint8 {
b := level & 0x7F
if voiceActivity {
b |= 0x80
}
return b
}
// DecodeAudioLevel splits the LOC AudioLevel byte into the RFC 6464
// level magnitude (bits 0-6) and voice-activity flag (bit 7).
func DecodeAudioLevel(b uint8) (level uint8, voiceActivity bool) {
return b & 0x7F, b&0x80 != 0
}
package loc
// Object is one LOC-packaged media chunk: the LOC Public Properties
// that travel in the MOQ Object Properties block, plus the codec
// elementary stream bytes that travel in the MOQ Object Payload.
//
// Object does not own a MOQ message — it produces the bytes a caller
// drops into a [github.com/floatdrop/moq-go/pkg/moqt/message.SubgroupObject]
// (or any other MOQ message that carries an object payload). The
// caller controls Group ID, Object ID, subgroup framing, and stream
// scheduling.
//
// LOC Private Properties (SecureObjects, §3.1.3) are not modelled
// here. When that support lands, Object grows a Private field and
// Encode/Decode learn the length-prefixed-prepended-to-payload layout
// the SecureObjects spec defines.
type Object struct {
Properties Properties
// Payload is the codec elementary stream — the "internal data" of
// an EncodedAudioChunk / EncodedVideoChunk in the WebCodecs Codec
// Registry. Nil and empty are equivalent.
Payload []byte
}
// Encode returns the bytes ready to plug into a MOQ Object:
//
// - props goes into the surrounding message's Properties slot
// (e.g. [message.SubgroupObject.Properties]).
// - payload is the MOQ Object Payload.
//
// The returned props slice is the inner KV-pair blob without an outer
// length prefix; the surrounding MOQ message frames it. The returned
// payload aliases [Object.Payload]; the caller must not mutate the
// originating slice after calling Encode if it is still being read.
func (o *Object) Encode() (props, payload []byte) {
return o.Properties.Encode(), o.Payload
}
// Decode reconstructs an Object from the Properties bytes (the inner
// KV-pair blob, no length prefix) and the Object Payload bytes. Both
// slices may be nil.
//
// The returned Object's Payload aliases the input slice. Callers that
// need to retain Payload past the lifetime of the input buffer must
// copy.
func Decode(props, payload []byte) (Object, error) {
p, err := ParseProperties(props)
if err != nil {
return Object{}, err
}
return Object{Properties: p, Payload: payload}, nil
}
package loc
import (
"fmt"
"github.com/floatdrop/moq-go/pkg/moqt/wire"
)
// Properties carries the LOC metadata that travels in the MOQ Object
// Properties block. The wire encoding is a sequence of [wire.KVPair]
// values, identical to [message.ObjectProperties] but without the
// outer length prefix — that prefix is owned by the containing
// MOQ message (e.g. [message.SubgroupObject] applies it on write).
//
// The well-known LOC fields are exposed as typed accessors. Pairs not
// recognised by the typed accessors land in Extras and round-trip
// unchanged.
//
// Zero values for Timestamp / Timescale / AudioLevel are valid wire
// values; absence is tracked by the matching Has-bit so callers can
// distinguish "field not present" from "field present and zero". The
// byte-slice fields (VideoConfig / VideoFrameMarking / AudioConfig)
// instead use nil to mean absent.
type Properties struct {
Timestamp uint64
Timescale uint64
// VideoConfig is the codec extradata (§2.3.2.1). Absent when nil.
VideoConfig []byte
// AudioConfig is the codec configuration (§2.3.3.1). Absent when nil.
AudioConfig []byte
// VideoFrameMarking carries the RFC 9626 frame-marking flags and
// layer identifiers (§2.3.2.2) as a 1-4 byte string. Absent when nil.
VideoFrameMarking []byte
AudioLevel uint8
HasTimestamp bool
HasTimescale bool
HasAudioLevel bool
// Extras carries KV pairs whose Type is not one of the well-known
// LOC properties. They round-trip verbatim. Extras MUST NOT contain
// any of the well-known LOC property IDs — use the typed fields
// instead. Append does not validate this; mixing the two leads to
// undefined ordering.
Extras []wire.KVPair
}
// Append serialises Properties as a flat sequence of KV pairs (no length
// prefix). The byte slice it produces (via [wire.Writer.Bytes]) goes
// directly into [message.SubgroupObject.Properties]; the
// SubgroupObject's own writer adds the outer length prefix.
func (p *Properties) Append(w *wire.Writer) {
pairs := p.toPairs()
w.KVPairs(pairs)
}
// Parse consumes KV pairs from r until r is empty and populates the
// fields of p. Any unknown property IDs land in Extras. Returns an
// error only if the wire data is malformed.
//
// Callers obtain a *wire.Reader bounded to the Properties bytes from
// the surrounding message — e.g. [message.SubgroupObject.Properties]
// is already the inner KV-pair blob with no length prefix.
func (p *Properties) Parse(r *wire.Reader) error {
pairs, err := r.KVPairsRemaining()
if err != nil {
return fmt.Errorf("moqt/loc: parsing properties: %w", err)
}
*p = Properties{}
for _, kv := range pairs {
switch kv.Type {
case PropTimestamp:
p.Timestamp = kv.IntVal
p.HasTimestamp = true
case PropTimescale:
p.Timescale = kv.IntVal
p.HasTimescale = true
case PropVideoFrameMarking:
p.VideoFrameMarking = kv.ByteVal
case PropAudioLevel:
if kv.IntVal > 0xFF {
return fmt.Errorf("moqt/loc: audio level %d exceeds 0xFF", kv.IntVal)
}
p.AudioLevel = uint8(kv.IntVal)
p.HasAudioLevel = true
case PropVideoConfig:
p.VideoConfig = kv.ByteVal
case PropAudioConfig:
p.AudioConfig = kv.ByteVal
default:
p.Extras = append(p.Extras, kv)
}
}
return nil
}
// Encode is a convenience that returns the serialised bytes ready to
// drop into [message.SubgroupObject.Properties].
func (p *Properties) Encode() []byte {
var w wire.Writer
p.Append(&w)
return w.Bytes()
}
// ParseProperties decodes a Properties value from raw KV-pair bytes
// (e.g. [message.SubgroupObject.Properties]). It is the inverse of
// [Properties.Encode].
func ParseProperties(raw []byte) (Properties, error) {
var p Properties
if len(raw) == 0 {
return p, nil
}
r := wire.NewReader(raw)
if err := p.Parse(r); err != nil {
return Properties{}, err
}
return p, nil
}
// toPairs collects the typed fields and Extras into a single KV slice.
// [wire.Writer.KVPairs] sorts by Type before encoding, so the order
// here does not matter.
func (p *Properties) toPairs() []wire.KVPair {
n := len(p.Extras)
if p.HasTimestamp {
n++
}
if p.HasTimescale {
n++
}
if p.VideoFrameMarking != nil {
n++
}
if p.HasAudioLevel {
n++
}
if p.VideoConfig != nil {
n++
}
if p.AudioConfig != nil {
n++
}
if n == 0 {
return nil
}
pairs := make([]wire.KVPair, 0, n)
if p.HasTimestamp {
pairs = append(pairs, wire.KVPair{Type: PropTimestamp, IntVal: p.Timestamp})
}
if p.HasTimescale {
pairs = append(pairs, wire.KVPair{Type: PropTimescale, IntVal: p.Timescale})
}
if p.VideoFrameMarking != nil {
pairs = append(pairs, wire.KVPair{Type: PropVideoFrameMarking, ByteVal: p.VideoFrameMarking})
}
if p.HasAudioLevel {
pairs = append(pairs, wire.KVPair{Type: PropAudioLevel, IntVal: uint64(p.AudioLevel)})
}
if p.VideoConfig != nil {
pairs = append(pairs, wire.KVPair{Type: PropVideoConfig, ByteVal: p.VideoConfig})
}
if p.AudioConfig != nil {
pairs = append(pairs, wire.KVPair{Type: PropAudioConfig, ByteVal: p.AudioConfig})
}
pairs = append(pairs, p.Extras...)
return pairs
}
package loc
import "encoding/binary"
// NALFraming describes how NAL units are delimited inside an
// AVC/HEVC LOC payload. See LOC §2.1.3 and §2.1.4.
type NALFraming int
const (
// NALFramingUnknown means the payload does not begin with a
// recognisable NAL framing (e.g. it is a non-NAL codec like AV1,
// or the buffer is too short to tell).
NALFramingUnknown NALFraming = iota
// NALFramingStartCode4 means the payload begins with the 4-byte
// AnnexB start code 0x00 0x00 0x00 0x01.
NALFramingStartCode4
// NALFramingStartCode3 means the payload begins with the 3-byte
// AnnexB start code 0x00 0x00 0x01. §2.1.4 permits this only when
// the track never uses length prefixes or Video Config.
NALFramingStartCode3
// NALFramingLengthPrefix means the payload begins with a 4-byte
// big-endian length followed by that many bytes of NAL unit data.
// §2.1.3: a length value of 1 SHOULD be interpreted as a start
// code rather than a length, so the length-prefix detector rejects
// that ambiguous case.
NALFramingLengthPrefix
)
// DetectNALFraming inspects the first bytes of a video payload and
// guesses how its NAL units are delimited. Detection is heuristic:
// the result is reliable only for AVC/HEVC payloads that start with a
// NAL unit. Returns [NALFramingUnknown] when the buffer does not
// match any of the three patterns or is shorter than 4 bytes.
//
// Detection order (matches §2.1.3's tie-breaker for length == 1):
// 1. The 4-byte AnnexB start code 0x00 0x00 0x00 0x01.
// 2. The 3-byte AnnexB start code 0x00 0x00 0x01.
// 3. A 4-byte length prefix whose value is > 1 and does not exceed
// the remaining payload length.
func DetectNALFraming(payload []byte) NALFraming {
if len(payload) < 3 {
return NALFramingUnknown
}
if len(payload) >= 4 && payload[0] == 0x00 && payload[1] == 0x00 &&
payload[2] == 0x00 && payload[3] == 0x01 {
return NALFramingStartCode4
}
if payload[0] == 0x00 && payload[1] == 0x00 && payload[2] == 0x01 {
return NALFramingStartCode3
}
if len(payload) < 4 {
return NALFramingUnknown
}
length := binary.BigEndian.Uint32(payload[:4])
if length <= 1 {
return NALFramingUnknown
}
if uint64(length)+4 > uint64(len(payload)) {
return NALFramingUnknown
}
return NALFramingLengthPrefix
}
package message
import (
"errors"
"fmt"
"github.com/floatdrop/moq-go/pkg/moqt/wire"
)
// Datagram type field bit constants (§11.3).
const (
DatagramPropertiesBit = 0x01 // Properties field present
DatagramEndOfGroupBit = 0x02 // End of group marker
DatagramZeroObjectIDBit = 0x04 // Object ID omitted (treated as 0)
DatagramDefaultPriorityBit = 0x08 // Priority omitted (use subscription default)
DatagramStatusBit = 0x20 // Object Status present instead of payload
)
// Valid datagram type ranges.
const (
DatagramTypeMin = 0x00
DatagramTypeMax = 0x0F
DatagramTypeStatusMin = 0x20
DatagramTypeStatusMax = 0x2F
)
// ObjectDatagram represents a MoQT object sent via QUIC datagram (§11.3).
type ObjectDatagram struct {
Type uint64 // Complex bit field
TrackAlias uint64
GroupID uint64
ObjectID uint64 // Optional based on ZERO_OBJECT_ID bit
PublisherPriority uint8 // Optional based on DEFAULT_PRIORITY bit
Properties []byte // Optional based on PROPERTIES bit
ObjectStatus uint64 // Optional based on STATUS bit
ObjectPayload []byte // Present when STATUS bit is 0
}
// IsValidDatagramType checks if a datagram type value is valid per §11.3.1
// Figure 23: 0x00..0x0F / 0x20..0x21 / 0x24..0x25 / 0x28..0x29 / 0x2C..0x2D.
//
// The two invalid classes MUST cause a session PROTOCOL_VIOLATION:
//
// - values outside the 0b00X0XXXX form (i.e. not 0x00..0x0F / 0x20..0x2F);
// - STATUS+END_OF_GROUP (0x22,0x23,0x26,0x27,0x2A,0x2B,0x2E,0x2F) — "an
// object status message cannot signal end of group".
//
// Note STATUS+PROPERTIES (0x21,0x25,0x29,0x2D) IS a valid type: it only
// becomes an error when the Object Status is not Normal (0x0) — a per-value
// rule enforced by [ObjectDatagram.Validate], not a type-level one.
func IsValidDatagramType(typ uint64) bool {
if typ > DatagramTypeStatusMax || (typ > DatagramTypeMax && typ < DatagramTypeStatusMin) {
return false
}
if typ&DatagramStatusBit != 0 && typ&DatagramEndOfGroupBit != 0 {
return false
}
return true
}
// HasProperties returns true if the PROPERTIES bit is set.
func (d *ObjectDatagram) HasProperties() bool {
return d.Type&DatagramPropertiesBit != 0
}
// HasEndOfGroup returns true if the END_OF_GROUP bit is set.
func (d *ObjectDatagram) HasEndOfGroup() bool {
return d.Type&DatagramEndOfGroupBit != 0
}
// HasZeroObjectID returns true if the ZERO_OBJECT_ID bit is set.
func (d *ObjectDatagram) HasZeroObjectID() bool {
return d.Type&DatagramZeroObjectIDBit != 0
}
// HasDefaultPriority returns true if the DEFAULT_PRIORITY bit is set.
func (d *ObjectDatagram) HasDefaultPriority() bool {
return d.Type&DatagramDefaultPriorityBit != 0
}
// HasStatus returns true if the STATUS bit is set.
func (d *ObjectDatagram) HasStatus() bool {
return d.Type&DatagramStatusBit != 0
}
// Validate checks if the datagram is valid according to MoQT spec §11.3.1.
// Every violation below is a session-level PROTOCOL_VIOLATION at the
// receiver.
func (d *ObjectDatagram) Validate() error {
if !IsValidDatagramType(d.Type) {
return fmt.Errorf("invalid datagram type: 0x%02X", d.Type)
}
// Per §11.3.1: PROPERTIES bit set with a Properties Length of 0 MUST
// close the session with a PROTOCOL_VIOLATION.
if d.HasProperties() && len(d.Properties) == 0 {
return errors.New("invalid datagram: PROPERTIES bit set with zero-length Properties")
}
if d.HasStatus() {
// §11.2.1.1: the defined Object Status values are Normal (0x0),
// End of Group (0x3), and End of Track (0x4); any other value
// SHOULD be treated as a protocol error. Matches the subgroup
// object codec's enforcement.
switch d.ObjectStatus {
case ObjectStatusNormal, ObjectStatusEndOfGroup, ObjectStatusEndOfTrack:
default:
return fmt.Errorf("invalid datagram: unknown object status 0x%X", d.ObjectStatus)
}
// §11.3.1: "If an Object Datagram includes both the STATUS bit and
// PROPERTIES bit, and the Object Status is not Normal (0x0), the
// endpoint MUST close the session with a PROTOCOL_VIOLATION,
// because only Normal Objects can have Properties."
if d.HasProperties() && d.ObjectStatus != ObjectStatusNormal {
return fmt.Errorf("invalid datagram: non-Normal status 0x%X with Properties", d.ObjectStatus)
}
}
return nil
}
// Append serializes the datagram to a wire.Writer.
func (d *ObjectDatagram) Append(w *wire.Writer) {
w.Varint(d.Type)
w.Varint(d.TrackAlias)
w.Varint(d.GroupID)
if !d.HasZeroObjectID() {
w.Varint(d.ObjectID)
}
if !d.HasDefaultPriority() {
w.UInt8(d.PublisherPriority)
}
if d.HasProperties() {
w.VarintBytes(d.Properties)
}
if d.HasStatus() {
w.Varint(d.ObjectStatus)
} else {
w.FixedBytes(d.ObjectPayload)
}
}
// Parse deserializes a datagram from a wire.Reader into d.
func (d *ObjectDatagram) Parse(r *wire.Reader) error {
typ, err := r.Varint()
if err != nil {
return fmt.Errorf("failed to read datagram type: %w", err)
}
d.Type = typ
// Validate the type before parsing fields — the layout depends on its
// bits. Rejects STATUS+END_OF_GROUP and out-of-form values (§11.3.1);
// per-value rules (e.g. non-Normal status with Properties) run in
// Validate once the fields are read.
if !IsValidDatagramType(d.Type) {
return fmt.Errorf("invalid datagram type: 0x%02X", d.Type)
}
d.TrackAlias, err = r.Varint()
if err != nil {
return fmt.Errorf("failed to read track alias: %w", err)
}
d.GroupID, err = r.Varint()
if err != nil {
return fmt.Errorf("failed to read group ID: %w", err)
}
if !d.HasZeroObjectID() {
d.ObjectID, err = r.Varint()
if err != nil {
return fmt.Errorf("failed to read object ID: %w", err)
}
} else {
d.ObjectID = 0
}
if !d.HasDefaultPriority() {
d.PublisherPriority, err = r.UInt8()
if err != nil {
return fmt.Errorf("failed to read publisher priority: %w", err)
}
}
if d.HasProperties() {
d.Properties, err = r.VarintBytes()
if err != nil {
return fmt.Errorf("failed to read properties: %w", err)
}
}
// Read ObjectStatus or ObjectPayload based on STATUS bit
if d.HasStatus() {
d.ObjectStatus, err = r.Varint()
if err != nil {
return fmt.Errorf("failed to read object status: %w", err)
}
// The status varint is the last field (§11.3.1 Figure 23); trailing
// bytes mean the sender and receiver disagree on the layout.
if !r.Empty() {
return fmt.Errorf("invalid datagram: %d trailing byte(s) after Object Status", r.Remaining())
}
} else {
d.ObjectPayload = r.RemainingBytes()
}
// Semantic checks (zero-length Properties, non-Normal status with
// Properties) live in Validate so parsing and standalone validation
// cannot drift.
return d.Validate()
}
package message
import (
"fmt"
"io"
"github.com/floatdrop/moq-go/pkg/moqt/wire"
)
// UnknownDataStreamTypeError is returned when the leading Type of an inbound
// data uni-stream is not one of the recognized data-stream types. The caller
// (typically session.AcceptDataStream) resets the underlying stream before
// surfacing this error so the accept loop can continue.
type UnknownDataStreamTypeError struct {
Type uint64
}
func (e *UnknownDataStreamTypeError) Error() string {
return fmt.Sprintf("moqt/message: unknown data stream type %#x", e.Type)
}
// ReservedSubgroupIDModeError is returned when the leading Type of an inbound
// data uni-stream matches the SUBGROUP_HEADER pattern (bit 4 set, bit 7 clear)
// but carries the reserved SUBGROUP_ID_MODE value 0b11 in bits 1-2. Per
// §11.4.2, this MUST be treated as a session-level PROTOCOL_VIOLATION — unlike
// a truly unknown stream type, which may be ignorable (GREASE).
type ReservedSubgroupIDModeError struct {
Type uint64
}
func (e *ReservedSubgroupIDModeError) Error() string {
return fmt.Sprintf(
"moqt/message: SUBGROUP_HEADER type %#x has reserved SUBGROUP_ID_MODE 0b11 — PROTOCOL_VIOLATION",
e.Type,
)
}
// ReadDataStreamType reads the leading Type varint that prefixes every MoQT
// uni-stream data header (SUBGROUP_HEADER §11.4.2, FETCH_HEADER §11.4.4,
// padding §11.5.1, ...). A dispatcher uses this together with type predicates
// such as IsSubgroupHeaderType to decide how to consume the remainder of the
// stream.
func ReadDataStreamType(r io.Reader) (uint64, error) {
typ, err := wire.ReadVarint(wire.NewByteReader(r))
if err != nil {
return 0, fmt.Errorf("moqt/message: read uni-stream type: %w", err)
}
return typ, nil
}
// PaddingStreamType is the leading Type varint of a padding uni-stream
// (§11.5.1). Receivers MUST silently discard padding streams.
const PaddingStreamType uint64 = 0x132B3E28
package message
import (
"fmt"
"io"
"github.com/floatdrop/moq-go/pkg/moqt/wire"
)
// Fetch is a FETCH message per §10.13.
//
// draft-20 removed the Fetch Type discriminant and with it the Joining
// variant: a FETCH now just names a track, and its range travels in the
// LOCATION_FILTER parameter (§5.1.2) like every other filter. The backfill
// that a Joining FETCH used to provide is now a fill fetch stream, requested
// with FILL_PARAMETERS on the SUBSCRIBE itself (§5.1.3).
//
// FETCH Message {
// Type (vi64) = 0x16,
// Length (16),
// Request ID (vi64),
// Track Namespace (..),
// Track Name Length (vi64),
// Track Name (..),
// Number of Parameters (vi64),
// Parameters (..) ...
// }
type Fetch struct {
RequestID uint64
Namespace wire.TrackNamespace
Name []byte
Parameters Parameters
}
// Append serializes the FETCH message to w.
func (m *Fetch) Append(w *wire.Writer) {
w.Varint(m.RequestID)
w.TrackNamespace(m.Namespace)
w.VarintBytes(m.Name)
m.Parameters.append(w)
}
// Parse deserializes the FETCH message from r.
func (m *Fetch) Parse(r *wire.Reader) error {
s := r.Scanner()
s.Varint(&m.RequestID)
s.TrackNamespace(&m.Namespace)
s.VarintBytes(&m.Name)
if err := s.Err(); err != nil {
return err
}
return m.Parameters.parse(r)
}
// Type returns the wire type ID for FETCH.
func (m *Fetch) Type() Type { return TypeFetch }
func (m *Fetch) GetRequestID() uint64 { return m.RequestID }
func (m *Fetch) SetRequestID(id uint64) { m.RequestID = id }
// validateFullTrackName enforces §2.4.1: "If an endpoint receives a Track
// Namespace or a Full Track Name exceeding 4,096 bytes, it MUST close the
// session with a PROTOCOL_VIOLATION." The namespace-only half is already
// enforced at parse time by wire.Reader.TrackNamespace; this adds the Track
// Name's length for messages that carry a full name.
func validateFullTrackName(ns wire.TrackNamespace, name []byte) error {
if total := ns.ByteLen() + len(name); total > wire.MaxFullTrackNameBytes {
return fmt.Errorf("moqt/message: full track name is %d bytes, max %d (§2.4.1)",
total, wire.MaxFullTrackNameBytes)
}
return nil
}
// Validate enforces the FETCH invariant the wire decoder cannot: §2.4.1's
// 4,096-byte cap applies to the full track name, not just the namespace.
// ParsePayload invokes this automatically after decoding a FETCH frame.
//
// The range is no longer a FETCH field in draft-20, so its validation lives
// on the LOCATION_FILTER parameter ([LocationFilter.Validate]).
func (m *Fetch) Validate() error {
return validateFullTrackName(m.Namespace, m.Name)
}
// FetchOK is a FETCH_OK message per §10.14.
type FetchOK struct {
EndOfTrack bool
EndLocation Location
Parameters Parameters
TrackProperties []byte
}
// Append serializes the FETCH_OK message to w.
func (m *FetchOK) Append(w *wire.Writer) {
if m.EndOfTrack {
w.UInt8(1)
} else {
w.UInt8(0)
}
w.Varint(m.EndLocation.Group)
w.Varint(m.EndLocation.Object)
m.Parameters.append(w)
w.FixedBytes(m.TrackProperties)
}
// Parse deserializes the FETCH_OK message from r.
func (m *FetchOK) Parse(r *wire.Reader) error {
s := r.Scanner()
var eot uint8
s.UInt8(&eot)
s.Varint(&m.EndLocation.Group)
s.Varint(&m.EndLocation.Object)
if err := s.Err(); err != nil {
return err
}
m.EndOfTrack = eot == 1
if err := m.Parameters.parse(r); err != nil {
return err
}
m.TrackProperties = r.RemainingBytes()
return nil
}
// Type returns the wire type ID for FETCH_OK.
func (m *FetchOK) Type() Type {
return TypeFetchOK
}
// FetchHeader is the header of a FETCH_HEADER stream (§11.4.4). It identifies
// which FETCH request this stream responds to.
type FetchHeader struct {
RequestID uint64
}
// RawType returns the leading Type varint as it appeared on the wire.
func (h FetchHeader) RawType() uint64 {
return 0x05
}
// WriteFetchHeader writes the FETCH_HEADER wire Type and Request ID.
func WriteFetchHeader(w io.Writer, h FetchHeader) error {
buf := wire.AppendVarint(nil, h.RawType())
buf = wire.AppendVarint(buf, h.RequestID)
_, err := w.Write(buf)
return err
}
// ReadFetchHeader reads a FETCH_HEADER from r. The caller must have already
// read the stream type (0x05) via ReadDataStreamType.
func ReadFetchHeader(r io.Reader) (FetchHeader, error) {
requestID, err := wire.ReadVarint(wire.NewByteReader(r))
if err != nil {
return FetchHeader{}, fmt.Errorf("moqt/message: read FETCH_HEADER Request ID: %w", err)
}
return FetchHeader{RequestID: requestID}, nil
}
// IsFetchHeaderType reports whether typ is a FETCH_HEADER stream type (0x05).
func IsFetchHeaderType(typ uint64) bool {
return typ == 0x05
}
package message
import (
"errors"
"fmt"
"io"
"github.com/floatdrop/moq-go/pkg/moqt/wire"
)
// FetchObject represents a single object in a FETCH response stream per §11.4.4.
type FetchObject struct {
// SerializationFlags control which fields are present and how they're encoded.
SerializationFlags uint64
// GroupIDDelta is the delta from the previous Group ID. Present when
// FetchFlagGroupIDDelta bit (0x08) is set.
GroupIDDelta uint64
// SubgroupID is encoded based on the two LSBs of SerializationFlags (mask 0x03).
// Only present on the wire when the mode is FetchSubgroupIDExplicit (0x03).
SubgroupID uint64
// ObjectIDDelta is the delta from the previous Object ID. Present when
// FetchFlagObjectIDDelta bit (0x04) is set.
ObjectIDDelta uint64
// PublisherPriority is present when FetchFlagPriority (0x10) is set.
PublisherPriority uint8
// Properties are present when FetchFlagProperties (0x20) is set.
Properties []byte
// ObjectPayload is always present, encoded on the wire with a varint
// length prefix (§11.4.4 Figure 27). FETCH objects carry no Object
// Status field (§11.2.1.1); absent ranges are expressed with the
// end-of-range markers instead.
ObjectPayload []byte
}
// Serialization flag bits per §11.4.4.1 (Table 8 & 9).
//
// Bits 0–1 (mask 0x03): Subgroup ID mode — see FetchSubgroupIDMode.
// Bit 2 (0x04): Object ID Delta present.
// Bit 3 (0x08): Group ID Delta present.
// Bit 4 (0x10): Priority field present.
// Bit 5 (0x20): Properties field present.
// Bit 6 (0x40): Datagram — no Subgroup ID; the subgroup-mode LSBs are ignored.
// Bit 7+ : reserved / end-of-range special values.
const (
FetchFlagSubgroupIDMode uint64 = 0x03 // bits 0–1: subgroup encoding mode
FetchFlagObjectIDDelta uint64 = 0x04 // bit 2: Object ID Delta present
FetchFlagGroupIDDelta uint64 = 0x08 // bit 3: Group ID Delta present
FetchFlagPriority uint64 = 0x10 // bit 4: Priority present
FetchFlagProperties uint64 = 0x20 // bit 5: Properties present
FetchFlagDatagram uint64 = 0x40 // bit 6: Datagram — ignore subgroup bits
)
// FetchSubgroupIDMode encodes how the Subgroup ID is determined (bits 0–1).
type FetchSubgroupIDMode uint8
const (
FetchSubgroupIDZero FetchSubgroupIDMode = 0x00 // Subgroup ID is zero
FetchSubgroupIDPrior FetchSubgroupIDMode = 0x01 // Subgroup ID = prior object's Subgroup ID
FetchSubgroupIDPriorPlusOne FetchSubgroupIDMode = 0x02 // Subgroup ID = prior + 1
FetchSubgroupIDExplicit FetchSubgroupIDMode = 0x03 // Subgroup ID field is present
)
// End of range markers per §11.4.4.2. Each stands for every Object between
// the previously serialized one and the Location this marker carries.
const (
FetchEndOfNonExistentRange = 0x8C // End of Non-Existent Range
FetchEndOfUnknownRange = 0x10C // End of Unknown Range
FetchEndOfTimedOutRange = 0x20C // End of Timed-Out Range (draft-20)
)
// isEndOfRange reports whether flags is any of the three §11.4.4.2 end-of-range
// markers, which share a wire shape: Group ID and Object ID follow the flags
// varint, and Subgroup ID, Priority and Properties are all absent.
func isEndOfRange(flags uint64) bool {
switch flags {
case FetchEndOfNonExistentRange, FetchEndOfUnknownRange, FetchEndOfTimedOutRange:
return true
}
return false
}
// Append serializes a FetchObject to w.
//
// For end-of-range markers (SerializationFlags == 0x8C, 0x10C or 0x20C), the spec
// requires Group ID and Object ID fields to follow the flags varint.
// For normal objects, the payload is length-prefixed (varint + bytes).
func (o *FetchObject) Append(w *wire.Writer) {
flags := o.SerializationFlags
// §11.4.4.1: when the Datagram bit is set the publisher "SHOULD set the
// two least significant bits to zero"; mask them so hand-built flag
// combinations stay conformant on the wire.
if flags&FetchFlagDatagram != 0 && flags < 128 {
flags &^= FetchFlagSubgroupIDMode
}
w.Varint(flags)
// End-of-range markers: Group ID and Object ID are always present (§11.4.4.2).
if isEndOfRange(o.SerializationFlags) {
w.Varint(o.GroupIDDelta) // used as absolute Group ID for end-of-range
w.Varint(o.ObjectIDDelta) // used as absolute Object ID for end-of-range
return
}
if o.SerializationFlags&FetchFlagGroupIDDelta != 0 {
w.Varint(o.GroupIDDelta)
}
if o.hasSubgroupIDField() {
w.Varint(o.SubgroupID)
}
if o.SerializationFlags&FetchFlagObjectIDDelta != 0 {
w.Varint(o.ObjectIDDelta)
}
if o.SerializationFlags&FetchFlagPriority != 0 {
w.UInt8(o.PublisherPriority)
}
if o.SerializationFlags&FetchFlagProperties != 0 {
w.VarintBytes(o.Properties)
}
// Object Payload Length (vi64) + Object Payload (..) per §11.4.4 Figure 27.
w.VarintBytes(o.ObjectPayload)
}
// truncated converts a bare io.EOF into io.ErrUnexpectedEOF. Parse reads
// after the leading flags varint use it: at that point part of an object has
// been consumed, so a FIN is a truncated object, not a clean end-of-stream.
// Callers (e.g. the relay's upstream stitcher) rely on that distinction to
// tell "the sender FIN'd between objects, vouching for the rest of the range
// (§11.4.4)" from "the response broke off mid-object".
func truncated(err error) error {
if errors.Is(err, io.EOF) {
return io.ErrUnexpectedEOF
}
return err
}
// Parse deserializes a FetchObject from r.
// r may be a *wire.Reader (in-memory) or a *wire.StreamReader (streaming).
func (o *FetchObject) Parse(r wire.Decoder) error {
flags, err := r.Varint()
if err != nil {
return err
}
o.SerializationFlags = flags
// End-of-range markers: Group ID and Object ID follow (§11.4.4.2).
if isEndOfRange(flags) {
groupID, err := r.Varint()
if err != nil {
return fmt.Errorf("moqt/message: end-of-range group ID: %w", truncated(err))
}
o.GroupIDDelta = groupID // stored in GroupIDDelta as absolute Group ID
objectID, err := r.Varint()
if err != nil {
return fmt.Errorf("moqt/message: end-of-range object ID: %w", truncated(err))
}
o.ObjectIDDelta = objectID // stored in ObjectIDDelta as absolute Object ID
return nil
}
if flags&FetchFlagGroupIDDelta != 0 {
delta, err := r.Varint()
if err != nil {
return truncated(err)
}
o.GroupIDDelta = delta
}
if o.hasSubgroupIDField() {
subgroupID, err := r.Varint()
if err != nil {
return truncated(err)
}
o.SubgroupID = subgroupID
}
if flags&FetchFlagObjectIDDelta != 0 {
delta, err := r.Varint()
if err != nil {
return truncated(err)
}
o.ObjectIDDelta = delta
}
if flags&FetchFlagPriority != 0 {
priority, err := r.UInt8()
if err != nil {
return truncated(err)
}
o.PublisherPriority = priority
}
if flags&FetchFlagProperties != 0 {
props, err := r.VarintBytes()
if err != nil {
return truncated(err)
}
o.Properties = props
}
// Object Payload Length (vi64) + Object Payload (..) per §11.4.4 Figure 27.
payload, err := r.VarintBytes()
if err != nil {
return truncated(err)
}
o.ObjectPayload = payload
return nil
}
// IsEndOfNonExistentRange reports whether this is an End of Non-Existent Range
// marker (0x8C): the Objects it covers are known not to exist.
func (o *FetchObject) IsEndOfNonExistentRange() bool {
return o.SerializationFlags == FetchEndOfNonExistentRange
}
// IsEndOfUnknownRange reports whether this is an End of Unknown Range marker
// (0x10C): no source could vouch for the Objects it covers either way.
func (o *FetchObject) IsEndOfUnknownRange() bool {
return o.SerializationFlags == FetchEndOfUnknownRange
}
// IsEndOfTimedOutRange reports whether this is an End of Timed-Out Range marker
// (0x20C): the Objects it covers were abandoned when FILL_TIMEOUT expired
// (§10.2.5), as opposed to being known absent (0x8C) or of unknown status
// (0x10C).
func (o *FetchObject) IsEndOfTimedOutRange() bool {
return o.SerializationFlags == FetchEndOfTimedOutRange
}
// IsEndOfRange reports whether this is any §11.4.4.2 end-of-range marker rather
// than a serialized Object.
func (o *FetchObject) IsEndOfRange() bool { return isEndOfRange(o.SerializationFlags) }
// IsDatagram reports whether the Datagram bit (0x40) is set: the object was
// published with Forwarding Preference "Datagram" and carries no Subgroup ID.
func (o *FetchObject) IsDatagram() bool {
return o.SerializationFlags&FetchFlagDatagram != 0
}
// hasSubgroupIDField reports whether a Subgroup ID field is present on the
// wire: the subgroup mode must be Explicit AND the Datagram bit must be
// clear — §11.4.4.1 Table 9 says 0x40 means "ignore the two least
// significant bits". Shared by Append and Parse so the encoder and decoder
// cannot disagree on field presence.
func (o *FetchObject) hasSubgroupIDField() bool {
return o.SubgroupMode() == FetchSubgroupIDExplicit && !o.IsDatagram()
}
// SubgroupMode returns the subgroup ID encoding mode from the two LSBs.
func (o *FetchObject) SubgroupMode() FetchSubgroupIDMode {
return FetchSubgroupIDMode(o.SerializationFlags & FetchFlagSubgroupIDMode)
}
// Validate checks the fetch object for protocol violations.
func (o *FetchObject) Validate() error {
flags := o.SerializationFlags
// End-of-range markers are always valid structurally.
if isEndOfRange(flags) {
return nil
}
// Values >= 128 that are not end-of-range markers are PROTOCOL_VIOLATION.
// Note: 0x40 with non-zero subgroup-mode LSBs stays valid — the publisher
// only SHOULD zero them and the subscriber MUST ignore them (§11.4.4.1),
// so rejecting the combination would itself be non-conformant.
if flags >= 128 {
return fmt.Errorf("moqt/message: fetch object has invalid serialization flags 0x%X", flags)
}
return nil
}
package message
import (
"fmt"
"slices"
"github.com/floatdrop/moq-go/pkg/moqt/wire"
)
// fillParamsAllowed is Table 6 of §10.2.15: the only parameters that may
// appear inside FILL_PARAMETERS. Note TRACK_PROPERTY_FILTER (0x29) is absent —
// a fill is scoped to Objects, so only the Object-scoped filters carry over.
var fillParamsAllowed = []ParamID{
ParamFillTimeout,
ParamSubscriberPriority,
ParamLocationFilter,
ParamGroupOrder,
ParamSubgroupFilter,
ParamObjectIDFilter,
ParamPriorityFilter,
ParamObjectPropertyFilter,
}
// FillParametersParam builds FILL_PARAMETERS (§10.2.15) from the parameters
// that apply to the fill fetch stream. Its presence on a SUBSCRIBE or
// REQUEST_UPDATE is what asks the publisher to open a fill fetch stream
// (§5.1.3) — an empty inner list still requests one, filling the whole track
// up to Largest Object.
//
// The value is a nested parameter sequence: it is a separate parameter scope,
// so a type may appear both here and in the enclosing message (§10.2.15).
func FillParametersParam(inner Parameters) Parameter {
var w wire.Writer
inner.append(&w)
return BytesParam(ParamFillParameters, w.Bytes())
}
// FillParametersFromParam extracts and parses FILL_PARAMETERS from a parameter
// list. ok is false when the parameter is absent, which per §5.1.3 means no
// fill fetch stream is requested — distinct from a present-but-empty list.
//
// An inner parameter outside Table 6 is an error the caller MUST map to a
// session-level PROTOCOL_VIOLATION (§10.2.15).
func FillParametersFromParam(ps Parameters) (inner Parameters, ok bool, err error) {
p, found := ps.Find(ParamFillParameters)
if !found {
return nil, false, nil
}
if err := inner.parse(wire.NewReader(p.Bytes)); err != nil {
return nil, true, fmt.Errorf("moqt/message: FILL_PARAMETERS: %w", err)
}
for _, ip := range inner {
if !slices.Contains(fillParamsAllowed, ip.Type) {
return nil, true, fmt.Errorf(
"moqt/message: %s not allowed inside FILL_PARAMETERS (PROTOCOL_VIOLATION §10.2.15)", ip.Type)
}
}
return inner, true, nil
}
// IncludePropertiesParam builds INCLUDE_PROPERTIES (§10.2.21): whether the
// response should carry Track Properties. The default is 1, so this is only
// worth sending to suppress them.
func IncludePropertiesParam(include bool) Parameter {
var v uint8
if include {
v = 1
}
return ByteParam(ParamIncludeProperties, v)
}
// IncludePropertiesFromParam reads INCLUDE_PROPERTIES (§10.2.21) from a
// parameter list, defaulting to true when absent. A value outside {0, 1} is an
// error the caller MUST map to a session-level PROTOCOL_VIOLATION.
func IncludePropertiesFromParam(ps Parameters) (bool, error) {
p, ok := ps.Find(ParamIncludeProperties)
if !ok {
return true, nil
}
switch p.Byte {
case 0:
return false, nil
case 1:
return true, nil
default:
return false, fmt.Errorf(
"moqt/message: INCLUDE_PROPERTIES value %d outside {0,1} (PROTOCOL_VIOLATION §10.2.21)", p.Byte)
}
}
package message
import (
"fmt"
"math"
"github.com/floatdrop/moq-go/pkg/moqt/wire"
)
// LocationFilter is the LOCATION_FILTER parameter value from §5.1.2.
//
// Wire format (the enclosing parameter is length-prefixed, and that Length is
// what selects how many of the four optional fields are present):
//
// LOCATION_FILTER Parameter {
// Parameter Type (vi64) = 0x21,
// Length (vi64),
// [StartGroup (vi64),]
// [StartObject (vi64),]
// [EndGroupDelta (vi64),]
// [EndObject (vi64),]
// }
//
// draft-20 replaced draft-19's Filter Type enum (NextGroupStart /
// LargestObject / AbsoluteStart / AbsoluteRange) with this positional
// encoding, so the field count *is* the discriminant:
//
// 0 fields unfiltered — and, in REQUEST_UPDATE, removes the filter
// 1 field StartGroup is RELATIVE: start = {Largest.Group + 1 - StartGroup, 0}
// 2 fields {0,0} means the Next Object; otherwise an absolute start
// 3 fields absolute start, end group = StartGroup + EndGroupDelta
// 4 fields ...plus an explicit last Object in the end group
//
// The range is inclusive at both ends. An omitted end is open-ended on a
// subscription and means Largest Object on a Fetch (§5.1.2).
type LocationFilter struct {
// Fields is how many of the four optional vi64s were on the wire (0-4).
// It selects the interpretation of the rest, so it is part of the value
// rather than a decoding artifact.
Fields int
StartGroup uint64
StartObject uint64
EndGroupDelta uint64
EndObject uint64
}
// Unfiltered reports whether the filter selects the whole track (no fields).
func (f *LocationFilter) Unfiltered() bool { return f.Fields == 0 }
// RelativeStart reports whether StartGroup counts back from the Next Group
// rather than naming an absolute Group (the one-field form).
func (f *LocationFilter) RelativeStart() bool { return f.Fields == 1 }
// NextObject reports whether the filter starts at the Object after Largest
// Object — the two-field all-zero form, draft-19's LargestObject filter.
func (f *LocationFilter) NextObject() bool {
return f.Fields == 2 && f.StartGroup == 0 && f.StartObject == 0
}
// HasEnd reports whether the filter bounds the end of the range.
func (f *LocationFilter) HasEnd() bool { return f.Fields >= 3 }
// HasEndObject reports whether the filter names a last Object in the end
// Group. When false but HasEnd is true, every Object in the end Group passes.
func (f *LocationFilter) HasEndObject() bool { return f.Fields == 4 }
// Validate enforces the §5.1.2 rules the decoder cannot: a field count in
// range, and the end-group sum staying inside the 64-bit Group space ("If
// StartGroup + EndGroupDelta exceeds 2^64 - 1, the endpoint MUST close the
// session with a PROTOCOL_VIOLATION"). Callers map a non-nil error to
// PROTOCOL_VIOLATION.
//
// Note the asymmetry with a relative start, which §5.1.2 clamps rather than
// rejects — see [LocationFilter.Start].
func (f *LocationFilter) Validate() error {
if f.Fields < 0 || f.Fields > 4 {
return fmt.Errorf("moqt/message: LOCATION_FILTER has %d fields, want 0-4 (§5.1.2)", f.Fields)
}
if f.HasEnd() && f.StartGroup > math.MaxUint64-f.EndGroupDelta {
return fmt.Errorf(
"moqt/message: LOCATION_FILTER end group overflow (start=%d delta=%d) (PROTOCOL_VIOLATION §5.1.2)",
f.StartGroup, f.EndGroupDelta)
}
return nil
}
// Start resolves the first Location that passes the filter, given the
// publisher's current Largest Object. hasLargest is false before anything has
// been published on the track, which §5.1.2 pins to {0, 0}.
//
// A relative StartGroup is clamped, not rejected (§5.1.2): a computed absolute
// group below 0 is set to 0, and one above 2^64 - 1 is set to 2^64 - 1.
func (f *LocationFilter) Start(largest Location, hasLargest bool) Location {
switch {
case f.Unfiltered():
return Location{}
case f.RelativeStart():
// {Largest.Group + 1 - StartGroup, 0}, clamped at both ends.
if !hasLargest {
return Location{}
}
if f.StartGroup > largest.Group {
// Largest.Group + 1 - StartGroup would go below 0.
return Location{}
}
if largest.Group == math.MaxUint64 && f.StartGroup == 0 {
// Largest.Group + 1 would exceed 2^64 - 1.
return Location{Group: math.MaxUint64}
}
return Location{Group: largest.Group + 1 - f.StartGroup}
case f.NextObject():
if !hasLargest {
return Location{}
}
if largest.Object == math.MaxUint64 {
// No Object can follow it within this Group.
return Location{Group: largest.Group, Object: math.MaxUint64}
}
return Location{Group: largest.Group, Object: largest.Object + 1}
default:
return Location{Group: f.StartGroup, Object: f.StartObject}
}
}
// End resolves the last Location that passes the filter. ok is false when the
// filter is open-ended, which on a subscription means "no end" and on a Fetch
// means Largest Object (§5.1.2) — a distinction the caller owns.
//
// Call Validate first: an unvalidated end-group sum can wrap.
func (f *LocationFilter) End() (loc Location, ok bool) {
if !f.HasEnd() {
return Location{}, false
}
end := Location{Group: f.StartGroup + f.EndGroupDelta, Object: math.MaxUint64}
if f.HasEndObject() {
end.Object = f.EndObject
}
return end, true
}
// Matches reports whether the Object at loc passes this filter on a
// subscription, given the publisher's Largest Object. Both ends are inclusive
// and an absent end is open-ended (§5.1.2).
func (f *LocationFilter) Matches(loc Location, largest Location, hasLargest bool) bool {
if loc.Less(f.Start(largest, hasLargest)) {
return false
}
if end, ok := f.End(); ok && end.Less(loc) {
return false
}
return true
}
// Append serialises the filter's fields to w. The caller writes the enclosing
// parameter's Length, which is what tells the peer how many fields follow.
func (f *LocationFilter) Append(w *wire.Writer) {
if f.Fields >= 1 {
w.Varint(f.StartGroup)
}
if f.Fields >= 2 {
w.Varint(f.StartObject)
}
if f.Fields >= 3 {
w.Varint(f.EndGroupDelta)
}
if f.Fields >= 4 {
w.Varint(f.EndObject)
}
}
// Parse deserialises a filter from r, consuming every remaining byte: r must
// be bounded to the LOCATION_FILTER parameter's value, since the byte count is
// the only thing that distinguishes the five forms (§5.1.2).
func (f *LocationFilter) Parse(r *wire.Reader) error {
*f = LocationFilter{}
dst := [4]*uint64{&f.StartGroup, &f.StartObject, &f.EndGroupDelta, &f.EndObject}
for !r.Empty() {
if f.Fields == len(dst) {
return fmt.Errorf("moqt/message: LOCATION_FILTER has %d trailing bytes after 4 fields (§5.1.2)",
r.Remaining())
}
v, err := r.Varint()
if err != nil {
return fmt.Errorf("moqt/message: LOCATION_FILTER field %d: %w", f.Fields, err)
}
*dst[f.Fields] = v
f.Fields++
}
return f.Validate()
}
// Bytes serialises the filter's fields to a fresh slice, for use as the
// LOCATION_FILTER parameter value.
func (f *LocationFilter) Bytes() []byte {
var w wire.Writer
f.Append(&w)
return w.Bytes()
}
// ParseLocationFilter deserialises a LocationFilter from a LOCATION_FILTER
// parameter value.
func ParseLocationFilter(raw []byte) (*LocationFilter, error) {
f := &LocationFilter{}
if err := f.Parse(wire.NewReader(raw)); err != nil {
return nil, err
}
return f, nil
}
// UnfilteredFilter returns a zero-length LOCATION_FILTER parameter (§5.1.2):
// the whole track. On REQUEST_UPDATE it removes an existing filter.
func UnfilteredFilter() Parameter {
return LocationFilterParam(&LocationFilter{})
}
// NextObjectFilter returns a LOCATION_FILTER parameter (§5.1.2) starting at
// the Object after the publisher's Largest Object — the live edge, and the
// filter to pair with a fill so each Object arrives exactly once (§5.1.3).
//
// This is draft-19's LargestObject filter.
func NextObjectFilter() Parameter {
return LocationFilterParam(&LocationFilter{Fields: 2})
}
// RelativeStartFilter returns an open-ended LOCATION_FILTER parameter (§5.1.2)
// starting groupsBack groups before the Next Group: 0 is the Next Group (which
// is draft-19's NextGroupStart filter), 1 the current group, N the group N-1
// before the current one.
func RelativeStartFilter(groupsBack uint64) Parameter {
return LocationFilterParam(&LocationFilter{Fields: 1, StartGroup: groupsBack})
}
// AbsoluteStartFilter returns an open-ended LOCATION_FILTER parameter (§5.1.2)
// starting at an explicit Location. A start of {0, 0} is equivalent to
// unfiltered, and is encoded that way — the two-field all-zero form is the
// Next Object filter, not an absolute {0, 0}.
func AbsoluteStartFilter(start Location) Parameter {
if start == (Location{}) {
return UnfilteredFilter()
}
return LocationFilterParam(&LocationFilter{
Fields: 2,
StartGroup: start.Group,
StartObject: start.Object,
})
}
// AbsoluteRangeFilter returns a LOCATION_FILTER parameter (§5.1.2) covering
// start through the end of group (start.Group + endGroupDelta), inclusive.
func AbsoluteRangeFilter(start Location, endGroupDelta uint64) Parameter {
return LocationFilterParam(&LocationFilter{
Fields: 3,
StartGroup: start.Group,
StartObject: start.Object,
EndGroupDelta: endGroupDelta,
})
}
// AbsoluteRangeObjectFilter returns a LOCATION_FILTER parameter (§5.1.2)
// covering the inclusive range start..{start.Group + endGroupDelta, endObject}.
func AbsoluteRangeObjectFilter(start Location, endGroupDelta, endObject uint64) Parameter {
return LocationFilterParam(&LocationFilter{
Fields: 4,
StartGroup: start.Group,
StartObject: start.Object,
EndGroupDelta: endGroupDelta,
EndObject: endObject,
})
}
package message
import (
"fmt"
"github.com/floatdrop/moq-go/pkg/moqt/wire"
)
// MaxGoawayURIBytes is the maximum New Session URI length per §10.4.
const MaxGoawayURIBytes = 8192
// Goaway is the GOAWAY message (§10.4).
type Goaway struct {
NewSessionURI []byte
Timeout uint64
}
func (m *Goaway) Type() Type { return TypeGoaway }
func (m *Goaway) Append(w *wire.Writer) {
w.VarintBytes(m.NewSessionURI)
w.Varint(m.Timeout)
}
func (m *Goaway) Parse(r *wire.Reader) error {
s := r.Scanner()
s.VarintBytes(&m.NewSessionURI)
if err := s.Err(); err != nil {
return err
}
if len(m.NewSessionURI) > MaxGoawayURIBytes {
return fmt.Errorf("moqt/message: GOAWAY URI length %d exceeds %d", len(m.NewSessionURI), MaxGoawayURIBytes)
}
s.Varint(&m.Timeout)
if err := s.Err(); err != nil {
return err
}
return nil
}
package message
import (
"math/rand/v2"
"github.com/floatdrop/moq-go/pkg/moqt/wire"
)
// GREASE (Generate Random Extensions And Sustain Extensibility) support per
// §14 of draft-ietf-moq-transport-20 and RFC 9170 §3.3.
//
// GREASE values follow the pattern 0x7F * N + 0x9D for non-negative integer
// values of N (that is, 0x9D, 0x11C, 0x19B, ..., 0x3FFFFFFFFFFFFFDE).
//
// Implementations SHOULD send GREASE values in extensible fields to exercise
// recipient tolerance. Recipients MUST ignore unknown values and MUST NOT
// close the session solely because they received one.
// greaseBase and greaseStep define the GREASE value pattern: base + step*N.
const (
greaseBase uint64 = 0x9D
greaseStep uint64 = 0x7F
)
// maxGreaseN is the largest N that fits in a 62-bit QUIC varint
// (max varint = 2^62 - 1 = 0x3FFFFFFFFFFFFFFF).
// 0x7F * N + 0x9D ≤ 0x3FFFFFFFFFFFFFFF → N ≤ (0x3FFFFFFFFFFFFFFF - 0x9D) / 0x7F.
const maxGreaseN uint64 = (0x3FFFFFFFFFFFFFFF - greaseBase) / greaseStep
// GreaseValue returns a random GREASE value from the reserved range. The
// returned value is suitable for use as a Setup Option type, Property type,
// or error code. Each call returns a fresh random value.
func GreaseValue() uint64 {
//nolint:gosec // G404: GREASE values are deliberately non-cryptographic (§1.4.3); randomness only spreads coverage.
n := rand.Uint64N(maxGreaseN + 1)
return greaseBase + greaseStep*n
}
// GreaseSetupOption returns a KVPair with a random GREASE type suitable for
// inclusion in a SETUP message's option list. Per §1.4.3, even types carry a
// varint value and odd types carry bytes; the GREASE pattern produces both
// parities, so the helper picks a value and fills the appropriate field with
// a small random payload.
func GreaseSetupOption() wire.KVPair {
v := GreaseValue()
kv := wire.KVPair{Type: v}
if kv.IsBytes() {
// Odd type → length-prefixed bytes. Send a small random payload.
kv.ByteVal = []byte{byte(rand.UintN(256))} //nolint:gosec // G404: non-cryptographic GREASE payload by design.
} else {
// Even type → varint. Send a small random value.
kv.IntVal = rand.Uint64N(256) //nolint:gosec // G404: non-cryptographic GREASE payload by design.
}
return kv
}
package message
import "cmp"
// Location represents a track location per §1.4.2.
type Location struct {
Group uint64
Object uint64
}
// Compare returns -1, 0, or +1 according to whether l sorts before, equal
// to, or after other in the (Group, Object) lexicographic order. This is
// the total order MoQT uses for §10.2.17 (LARGEST_OBJECT monotonicity),
// §11.2 (intra-track Object ordering), and Fetch/Cache range scans
// (§5.1.2).
//
// The signature matches [cmp.Compare] so callers can pass
// Location.Compare directly to [slices.SortFunc] and
// [slices.BinarySearchFunc].
func (l Location) Compare(other Location) int {
return cmp.Or(
cmp.Compare(l.Group, other.Group),
cmp.Compare(l.Object, other.Object),
)
}
// Less reports whether l comes strictly before other in the (Group, Object)
// order described on [Location.Compare].
func (l Location) Less(other Location) bool { return l.Compare(other) < 0 }
package message
import "github.com/floatdrop/moq-go/pkg/moqt/wire"
// PublishNamespace is the PUBLISH_NAMESPACE message (§10.16). It announces
// that the publisher will publish tracks within a namespace.
type PublishNamespace struct {
RequestID uint64
Namespace wire.TrackNamespace
Parameters Parameters
}
// Type returns the wire type ID for PUBLISH_NAMESPACE.
func (m *PublishNamespace) Type() Type { return TypePublishNamespace }
func (m *PublishNamespace) GetRequestID() uint64 { return m.RequestID }
func (m *PublishNamespace) SetRequestID(id uint64) { m.RequestID = id }
// Append serializes the PUBLISH_NAMESPACE message to w.
func (m *PublishNamespace) Append(w *wire.Writer) {
w.Varint(m.RequestID)
w.TrackNamespace(m.Namespace)
m.Parameters.append(w)
}
// Parse deserializes the PUBLISH_NAMESPACE message from r.
func (m *PublishNamespace) Parse(r *wire.Reader) error {
s := r.Scanner()
s.Varint(&m.RequestID)
s.TrackNamespace(&m.Namespace)
if err := s.Err(); err != nil {
return err
}
return m.Parameters.parse(r)
}
// Namespace is the NAMESPACE message (§10.17). It announces a track
// namespace suffix on a PUBLISH_NAMESPACE or SUBSCRIBE_NAMESPACE request stream.
type Namespace struct {
TrackNamespaceSuffix wire.TrackNamespace
}
// Type returns the wire type ID for NAMESPACE.
func (m *Namespace) Type() Type {
return TypeNamespace
}
// Append serializes the NAMESPACE message to w.
func (m *Namespace) Append(w *wire.Writer) {
w.TrackNamespace(m.TrackNamespaceSuffix)
}
// Parse deserializes the NAMESPACE message from r.
func (m *Namespace) Parse(r *wire.Reader) error {
s := r.Scanner()
s.TrackNamespace(&m.TrackNamespaceSuffix)
return s.Err()
}
// NamespaceDone is the NAMESPACE_DONE message (§10.18). It signals that
// no more tracks will be published within a namespace.
type NamespaceDone struct {
TrackNamespaceSuffix wire.TrackNamespace
}
// Type returns the wire type ID for NAMESPACE_DONE.
func (m *NamespaceDone) Type() Type {
return TypeNamespaceDone
}
// Append serializes the NAMESPACE_DONE message to w.
func (m *NamespaceDone) Append(w *wire.Writer) {
w.TrackNamespace(m.TrackNamespaceSuffix)
}
// Parse deserializes the NAMESPACE_DONE message from r.
func (m *NamespaceDone) Parse(r *wire.Reader) error {
s := r.Scanner()
s.TrackNamespace(&m.TrackNamespaceSuffix)
return s.Err()
}
// SubscribeNamespace is the SUBSCRIBE_NAMESPACE message (§10.19). It
// subscribes to all tracks within a namespace prefix.
type SubscribeNamespace struct {
RequestID uint64
TrackNamespacePrefix wire.TrackNamespace
Parameters Parameters
}
// Type returns the wire type ID for SUBSCRIBE_NAMESPACE.
func (m *SubscribeNamespace) Type() Type { return TypeSubscribeNamespace }
func (m *SubscribeNamespace) GetRequestID() uint64 { return m.RequestID }
func (m *SubscribeNamespace) SetRequestID(id uint64) { m.RequestID = id }
// Append serializes the SUBSCRIBE_NAMESPACE message to w.
func (m *SubscribeNamespace) Append(w *wire.Writer) {
w.Varint(m.RequestID)
w.TrackNamespace(m.TrackNamespacePrefix)
m.Parameters.append(w)
}
// Parse deserializes the SUBSCRIBE_NAMESPACE message from r.
func (m *SubscribeNamespace) Parse(r *wire.Reader) error {
s := r.Scanner()
s.Varint(&m.RequestID)
s.TrackNamespace(&m.TrackNamespacePrefix)
if err := s.Err(); err != nil {
return err
}
return m.Parameters.parse(r)
}
// SubscribeTracks is the SUBSCRIBE_TRACKS message (§10.20). It subscribes
// to all tracks within a namespace prefix.
type SubscribeTracks struct {
RequestID uint64
TrackNamespacePrefix wire.TrackNamespace
Parameters Parameters
}
// Type returns the wire type ID for SUBSCRIBE_TRACKS.
func (m *SubscribeTracks) Type() Type { return TypeSubscribeTracks }
func (m *SubscribeTracks) GetRequestID() uint64 { return m.RequestID }
func (m *SubscribeTracks) SetRequestID(id uint64) { m.RequestID = id }
// Append serializes the SUBSCRIBE_TRACKS message to w.
func (m *SubscribeTracks) Append(w *wire.Writer) {
w.Varint(m.RequestID)
w.TrackNamespace(m.TrackNamespacePrefix)
m.Parameters.append(w)
}
// Parse deserializes the SUBSCRIBE_TRACKS message from r.
func (m *SubscribeTracks) Parse(r *wire.Reader) error {
s := r.Scanner()
s.Varint(&m.RequestID)
s.TrackNamespace(&m.TrackNamespacePrefix)
if err := s.Err(); err != nil {
return err
}
return m.Parameters.parse(r)
}
// PublishSkipped is the PUBLISH_SKIPPED message (§10.21). It signals that a
// specific track's Subscription was not created for this SUBSCRIBE_TRACKS.
type PublishSkipped struct {
TrackNamespaceSuffix wire.TrackNamespace
TrackName []byte
}
// Type returns the wire type ID for PUBLISH_SKIPPED.
func (m *PublishSkipped) Type() Type {
return TypePublishSkipped
}
// Append serializes the PUBLISH_SKIPPED message to w.
func (m *PublishSkipped) Append(w *wire.Writer) {
w.TrackNamespace(m.TrackNamespaceSuffix)
w.VarintBytes(m.TrackName)
}
// Parse deserializes the PUBLISH_SKIPPED message from r.
func (m *PublishSkipped) Parse(r *wire.Reader) error {
s := r.Scanner()
s.TrackNamespace(&m.TrackNamespaceSuffix)
s.VarintBytes(&m.TrackName)
return s.Err()
}
package message
import (
"cmp"
"errors"
"fmt"
"slices"
"time"
"github.com/floatdrop/moq-go/pkg/moqt/wire"
)
// ParamID is a MoQT Message Parameter type ID (§10.2). Distinct from
// SetupOption because the two code spaces overlap (parameter 0x03 vs option
// 0x03 are both AUTHORIZATION_TOKEN but in different contexts with different
// parse rules) and from session/request error codes which overlap numerically.
type ParamID uint64
// Parameter wire type IDs from §10.2.
const (
ParamObjectDeliveryTimeout ParamID = 0x02
ParamAuthorizationToken ParamID = 0x03
ParamRendezvousTimeout ParamID = 0x04
ParamSubgroupDeliveryTimeout ParamID = 0x06
ParamExpires ParamID = 0x08
ParamLargestObject ParamID = 0x09
ParamFillTimeout ParamID = 0x0A
ParamForward ParamID = 0x10
ParamSubscriberPriority ParamID = 0x20
ParamLocationFilter ParamID = 0x21
ParamGroupOrder ParamID = 0x22
ParamFillParameters ParamID = 0x23
// Range Filter parameters (§5.1.4, §10.2.10-14). All five carry a
// length-prefixed blob (SetID, optional Property Type, delta-encoded
// Ranges) — see rangefilter.go. NOTE: 0x26/0x28 are even, so under the
// §1.4.3 KV-pair rule they would carry a bare varint with no Length; but
// §5.1.4's figure shows a Length on all five, and this codebase encodes
// parameters by a per-type Kind (paramKinds), not by §1.4.3 parity — so all
// five register as KindBytes (length-prefixed).
ParamSubgroupFilter ParamID = 0x25
ParamObjectIDFilter ParamID = 0x26
ParamPriorityFilter ParamID = 0x27
ParamObjectPropertyFilter ParamID = 0x28
ParamTrackPropertyFilter ParamID = 0x29
ParamNewGroupRequest ParamID = 0x32
ParamTrackNamespacePrefix ParamID = 0x34
ParamIncludeProperties ParamID = 0x35
)
// String returns a short name for known parameter types; unknown values render
// as hex.
func (p ParamID) String() string {
switch p {
case ParamObjectDeliveryTimeout:
return "OBJECT_DELIVERY_TIMEOUT"
case ParamAuthorizationToken:
return "AUTHORIZATION_TOKEN"
case ParamRendezvousTimeout:
return "RENDEZVOUS_TIMEOUT"
case ParamSubgroupDeliveryTimeout:
return "SUBGROUP_DELIVERY_TIMEOUT"
case ParamExpires:
return "EXPIRES"
case ParamLargestObject:
return "LARGEST_OBJECT"
case ParamFillTimeout:
return "FILL_TIMEOUT"
case ParamForward:
return "FORWARD"
case ParamSubscriberPriority:
return "SUBSCRIBER_PRIORITY"
case ParamLocationFilter:
return "LOCATION_FILTER"
case ParamGroupOrder:
return "GROUP_ORDER"
case ParamFillParameters:
return "FILL_PARAMETERS"
case ParamSubgroupFilter:
return "SUBGROUP_FILTER"
case ParamObjectIDFilter:
return "OBJECTID_FILTER"
case ParamPriorityFilter:
return "PRIORITY_FILTER"
case ParamObjectPropertyFilter:
return "OBJECT_PROPERTY_FILTER"
case ParamTrackPropertyFilter:
return "TRACK_PROPERTY_FILTER"
case ParamNewGroupRequest:
return "NEW_GROUP_REQUEST"
case ParamTrackNamespacePrefix:
return "TRACK_NAMESPACE_PREFIX"
case ParamIncludeProperties:
return "INCLUDE_PROPERTIES"
}
return fmt.Sprintf("ParamID(%#x)", uint64(p))
}
// ParamKind describes a parameter's value encoding (§10.2).
type ParamKind uint8
const (
// kindUnset is the zero value: the value kind was never set, i.e. the
// Parameter was built as a bare struct literal rather than via a
// constructor or parse. appendParamValue panics on it instead of silently
// emitting a varint, so the mistake surfaces immediately.
kindUnset ParamKind = iota
KindVarint // single varint
KindByte // single byte (uint8)
KindBytes // varint-length-prefixed bytes
KindLocation // two varints: Group, Object
)
var paramKinds = map[ParamID]ParamKind{
ParamObjectDeliveryTimeout: KindVarint,
ParamAuthorizationToken: KindBytes,
ParamRendezvousTimeout: KindVarint,
ParamSubgroupDeliveryTimeout: KindVarint,
ParamExpires: KindVarint,
ParamLargestObject: KindLocation,
ParamFillTimeout: KindVarint,
ParamForward: KindByte,
ParamSubscriberPriority: KindByte,
ParamLocationFilter: KindBytes,
ParamGroupOrder: KindByte,
ParamFillParameters: KindBytes,
ParamSubgroupFilter: KindBytes,
ParamObjectIDFilter: KindBytes,
ParamPriorityFilter: KindBytes,
ParamObjectPropertyFilter: KindBytes,
ParamTrackPropertyFilter: KindBytes,
ParamNewGroupRequest: KindVarint,
ParamTrackNamespacePrefix: KindBytes,
ParamIncludeProperties: KindByte,
}
// kindOf returns the registered kind for a parameter type, or an error if the
// type is unknown. Unknown parameters are a session-level PROTOCOL_VIOLATION
// per §10.2.
func kindOf(t ParamID) (ParamKind, error) {
k, ok := paramKinds[t]
if !ok {
return 0, fmt.Errorf("moqt/message: unknown parameter type %s", t)
}
return k, nil
}
// Parameter is a single MoQT message parameter (§10.2). Exactly one of the
// value fields holds data, determined by the value kind the Parameter was
// constructed with (see [ParamKind] and the constructors below).
type Parameter struct {
Type ParamID
Varint uint64
Byte uint8
Bytes []byte
Group uint64 // KindLocation: Group ID
Object uint64 // KindLocation: Object ID
// kind records how the value is encoded. It is set by every constructor
// and by parse, so encoding is self-describing and does not consult the
// kind registry — an extension Parameter built via a generic helper
// encodes per the helper used, not per a (possibly absent) registry entry.
kind ParamKind
}
// Generic construction helpers — keyed by ParamID and value kind. Use the
// typed helpers below for known parameters; reach for these only when
// constructing a parameter the registry doesn't have a dedicated helper for
// (e.g. while experimenting with extensions).
func VarintParam(t ParamID, v uint64) Parameter {
return Parameter{Type: t, Varint: v, kind: KindVarint}
}
func ByteParam(t ParamID, v uint8) Parameter { return Parameter{Type: t, Byte: v, kind: KindByte} }
func BytesParam(t ParamID, v []byte) Parameter {
return Parameter{Type: t, Bytes: v, kind: KindBytes}
}
func LocationParam(t ParamID, g, o uint64) Parameter {
return Parameter{Type: t, Group: g, Object: o, kind: KindLocation}
}
// Typed helpers for the well-known parameters. Each bakes in the right
// ParamID and value kind, and enforces the constraints the spec puts on the
// value (bool for FORWARD, an enum for GROUP_ORDER, time.Duration for the
// millisecond-valued timeouts, etc.).
// ObjectDeliveryTimeoutParam builds OBJECT_DELIVERY_TIMEOUT (§10.2.4): the
// maximum duration the publisher holds a single object before declaring
// failure.
func ObjectDeliveryTimeoutParam(d time.Duration) Parameter {
//nolint:gosec // G115: d is a non-negative timeout Duration; whole ms fits a varint.
return VarintParam(ParamObjectDeliveryTimeout, uint64(d/time.Millisecond))
}
// RendezvousTimeoutParam builds RENDEZVOUS_TIMEOUT (§10.2.6): how long the
// subscriber is willing to wait for a publisher to become available. A zero
// duration tells the relay to respond immediately with DOES_NOT_EXIST when
// no publisher exists.
func RendezvousTimeoutParam(d time.Duration) Parameter {
//nolint:gosec // G115: d is a non-negative timeout Duration; whole ms fits a varint.
return VarintParam(ParamRendezvousTimeout, uint64(d/time.Millisecond))
}
// SubgroupDeliveryTimeoutParam builds SUBGROUP_DELIVERY_TIMEOUT (§10.2.3).
func SubgroupDeliveryTimeoutParam(d time.Duration) Parameter {
//nolint:gosec // G115: d is a non-negative timeout Duration; whole ms fits a varint.
return VarintParam(ParamSubgroupDeliveryTimeout, uint64(d/time.Millisecond))
}
// FillTimeoutParam builds FILL_TIMEOUT (§10.2.5): the maximum total duration
// a relay should spend waiting for upstream sources to provide objects that
// are not immediately available. A zero duration means the subscriber only
// wants objects that are immediately available.
func FillTimeoutParam(d time.Duration) Parameter {
//nolint:gosec // G115: d is a non-negative timeout Duration; whole ms fits a varint.
return VarintParam(ParamFillTimeout, uint64(d/time.Millisecond))
}
// ExpiresParam builds EXPIRES (§10.2.16): the time after which the sender
// will terminate the subscription. Zero means the subscription does not
// expire (or expires at an unknown time).
func ExpiresParam(d time.Duration) Parameter {
//nolint:gosec // G115: d is a non-negative timeout Duration; whole ms fits a varint.
return VarintParam(ParamExpires, uint64(d/time.Millisecond))
}
// LargestObjectParam builds LARGEST_OBJECT (§10.2.17): the largest Location
// {Group, Object} observed in the track by the sender.
func LargestObjectParam(group, object uint64) Parameter {
return LocationParam(ParamLargestObject, group, object)
}
// ForwardParam builds FORWARD (§10.2.18). The wire value is restricted to
// 0/1 per the spec, so the helper takes a bool.
func ForwardParam(forward bool) Parameter {
var v uint8
if forward {
v = 1
}
return ByteParam(ParamForward, v)
}
// SubscriberPriorityParam builds SUBSCRIBER_PRIORITY (§10.2.7). Lower numbers
// get higher priority; the implicit default when omitted is 128.
func SubscriberPriorityParam(priority uint8) Parameter {
return ByteParam(ParamSubscriberPriority, priority)
}
// LocationFilterParam builds LOCATION_FILTER (§10.2.9) from a typed
// LocationFilter. The filter is serialised to bytes and stored as a
// length-prefixed KindBytes parameter per §10.2.9.
func LocationFilterParam(f *LocationFilter) Parameter {
return BytesParam(ParamLocationFilter, f.Bytes())
}
// LocationFilterFromParam extracts and parses a LOCATION_FILTER
// parameter from a Parameters list. Returns nil, nil if the parameter is
// absent (unfiltered subscription). Returns an error if the parameter is
// present but malformed.
func LocationFilterFromParam(ps Parameters) (*LocationFilter, error) {
p, ok := ps.Find(ParamLocationFilter)
if !ok {
return nil, nil //nolint:nilnil // absent optional parameter: (nil filter, nil error) is the documented contract.
}
return ParseLocationFilter(p.Bytes)
}
// GroupOrder is the value of the GROUP_ORDER parameter (§10.2.8). The spec
// restricts the wire value to Ascending or Descending; anything else is a
// session-level PROTOCOL_VIOLATION.
type GroupOrder uint8
const (
GroupOrderAscending GroupOrder = 0x1
GroupOrderDescending GroupOrder = 0x2
)
// GroupOrderParam builds GROUP_ORDER (§10.2.8).
func GroupOrderParam(order GroupOrder) Parameter {
return ByteParam(ParamGroupOrder, uint8(order))
}
// NewGroupRequestParam builds NEW_GROUP_REQUEST (§10.2.19): the largest known
// Group ID plus 1, or 0 if the subscriber has no Group information.
func NewGroupRequestParam(largestGroupPlusOne uint64) Parameter {
return VarintParam(ParamNewGroupRequest, largestGroupPlusOne)
}
// TrackNamespacePrefixParam builds TRACK_NAMESPACE_PREFIX (§10.2.20): a
// namespace prefix used for namespace subscription updates. The value is a
// TrackNamespace structure serialized per §2.4.1.
func TrackNamespacePrefixParam(prefix wire.TrackNamespace) Parameter {
// Serialize the TrackNamespace to bytes
var buf []byte
w := wire.NewWriter(buf)
w.TrackNamespace(prefix)
return BytesParam(ParamTrackNamespacePrefix, w.Bytes())
}
// Parameters is a list of message parameters.
//
//nolint:recvcheck // value receivers for reads, pointer receiver for in-place mutation — intentional.
type Parameters []Parameter
// Find returns the first parameter with the given type, plus a bool indicating
// presence.
func (ps Parameters) Find(t ParamID) (Parameter, bool) {
for _, p := range ps {
if p.Type == t {
return p, true
}
}
return Parameter{}, false
}
// FindAll returns every parameter with the given type, in list order. Range
// Filter parameters (§5.1.4) legitimately repeat within one message (multiple
// SetIDs / Property Types), so callers that handle them must iterate all
// occurrences rather than rely on [Parameters.Find]'s first-only result.
func (ps Parameters) FindAll(t ParamID) []Parameter {
var out []Parameter
for _, p := range ps {
if p.Type == t {
out = append(out, p)
}
}
return out
}
// IsRangeFilterParam reports whether t is one of the five Range Filter
// parameter types (§5.1.4, 0x25-0x29).
func IsRangeFilterParam(t ParamID) bool {
return t >= ParamSubgroupFilter && t <= ParamTrackPropertyFilter
}
// append writes count + sorted, delta-encoded entries to w. Duplicate types
// are written in input order; callers should de-duplicate where required.
func (ps Parameters) append(w *wire.Writer) {
w.Varint(uint64(len(ps)))
sorted := make(Parameters, len(ps))
copy(sorted, ps)
slices.SortStableFunc(sorted, func(a, b Parameter) int { return cmp.Compare(a.Type, b.Type) })
var prev uint64
for _, p := range sorted {
t := uint64(p.Type)
w.Varint(t - prev)
appendParamValue(w, p)
prev = t
}
}
// parse reads a Number-of-Parameters varint followed by that many parameters
// from r.
func (ps *Parameters) parse(r *wire.Reader) error {
count, err := r.Varint()
if err != nil {
return err
}
// count is an untrusted varint (up to 2^62-1); never preallocate from it
// directly or a crafted message triggers an out-of-range makeslice panic.
// Each parameter occupies at least one byte on the wire (its type-delta
// varint), so the real count cannot exceed the remaining bytes — the loop
// surfaces a truncated count as a read error.
//nolint:gosec // G115: Reader.Remaining() = len(buf)-off is always >= 0.
out := make(Parameters, 0, min(count, uint64(r.Remaining())))
var prev uint64
for range count {
delta, err := r.Varint()
if err != nil {
return err
}
if delta > ^uint64(0)-prev {
return errors.New("moqt/message: parameter type delta overflow")
}
t := prev + delta
p := Parameter{Type: ParamID(t)}
if err := parseParamValue(r, &p); err != nil {
return err
}
out = append(out, p)
prev = t
}
*ps = out
return nil
}
func appendParamValue(w *wire.Writer, p Parameter) {
switch p.kind {
case KindVarint:
w.Varint(p.Varint)
case KindByte:
w.UInt8(p.Byte)
case KindBytes:
w.VarintBytes(p.Bytes)
case KindLocation:
w.Varint(p.Group)
w.Varint(p.Object)
case kindUnset:
// The Parameter was built as a bare struct literal rather than via a
// typed helper or VarintParam/ByteParam/BytesParam/LocationParam (or
// parse). There is no value kind to encode — surface the programming
// error loudly instead of silently writing varint(0).
panic(fmt.Sprintf(
"moqt/message: Parameter type %s has no value kind; build it with a constructor, not a bare literal",
p.Type,
))
}
}
func parseParamValue(r *wire.Reader, p *Parameter) error {
k, err := kindOf(p.Type)
if err != nil {
return err
}
p.kind = k
switch k {
case KindVarint:
v, err := r.Varint()
if err != nil {
return err
}
p.Varint = v
case KindByte:
v, err := r.UInt8()
if err != nil {
return err
}
p.Byte = v
case KindBytes:
v, err := r.VarintBytes()
if err != nil {
return err
}
p.Bytes = v
case KindLocation:
g, err := r.Varint()
if err != nil {
return err
}
o, err := r.Varint()
if err != nil {
return err
}
p.Group = g
p.Object = o
case kindUnset:
// kindOf never returns kindUnset for a registered type, so this is
// unreachable; enumerated to keep the switch exhaustive.
return fmt.Errorf("moqt/message: parameter %s has no value kind", p.Type)
}
return nil
}
package message
import (
"fmt"
"github.com/floatdrop/moq-go/pkg/moqt/wire"
)
// PropertyType identifies a MoQT property per §12 and the IANA 'MOQ Properties'
// registry. Types are used as absolute values in the KVPair.Type field; the
// delta encoding is handled by the wire layer.
type PropertyType = uint64
// Property type constants from §12 and the IANA registry (Table 14).
// All types listed here are from draft-ietf-moq-transport-20.
const (
// PropertySubgroupDeliveryTimeout (0x06) is a Track or Object Property
// (§12.1). Value: varint (milliseconds). Semantics defined in §8. As an
// Object Property on the first object in a subgroup it overrides the
// Track-level value for that subgroup; it is ignored on any other object.
PropertySubgroupDeliveryTimeout PropertyType = 0x06
// PropertyObjectDeliveryTimeout (0x02) is a Track or Object Property
// (§12.2). Value: varint (milliseconds). Semantics defined in §8. As an
// Object Property on the first object in a subgroup it overrides the
// Track-level value for that subgroup; it is ignored on any other object.
PropertyObjectDeliveryTimeout PropertyType = 0x02
// PropertyMaxCacheDuration (0x04) is a Track Property (§12.3).
// Value: varint (milliseconds).
PropertyMaxCacheDuration PropertyType = 0x04
// PropertyDefaultPublisherPriority (0x0E) is a Track Property (§12.4).
// Value: varint 0–255. Default: 128.
PropertyDefaultPublisherPriority PropertyType = 0x0E
// PropertyDefaultPublisherGroupOrder (0x22) is a Track Property (§12.5).
// Value: varint; 0x1 = Ascending (default), 0x2 = Descending.
PropertyDefaultPublisherGroupOrder PropertyType = 0x22
// PropertyDynamicGroups (0x30) is a Track Property (§12.6).
// Value: varint 0 or 1.
PropertyDynamicGroups PropertyType = 0x30
// PropertyImmutableProperties (0x0B) is a Track or Object Property (§12.7).
// Value: bytes containing a nested sequence of KV pairs.
PropertyImmutableProperties PropertyType = 0x0B
// PropertyPriorGroupIDGap (0x3C) is an Object Property (§12.8).
// Value: varint.
PropertyPriorGroupIDGap PropertyType = 0x3C
// PropertyPriorObjectIDGap (0x3E) is an Object Property (§12.9).
// Value: varint.
PropertyPriorObjectIDGap PropertyType = 0x3E
)
// MandatoryTrackPropertyMin and MandatoryTrackPropertyMax define the range of
// Mandatory Track Property types per §2.5.1. Properties in [0x4000, 0x7FFF]
// MUST have Track scope; receiving one as an Object Property is malformed.
// An endpoint that does not understand a Mandatory Track Property in PUBLISH,
// SUBSCRIBE_OK, or FETCH_OK MUST NOT process or forward that track.
const (
MandatoryTrackPropertyMin PropertyType = 0x4000
MandatoryTrackPropertyMax PropertyType = 0x7FFF
)
// IsMandatoryTrackProperty reports whether t is in the mandatory range
// [0x4000, 0x7FFF] per §2.5.1.
func IsMandatoryTrackProperty(t PropertyType) bool {
return t >= MandatoryTrackPropertyMin && t <= MandatoryTrackPropertyMax
}
// ParseTrackProperties parses raw Track Properties bytes (the trailing field
// in PUBLISH, SUBSCRIBE_OK, FETCH_OK, etc.) as a sequence of KV pairs.
// Track Properties have no explicit length prefix — they are bounded by the
// outer message frame (§2.5). The raw bytes are typically obtained via
// wire.Reader.RemainingBytes().
//
// Returns an error if any pair cannot be parsed. Mandatory Track Property
// screening (§2.5.1) is the caller's job — see
// [FirstUnknownMandatoryTrackProperty].
func ParseTrackProperties(raw []byte) ([]wire.KVPair, error) {
if len(raw) == 0 {
return nil, nil
}
r := wire.NewReader(raw)
pairs, err := r.KVPairsRemaining()
if err != nil {
return nil, fmt.Errorf("moqt/message: track properties: %w", err)
}
return pairs, nil
}
// AppendTrackProperties serialises a slice of KV pairs as raw Track Properties
// bytes (no length prefix). The result is suitable for appending directly to
// a message writer via w.FixedBytes().
func AppendTrackProperties(pairs []wire.KVPair) []byte {
var w wire.Writer
w.KVPairs(pairs)
return w.Bytes()
}
// FirstUnknownMandatoryTrackProperty returns the first Mandatory Track
// Property (range 0x4000–0x7FFF) in pairs whose type is not in knownTypes,
// and whether one was found — the offending type is what callers need to
// build their rejection error. A nil knownTypes treats every mandatory
// property as unknown.
//
// Per §2.5.1, an endpoint that receives Track Properties containing an
// unknown Mandatory Track Property MUST NOT process or forward that track.
func FirstUnknownMandatoryTrackProperty(
pairs []wire.KVPair,
knownTypes map[PropertyType]struct{},
) (PropertyType, bool) {
for _, kv := range pairs {
if !IsMandatoryTrackProperty(kv.Type) {
continue
}
if _, known := knownTypes[kv.Type]; !known {
return kv.Type, true
}
}
return 0, false
}
package message
import (
"github.com/floatdrop/moq-go/pkg/moqt"
"github.com/floatdrop/moq-go/pkg/moqt/wire"
)
// Publish is the PUBLISH message (§10.11).
type Publish struct {
RequestID uint64
Namespace wire.TrackNamespace
Name []byte
TrackAlias uint64
Parameters Parameters
TrackProperties []byte
}
func (m *Publish) Type() Type { return TypePublish }
func (m *Publish) GetRequestID() uint64 { return m.RequestID }
func (m *Publish) SetRequestID(id uint64) { m.RequestID = id }
func (m *Publish) Append(w *wire.Writer) {
w.Varint(m.RequestID)
w.TrackNamespace(m.Namespace)
w.VarintBytes(m.Name)
w.Varint(m.TrackAlias)
m.Parameters.append(w)
w.FixedBytes(m.TrackProperties)
}
func (m *Publish) Parse(r *wire.Reader) error {
s := r.Scanner()
s.Varint(&m.RequestID)
s.TrackNamespace(&m.Namespace)
s.VarintBytes(&m.Name)
s.Varint(&m.TrackAlias)
if err := s.Err(); err != nil {
return err
}
if err := m.Parameters.parse(r); err != nil {
return err
}
m.TrackProperties = r.RemainingBytes()
return nil
}
// PublishDone is the PUBLISH_DONE message (§10.12).
type PublishDone struct {
StatusCode moqt.PublishDoneCode
StreamCount uint64
ErrorReason string
}
func (m *PublishDone) Type() Type { return TypePublishDone }
func (m *PublishDone) Append(w *wire.Writer) {
w.Varint(uint64(m.StatusCode))
w.Varint(m.StreamCount)
w.ReasonPhrase(m.ErrorReason)
}
func (m *PublishDone) Parse(r *wire.Reader) error {
s := r.Scanner()
var code uint64
s.Varint(&code)
s.Varint(&m.StreamCount)
s.ReasonPhrase(&m.ErrorReason)
if err := s.Err(); err != nil {
return err
}
m.StatusCode = moqt.PublishDoneCode(code)
return nil
}
// Validate enforces the §2.4.1 Full Track Name size limit; ParsePayload
// invokes it automatically after decoding a PUBLISH frame.
func (m *Publish) Validate() error {
return validateFullTrackName(m.Namespace, m.Name)
}
package message
import "github.com/floatdrop/moq-go/pkg/moqt/wire"
// PublishStateNotify is a PUBLISH_STATE_NOTIFY message per §10.10, new in
// draft-20.
//
// The publisher sends it on a subscription's bidi stream to report that the
// subscription's state changed for some reason other than a subscriber
// REQUEST_UPDATE. It is unilateral: the receiver sends no REQUEST_OK or
// REQUEST_ERROR, and it does not count against MAX_REQUEST_UPDATES
// (§10.3.1.7). It is informative — no action is required of the recipient.
//
// It carries no Request ID: the stream it arrives on names the subscription.
// That is also why it does not implement [WithRequestID], unlike the other
// request-stream messages.
//
// PUBLISH_STATE_NOTIFY Message {
// Type (vi64) = 0x22,
// Length (16),
// Number of Parameters (vi64),
// Parameters (..) ...
// }
//
// Only the parameters whose values changed are present; an absent parameter is
// unchanged. §10.10 requires LARGEST_OBJECT when known, so the subscriber can
// tell where in the Track the change took effect.
//
// It applies only to subscriptions and only in the publisher-to-subscriber
// direction: receiving one for another request type, or from the subscriber,
// is a session-level PROTOCOL_VIOLATION.
type PublishStateNotify struct {
Parameters Parameters
}
// Append serializes the PUBLISH_STATE_NOTIFY message to w.
func (m *PublishStateNotify) Append(w *wire.Writer) { m.Parameters.append(w) }
// Parse deserializes the PUBLISH_STATE_NOTIFY message from r.
func (m *PublishStateNotify) Parse(r *wire.Reader) error { return m.Parameters.parse(r) }
// Type returns the wire type ID for PUBLISH_STATE_NOTIFY.
func (m *PublishStateNotify) Type() Type { return TypePublishStateNotify }
package message
import (
"errors"
"fmt"
"math"
"github.com/floatdrop/moq-go/pkg/moqt/wire"
)
// ErrInvalidFilter marks a malformed Range Filter (§5.1.4). The session/relay
// layer maps it to REQUEST_ERROR with code INVALID_FILTER (§10.6, 0x36). It is
// returned for a delta that overflows 2^64-1, an out-of-range PRIORITY value
// (§10.2.12), an odd Property Type on the Object/Track Property filters
// (§10.2.13/§10.2.14), and — at the session layer — a duplicate
// (Type, SetID, Property Type) combination or a total range count exceeding the
// negotiated MAX_FILTER_RANGES (§10.3.1.6).
var ErrInvalidFilter = errors.New("moqt/message: invalid range filter (INVALID_FILTER §5.1.4)")
// Range is one inclusive [Start, End] band of a Range Filter (§5.1.4). Open
// marks the final, open-ended range — its End is omitted on the wire and it
// matches any value >= Start. End is ignored when Open is set.
type Range struct {
Start uint64
End uint64
Open bool
}
// RangeFilter is one Range Filter parameter (§5.1.4): SUBGROUP_FILTER (0x25),
// OBJECTID_FILTER (0x26), PRIORITY_FILTER (0x27), OBJECT_PROPERTY_FILTER (0x28),
// or TRACK_PROPERTY_FILTER (0x29). Type is the parameter ID; SetID groups
// filters for AND/OR combination (§5.1.4); PropertyType is meaningful only for
// the Object/Track Property filters (0x28/0x29) and is 0 otherwise. Ranges is
// the ordered, non-overlapping set of value bands the filter selects.
type RangeFilter struct {
Type ParamID
SetID uint8
PropertyType PropertyType // only for ParamObjectPropertyFilter / ParamTrackPropertyFilter
Ranges []Range
}
// hasPropertyType reports whether this filter type carries a Property Type
// prefix on the wire — only the Object/Track Property filters do (§5.1.4).
func (f *RangeFilter) hasPropertyType() bool {
return f.Type == ParamObjectPropertyFilter || f.Type == ParamTrackPropertyFilter
}
// Append serialises the filter's value blob to w: SetID, optional Property
// Type, then the delta-encoded Ranges (§5.1.4 — Start delta from the prior
// Range's End or 0, End delta from the current Start; the final End is omitted
// for an Open range). It assumes a validated filter; a mid-list Open range
// would truncate the blob, so call [RangeFilter.Validate] first.
func (f *RangeFilter) Append(w *wire.Writer) {
w.UInt8(f.SetID)
if f.hasPropertyType() {
w.Varint(f.PropertyType)
}
var prevEnd uint64
for _, rg := range f.Ranges {
w.Varint(rg.Start - prevEnd) // Start delta from prior End (0 for the first)
if rg.Open {
return // final End omitted → open-ended range
}
w.Varint(rg.End - rg.Start) // End delta from this Start
prevEnd = rg.End
}
}
// Bytes serialises the filter to a fresh byte slice — the value of the
// [RangeFilterParam] parameter.
func (f *RangeFilter) Bytes() []byte {
var w wire.Writer
f.Append(&w)
return w.Bytes()
}
// RangeFilterParam builds the message Parameter (§10.2) carrying f. The value
// is a length-prefixed blob (KindBytes) for all five filter types — see the
// paramKinds note in params.go on the §1.4.3-vs-§5.1.4 parity tension.
func RangeFilterParam(f *RangeFilter) Parameter {
return BytesParam(f.Type, f.Bytes())
}
// ParseRangeFilter decodes a Range Filter parameter's value blob (raw) for
// parameter type t (§5.1.4), resolving the delta-encoded Ranges to absolute
// [Start, End] bands. The open-ended final range is detected when the blob is
// exhausted immediately after a Start. Any delta that overflows 2^64-1 is
// rejected with [ErrInvalidFilter]. Per-type value checks (PRIORITY bound, odd
// Property Type) are applied by [RangeFilter.Validate], not here.
func ParseRangeFilter(t ParamID, raw []byte) (*RangeFilter, error) {
r := wire.NewReader(raw)
f := &RangeFilter{Type: t}
setID, err := r.UInt8()
if err != nil {
return nil, fmt.Errorf("%w: SetID: %w", ErrInvalidFilter, err)
}
f.SetID = setID
if f.hasPropertyType() {
pt, err := r.Varint()
if err != nil {
return nil, fmt.Errorf("%w: property type: %w", ErrInvalidFilter, err)
}
f.PropertyType = pt
}
var prevEnd uint64
for !r.Empty() {
sd, err := r.Varint()
if err != nil {
return nil, fmt.Errorf("%w: range start delta: %w", ErrInvalidFilter, err)
}
if sd > math.MaxUint64-prevEnd {
return nil, fmt.Errorf("%w: range start delta overflows 2^64-1", ErrInvalidFilter)
}
start := prevEnd + sd
// A Start with no following End is the omitted-final-End open range.
if r.Empty() {
f.Ranges = append(f.Ranges, Range{Start: start, Open: true})
break
}
ed, err := r.Varint()
if err != nil {
return nil, fmt.Errorf("%w: range end delta: %w", ErrInvalidFilter, err)
}
if ed > math.MaxUint64-start {
return nil, fmt.Errorf("%w: range end delta overflows 2^64-1", ErrInvalidFilter)
}
f.Ranges = append(f.Ranges, Range{Start: start, End: start + ed})
prevEnd = start + ed
}
return f, nil
}
// Validate applies the §5.1.4 per-filter value checks that need no session
// state: the Object/Track Property filters require an even Property Type
// (§10.2.13/§10.2.14), PRIORITY_FILTER values must fit 8 bits (§10.2.12), and
// only the final Range may be open-ended (a mid-list Open cannot round-trip).
// Duplicate-combination and MAX_FILTER_RANGES checks need session state and
// live in [RangeFiltersFromParams] / [RangeFilterSet.Validate].
func (f *RangeFilter) Validate() error {
if f.hasPropertyType() && f.PropertyType%2 != 0 {
return fmt.Errorf("%w: %s property type 0x%X must be even", ErrInvalidFilter, f.Type, f.PropertyType)
}
for i, rg := range f.Ranges {
if rg.Open && i != len(f.Ranges)-1 {
return fmt.Errorf("%w: only the final range may be open-ended", ErrInvalidFilter)
}
if f.Type == ParamPriorityFilter && (rg.Start > 255 || (!rg.Open && rg.End > 255)) {
return fmt.Errorf("%w: PRIORITY value exceeds 255 (§10.2.12)", ErrInvalidFilter)
}
}
return nil
}
// matchValue reports whether v falls in any of the filter's Ranges (inclusive;
// an Open range matches v >= Start). A filter with no Ranges matches nothing.
func (f *RangeFilter) matchValue(v uint64) bool {
for _, rg := range f.Ranges {
if rg.Open {
if v >= rg.Start {
return true
}
continue
}
if v >= rg.Start && v <= rg.End {
return true
}
}
return false
}
// RangeFilterSet is the collection of Range Filters (§5.1.4) on one request,
// grouped by SetID. A value passes a group when it satisfies every filter in
// that group (AND); it passes the set when it passes any group (OR) — §5.1.4's
// "SetID=0 OR SetID=1 OR ...". A nil or empty set imposes no restriction. Build
// it with [RangeFiltersFromParams].
type RangeFilterSet struct {
groups []rangeGroup
totalRanges int
hasObjectProperty bool // any group holds an OBJECT_PROPERTY_FILTER
hasTrackProperty bool // any group holds a TRACK_PROPERTY_FILTER
}
// rangeGroup holds every filter sharing one SetID (AND-combined).
type rangeGroup struct {
setID uint8
filters []RangeFilter
}
type filterKey struct {
typ ParamID
setID uint8
propTy PropertyType
}
// RangeFiltersFromParams extracts every Range Filter parameter (§5.1.4) from ps,
// validates each, rejects a duplicate (Type, SetID, Property Type) combination
// (§5.1.4), and groups them by SetID. Returns (nil, nil) when ps carries no
// range filters — the "no filter" default, matching [LocationFilterFromParam].
// The MAX_FILTER_RANGES limit needs the negotiated cap and is enforced
// separately by [RangeFilterSet.Validate].
func RangeFiltersFromParams(ps Parameters) (*RangeFilterSet, error) {
var set *RangeFilterSet
seen := make(map[filterKey]struct{})
groupIdx := make(map[uint8]int)
for _, p := range ps {
if !IsRangeFilterParam(p.Type) {
continue
}
f, err := ParseRangeFilter(p.Type, p.Bytes)
if err != nil {
return nil, err
}
if err := f.Validate(); err != nil {
return nil, err
}
key := filterKey{typ: p.Type, setID: f.SetID, propTy: f.PropertyType}
if _, dup := seen[key]; dup {
return nil, fmt.Errorf("%w: duplicate filter (type=%s setID=%d propertyType=0x%X)",
ErrInvalidFilter, p.Type, f.SetID, f.PropertyType)
}
seen[key] = struct{}{}
if set == nil {
set = &RangeFilterSet{}
}
set.totalRanges += len(f.Ranges)
// SUBGROUP/OBJECTID/PRIORITY carry no property blob; only these two do.
if p.Type == ParamObjectPropertyFilter {
set.hasObjectProperty = true
}
if p.Type == ParamTrackPropertyFilter {
set.hasTrackProperty = true
}
gi, ok := groupIdx[f.SetID]
if !ok {
gi = len(set.groups)
set.groups = append(set.groups, rangeGroup{setID: f.SetID})
groupIdx[f.SetID] = gi
}
set.groups[gi].filters = append(set.groups[gi].filters, *f)
}
return set, nil
}
// Validate enforces the MAX_FILTER_RANGES setup option (§10.3.1.6): a limit of
// 0 prohibits range filters entirely, and the total number of Ranges across all
// filters must not exceed maxFilterRanges. Returns [ErrInvalidFilter] on breach.
// A nil set (no filters) is always valid.
func (s *RangeFilterSet) Validate(maxFilterRanges uint64) error {
if s == nil {
return nil
}
if maxFilterRanges == 0 {
return fmt.Errorf("%w: range filters not permitted (MAX_FILTER_RANGES=0)", ErrInvalidFilter)
}
//nolint:gosec // G115: totalRanges is a non-negative sum of len(Ranges).
if uint64(s.totalRanges) > maxFilterRanges {
return fmt.Errorf("%w: %d ranges exceed MAX_FILTER_RANGES=%d",
ErrInvalidFilter, s.totalRanges, maxFilterRanges)
}
return nil
}
// propertyValue extracts property t's value from a decoded property KV set.
// Even property types carry a varint value (in wire.KVPair.IntVal); Range
// Filters require an even Property Type (enforced by Validate), so an odd type
// never reaches here.
func propertyValue(pairs []wire.KVPair, t PropertyType) (uint64, bool) {
for _, kv := range pairs {
if kv.Type == t {
return kv.IntVal, true
}
}
return 0, false
}
// matchObject reports whether the group's object-scoped filters
// (SUBGROUP/OBJECTID/PRIORITY/OBJECT_PROPERTY) all match — the AND within a
// SetID. Track-property filters in the group are not object constraints and are
// skipped here (they gate the track via trackPassPerGroup).
func (g *rangeGroup) matchObject(subgroupID, objectID uint64, priority uint8, props []wire.KVPair) bool {
for i := range g.filters {
f := &g.filters[i]
//nolint:exhaustive // only the four object-scoped filter types constrain
// an object; TRACK_PROPERTY (the default) is gated via TrackPassPerGroup,
// and no other ParamID reaches a rangeGroup.
switch f.Type {
case ParamSubgroupFilter:
if !f.matchValue(subgroupID) {
return false
}
case ParamObjectIDFilter:
if !f.matchValue(objectID) {
return false
}
case ParamPriorityFilter:
if !f.matchValue(uint64(priority)) {
return false
}
case ParamObjectPropertyFilter:
v, ok := propertyValue(props, f.PropertyType)
if !ok || !f.matchValue(v) {
return false
}
default:
// ParamTrackPropertyFilter is a track constraint, gated separately
// via TrackPassPerGroup — not an object constraint here.
}
}
return true
}
// matchTrack reports whether the group's TRACK_PROPERTY filters all match — the
// AND within a SetID for the track scope. A group with no track filter passes
// vacuously.
func (g *rangeGroup) matchTrack(props []wire.KVPair) bool {
for i := range g.filters {
f := &g.filters[i]
if f.Type == ParamTrackPropertyFilter {
v, ok := propertyValue(props, f.PropertyType)
if !ok || !f.matchValue(v) {
return false
}
}
}
return true
}
// MatchesObject reports whether an object with the given Subgroup ID, Object ID,
// Publisher Priority, and Object-Properties blob passes the set's object-scoped
// filters (§5.1.4): OR over SetID of (AND of the group's SUBGROUP/OBJECTID/
// PRIORITY/OBJECT_PROPERTY filters). A nil/empty set matches everything.
//
// This ignores TRACK_PROPERTY filters, so it is exact for SUBSCRIBE/FETCH (which
// carry no track filters). When a SetID mixes object and track filters (possible
// in SUBSCRIBE_TRACKS), use [RangeFilterSet.MatchesObjectInSets] with a
// [RangeFilterSet.TrackPassPerGroup] vector instead.
func (s *RangeFilterSet) MatchesObject(subgroupID, objectID uint64, priority uint8, objProps []byte) bool {
return s.MatchesObjectInSets(subgroupID, objectID, priority, objProps, nil)
}
// MatchesObjectInSets is [RangeFilterSet.MatchesObject] with per-SetID track
// gating: group i is eligible only when trackPass[i] is true (nil trackPass =
// all groups eligible). This implements the exact §5.1.4 semantics
// OR_i(trackPass[i] AND objectFilters_i) for the mixed object+track-in-one-SetID
// case, where a naive MatchesTrack() && MatchesObject() would be wrong.
func (s *RangeFilterSet) MatchesObjectInSets(
subgroupID, objectID uint64, priority uint8, objProps []byte, trackPass []bool,
) bool {
if s == nil || len(s.groups) == 0 {
return true
}
var props []wire.KVPair
if s.hasObjectProperty {
props, _ = ParseTrackProperties(objProps) // malformed → nil → property filters miss
}
for i := range s.groups {
if trackPass != nil && !trackPass[i] {
continue
}
if s.groups[i].matchObject(subgroupID, objectID, priority, props) {
return true
}
}
return false
}
// TrackPassPerGroup returns, for each SetID group (in the same order as
// [RangeFilterSet.MatchesObjectInSets] evaluates), whether the group's
// TRACK_PROPERTY filters all pass for a track with the given Track Properties.
// Computed once per (track, subscription) and reused across that track's
// objects. Returns nil for a nil set.
func (s *RangeFilterSet) TrackPassPerGroup(trackProps []byte) []bool {
if s == nil {
return nil
}
var props []wire.KVPair
if s.hasTrackProperty {
props, _ = ParseTrackProperties(trackProps)
}
pass := make([]bool, len(s.groups))
for i := range s.groups {
pass[i] = s.groups[i].matchTrack(props)
}
return pass
}
// MatchesTrack reports whether a track with the given Track Properties passes
// the set's TRACK_PROPERTY filters (§5.1.4 / §10.2.14) — the PUBLISH-forwarding
// gate for SUBSCRIBE_TRACKS: OR over SetID of (AND of the group's track-property
// filters). A group with no track filter passes vacuously, so a set with only
// object filters matches every track; a nil set matches everything.
func (s *RangeFilterSet) MatchesTrack(trackProps []byte) bool {
if s == nil || len(s.groups) == 0 {
return true
}
var props []wire.KVPair
if s.hasTrackProperty {
props, _ = ParseTrackProperties(trackProps)
}
for i := range s.groups {
if s.groups[i].matchTrack(props) {
return true
}
}
return false
}
package message
import (
"errors"
"fmt"
"github.com/floatdrop/moq-go/pkg/moqt"
"github.com/floatdrop/moq-go/pkg/moqt/wire"
)
// RequestUpdate is the REQUEST_UPDATE message (§10.9).
type RequestUpdate struct {
RequestID uint64
Parameters Parameters
}
func (m *RequestUpdate) Type() Type { return TypeRequestUpdate }
func (m *RequestUpdate) GetRequestID() uint64 { return m.RequestID }
func (m *RequestUpdate) SetRequestID(id uint64) { m.RequestID = id }
func (m *RequestUpdate) Append(w *wire.Writer) {
w.Varint(m.RequestID)
m.Parameters.append(w)
}
func (m *RequestUpdate) Parse(r *wire.Reader) error {
s := r.Scanner()
s.Varint(&m.RequestID)
if err := s.Err(); err != nil {
return err
}
return m.Parameters.parse(r)
}
// RequestOK is the REQUEST_OK message (§10.5). Track Properties are populated
// when used as a TRACK_STATUS_OK response and empty otherwise (PUBLISH, REQUEST_UPDATE).
type RequestOK struct {
Parameters Parameters
TrackProperties []byte
}
func (m *RequestOK) Type() Type { return TypeRequestOK }
func (m *RequestOK) Append(w *wire.Writer) {
m.Parameters.append(w)
w.FixedBytes(m.TrackProperties)
}
func (m *RequestOK) Parse(r *wire.Reader) error {
if err := m.Parameters.parse(r); err != nil {
return err
}
m.TrackProperties = r.RemainingBytes()
return nil
}
// Redirect carries the optional redirect payload of REQUEST_ERROR (§10.6.1).
type Redirect struct {
ConnectURI []byte
Namespace wire.TrackNamespace
TrackName []byte
}
// RequestError is the REQUEST_ERROR message (§10.6.2). Redirect is non-nil
// when ErrorCode is REDIRECT.
type RequestError struct {
ErrorCode moqt.RequestErrorCode
RetryInterval uint64
ErrorReason string
Redirect *Redirect
}
func (m *RequestError) Type() Type { return TypeRequestError }
func (m *RequestError) Append(w *wire.Writer) {
w.Varint(uint64(m.ErrorCode))
w.Varint(m.RetryInterval)
w.ReasonPhrase(m.ErrorReason)
if m.Redirect != nil {
w.VarintBytes(m.Redirect.ConnectURI)
w.TrackNamespace(m.Redirect.Namespace)
w.VarintBytes(m.Redirect.TrackName)
}
}
func (m *RequestError) Parse(r *wire.Reader) error {
s := r.Scanner()
var code uint64
s.Varint(&code)
s.Varint(&m.RetryInterval)
s.ReasonPhrase(&m.ErrorReason)
if err := s.Err(); err != nil {
return err
}
m.ErrorCode = moqt.RequestErrorCode(code)
if r.Empty() {
return nil
}
var rd Redirect
s.VarintBytes(&rd.ConnectURI)
s.TrackNamespace(&rd.Namespace)
s.VarintBytes(&rd.TrackName)
if err := s.Err(); err != nil {
return err
}
m.Redirect = &rd
return nil
}
// Validate enforces the §10.6.2 REQUEST_ERROR invariants. It is invoked
// automatically by [ParsePayload] after decode, so a malformed REQUEST_ERROR
// (REDIRECT code without a Redirect block, or vice versa) is rejected at the
// parse boundary rather than reaching the session layer.
func (m *RequestError) Validate() error {
return m.ValidateRedirect()
}
// ValidateRedirect enforces the §10.6.2 constraints: the Redirect block MUST
// be present when ErrorCode is REDIRECT, and MUST NOT be present otherwise.
// It is the implementation behind [RequestError.Validate]; callers may also
// invoke it directly.
func (m *RequestError) ValidateRedirect() error {
if m.ErrorCode == moqt.RequestRedirect && m.Redirect == nil {
return errors.New("moqt/message: ErrorCode is REDIRECT but Redirect block is absent")
}
if m.ErrorCode != moqt.RequestRedirect && m.Redirect != nil {
return fmt.Errorf("moqt/message: Redirect present but ErrorCode %#x is not REDIRECT", uint64(m.ErrorCode))
}
return nil
}
package message
import (
"github.com/floatdrop/moq-go/pkg/moqt/wire"
)
// SetupOption is a MoQT SETUP option type ID (§10.3.1). Distinct from ParamID
// because the two code spaces overlap: option 0x03 is AUTHORIZATION_TOKEN at
// the session level and parameter 0x03 is AUTHORIZATION_TOKEN at the request
// level — different message contexts, different parsing rules. The underlying
// wire field (wire.KVPair.Type) stays uint64 because KVPair is wire-generic.
type SetupOption uint64
const (
SetupOptionPath SetupOption = 0x01
SetupOptionAuthorizationToken SetupOption = 0x03
SetupOptionMaxAuthTokenCache SetupOption = 0x04
SetupOptionAuthority SetupOption = 0x05
SetupOptionMaxFilterRanges SetupOption = 0x06
SetupOptionMOQTImplementation SetupOption = 0x07
SetupOptionMaxRequestUpdates SetupOption = 0x08
)
// Setup carries the SETUP message payload (§10.3). Setup Options span the
// remainder of the message payload as a delta-encoded sequence of KVPairs.
type Setup struct {
Options []wire.KVPair
}
// PathOption builds a PATH setup option (§10.3.1.2). Client-only, native-QUIC
// only: a PATH option received by a server, on a WebTransport session, or with
// an unsupported path triggers an INVALID_PATH session close. pathAndQuery is
// the path-abempty portion of the moqt URI, optionally followed by "?" and
// the query.
func PathOption(pathAndQuery string) wire.KVPair {
return wire.KVPair{Type: uint64(SetupOptionPath), ByteVal: []byte(pathAndQuery)}
}
// AuthorityOption builds an AUTHORITY setup option (§10.3.1.1). Client-only,
// native-QUIC only: a server-sent or WebTransport-sent AUTHORITY triggers
// INVALID_AUTHORITY. authority is the authority portion of the moqt URI.
func AuthorityOption(authority string) wire.KVPair {
return wire.KVPair{Type: uint64(SetupOptionAuthority), ByteVal: []byte(authority)}
}
// MOQTImplementationOption builds a MOQT_IMPLEMENTATION setup option
// (§10.3.1.5). Optional; intended for debugging and interop tracking. nameAndVersion
// SHOULD be the implementation name plus version (e.g. "mediamesh/0.1.0").
func MOQTImplementationOption(nameAndVersion string) wire.KVPair {
return wire.KVPair{Type: uint64(SetupOptionMOQTImplementation), ByteVal: []byte(nameAndVersion)}
}
// MaxAuthTokenCacheSizeOption builds a MAX_AUTH_TOKEN_CACHE_SIZE option
// (§10.3.1.3). maxBytes is the peer-allowed total size in bytes of registered
// authorization tokens. The default if omitted is 0, which prohibits the use
// of token Aliases.
func MaxAuthTokenCacheSizeOption(maxBytes uint64) wire.KVPair {
return wire.KVPair{Type: uint64(SetupOptionMaxAuthTokenCache), IntVal: maxBytes}
}
// MaxRequestUpdatesOption builds a MAX_REQUEST_UPDATES option (§10.3.1.7).
// maxUpdates is the maximum number of unacknowledged REQUEST_UPDATE messages
// the peer may have outstanding on any single request stream; the receiver of
// a REQUEST_UPDATE that exceeds it MUST close the session with
// TOO_MANY_REQUEST_UPDATES. The default if omitted is 0, which means the
// endpoint does not limit REQUEST_UPDATE concurrency.
func MaxRequestUpdatesOption(maxUpdates uint64) wire.KVPair {
return wire.KVPair{Type: uint64(SetupOptionMaxRequestUpdates), IntVal: maxUpdates}
}
// MaxFilterRangesOption builds a MAX_FILTER_RANGES option (§10.3.1.6).
// maxRanges is the maximum total number of Ranges (Start/End pairs) the peer
// may send across all Range Filter parameters (§5.1.4) for a single
// subscription or fetch. The default if omitted is 0, which prohibits the peer
// from sending any Range Filter parameters.
func MaxFilterRangesOption(maxRanges uint64) wire.KVPair {
return wire.KVPair{Type: uint64(SetupOptionMaxFilterRanges), IntVal: maxRanges}
}
func (m *Setup) Type() Type { return TypeSetup }
func (m *Setup) Append(w *wire.Writer) {
w.KVPairs(m.Options)
}
func (m *Setup) Parse(r *wire.Reader) error {
s := r.Scanner()
s.KVPairsRemaining(&m.Options)
return s.Err()
}
package message
import (
"fmt"
"io"
"github.com/floatdrop/moq-go/pkg/moqt/wire"
)
// SubgroupIDMode is the 2-bit SUBGROUP_ID_MODE sub-field of the
// SUBGROUP_HEADER Type byte (bits 1-2, §11.4.2). It controls whether and
// how the Subgroup ID is transmitted in the header.
type SubgroupIDMode uint8
const (
// SubgroupIDImplicitZero: Subgroup ID is omitted; receiver MUST treat
// it as 0.
SubgroupIDImplicitZero SubgroupIDMode = 0b00
// SubgroupIDImplicitFirstObject: Subgroup ID is omitted; receiver MUST
// treat it as equal to the first Object ID transmitted in this
// subgroup.
SubgroupIDImplicitFirstObject SubgroupIDMode = 0b01
// SubgroupIDExplicit: Subgroup ID is present in the header.
SubgroupIDExplicit SubgroupIDMode = 0b10
// 0b11 is reserved (§11.4.2); receiving it MUST cause a session-level
// PROTOCOL_VIOLATION, and constructing it is a programmer error.
)
// SubgroupHeader is the alias-bearing prefix of a SUBGROUP_HEADER stream
// (§11.4.2). The flag fields correspond directly to the bits of the wire
// Type byte; Type() encodes them and DecodeSubgroupHeaderType parses them
// back.
type SubgroupHeader struct {
// Properties: when true, every Object on this stream carries an
// Object Properties structure (§11.2.1.2). Wire bit 0.
Properties bool
// SubgroupIDMode controls how the Subgroup ID is conveyed (wire
// bits 1-2).
SubgroupIDMode SubgroupIDMode
// EndOfGroup: when true, this subgroup contains the largest Object
// in the Group. Wire bit 3.
EndOfGroup bool
// InlinePriority: when true, the subgroup body begins with a one-byte
// Publisher Priority value that overrides the subscription default.
// When false (zero value, common case), the body starts directly with
// the first Object and the subgroup inherits the Publisher Priority
// from the SUBSCRIBE/PUBLISH control message. Wire bit 5 (the spec's
// DEFAULT_PRIORITY bit) — set on the wire when this field is false.
InlinePriority bool
// ReplayingSubgroup: when true, the first Object on this stream is
// NOT the first object the original publisher pushed for this
// subgroup — i.e. the stream is a partial replay from a relay or
// cache. When false (zero value, common case), the first Object on
// the stream is the first Object of the subgroup. Wire bit 6 (the
// spec's FIRST_OBJECT bit) — set on the wire when this field is
// false.
ReplayingSubgroup bool
// TrackAlias identifies the track this subgroup belongs to within
// the publisher → subscriber direction of the session (§11.1).
TrackAlias uint64
// GroupID is the Group ID of this subgroup (§11.4.2). Always present
// on the wire after TrackAlias.
GroupID uint64
// SubgroupID is the Subgroup ID of this subgroup. Present on the wire
// only when SubgroupIDMode == SubgroupIDExplicit (0b10). When the mode
// is SubgroupIDImplicitZero the receiver treats it as 0; when the mode
// is SubgroupIDImplicitFirstObject the receiver treats it as equal to
// the first Object ID on the stream.
SubgroupID uint64
// PublisherPriority is the per-subgroup publisher priority byte.
// Present on the wire only when InlinePriority == true. When
// InlinePriority is false the subgroup inherits the priority from the
// enclosing SUBSCRIBE/PUBLISH control message.
PublisherPriority uint8
}
// Wire-byte bit layout (§11.4.2): 0b0XX1XXXX where bit 4 is always set
// and bit 7 is always clear.
const (
subgroupBitProperties uint64 = 0x01 // bit 0
subgroupModeMask uint64 = 0x06 // bits 1-2
subgroupModeShift = 1
subgroupBitEndOfGroup uint64 = 0x08 // bit 3
subgroupBitMandatory uint64 = 0x10 // bit 4
subgroupBitDefaultPriority uint64 = 0x20 // bit 5
subgroupBitFirstObject uint64 = 0x40 // bit 6
)
// Type returns the wire Type byte encoding the flag fields (§11.4.2).
// The mandatory bit-4 sanity bit is always set. SubgroupIDMode is masked
// to 2 bits — callers that pass an out-of-range value get the bottom
// two bits.
//
// Note that InlinePriority and ReplayingSubgroup are inverted relative
// to the wire bits: a false (zero) field sets the corresponding wire
// bit. This makes the zero value of SubgroupHeader produce the typical
// "inherit priority, original publish" Type byte (0x70).
func (h SubgroupHeader) Type() uint64 {
t := subgroupBitMandatory
if h.Properties {
t |= subgroupBitProperties
}
t |= (uint64(h.SubgroupIDMode) & 0b11) << subgroupModeShift
if h.EndOfGroup {
t |= subgroupBitEndOfGroup
}
if !h.InlinePriority {
t |= subgroupBitDefaultPriority
}
if !h.ReplayingSubgroup {
t |= subgroupBitFirstObject
}
return t
}
// DecodeSubgroupHeaderType parses a wire Type byte (§11.4.2) into the
// flag fields of a SubgroupHeader. TrackAlias is left zero — the header
// parser reads it from the following varint. Returns an error if t is
// not a valid SUBGROUP_HEADER Type (i.e. IsSubgroupHeaderType(t) is
// false).
func DecodeSubgroupHeaderType(t uint64) (SubgroupHeader, error) {
if !IsSubgroupHeaderType(t) {
return SubgroupHeader{}, fmt.Errorf("moqt/message: invalid SUBGROUP_HEADER type %#x", t)
}
return SubgroupHeader{
Properties: t&subgroupBitProperties != 0,
SubgroupIDMode: SubgroupIDMode((t & subgroupModeMask) >> subgroupModeShift),
EndOfGroup: t&subgroupBitEndOfGroup != 0,
InlinePriority: t&subgroupBitDefaultPriority == 0,
ReplayingSubgroup: t&subgroupBitFirstObject == 0,
}, nil
}
// IsSubgroupHeaderType reports whether t is one of the valid SUBGROUP_HEADER
// type values per §11.4.2: the four ranges 0x10..0x1F, 0x30..0x3F, 0x50..0x5F,
// 0x70..0x7F, excluding values where SUBGROUP_ID_MODE (bits 1-2) is 0b11.
func IsSubgroupHeaderType(t uint64) bool {
if t > 0x7F {
return false
}
// Bit 4 must be set, bit 7 clear (0b0XX1XXXX).
if t&subgroupBitMandatory == 0 || t&0x80 != 0 {
return false
}
// SUBGROUP_ID_MODE = 0b11 is reserved.
if t&subgroupModeMask == subgroupModeMask {
return false
}
return true
}
// IsReservedSubgroupHeaderType reports whether t looks like a SUBGROUP_HEADER
// type byte (bit 4 set, bit 7 clear) but has the reserved SUBGROUP_ID_MODE
// value 0b11 in bits 1-2. Per §11.4.2, receiving such a value MUST be treated
// as a session-level PROTOCOL_VIOLATION — unlike a truly unknown stream type,
// which may be ignorable (GREASE).
func IsReservedSubgroupHeaderType(t uint64) bool {
if t > 0x7F {
return false
}
// Must look like a subgroup header: bit 4 set, bit 7 clear.
if t&subgroupBitMandatory == 0 || t&0x80 != 0 {
return false
}
// Reserved: SUBGROUP_ID_MODE bits 1-2 are both set (0b11).
return t&subgroupModeMask == subgroupModeMask
}
// WriteSubgroupHeader writes the full SUBGROUP_HEADER wire encoding (§11.4.2):
// Type, Track Alias, Group ID, optional Subgroup ID (when
// SubgroupIDMode == SubgroupIDExplicit), and optional Publisher Priority (when
// InlinePriority == true).
func WriteSubgroupHeader(w io.Writer, h SubgroupHeader) error {
buf := wire.AppendVarint(nil, h.Type())
buf = wire.AppendVarint(buf, h.TrackAlias)
buf = wire.AppendVarint(buf, h.GroupID)
if h.SubgroupIDMode == SubgroupIDExplicit {
buf = wire.AppendVarint(buf, h.SubgroupID)
}
if h.InlinePriority {
buf = append(buf, h.PublisherPriority)
}
_, err := w.Write(buf)
return err
}
// ReadSubgroupHeader reads a complete SUBGROUP_HEADER from r (§11.4.2).
// The caller must have already read the leading Type varint via
// ReadDataStreamType and verified it with IsSubgroupHeaderType; pass that
// raw type value as typ. ReadSubgroupHeader decodes the flag fields from typ
// and then reads Track Alias, Group ID, optional Subgroup ID (when
// SubgroupIDMode == SubgroupIDExplicit), and optional Publisher Priority
// (when InlinePriority is set).
func ReadSubgroupHeader(r io.Reader, typ uint64) (SubgroupHeader, error) {
h, err := DecodeSubgroupHeaderType(typ)
if err != nil {
return SubgroupHeader{}, err
}
br := wire.NewByteReader(r)
alias, err := wire.ReadVarint(br)
if err != nil {
return SubgroupHeader{}, fmt.Errorf("moqt/message: SUBGROUP_HEADER track alias: %w", err)
}
h.TrackAlias = alias
groupID, err := wire.ReadVarint(br)
if err != nil {
return SubgroupHeader{}, fmt.Errorf("moqt/message: SUBGROUP_HEADER group ID: %w", err)
}
h.GroupID = groupID
if h.SubgroupIDMode == SubgroupIDExplicit {
subgroupID, err := wire.ReadVarint(br)
if err != nil {
return SubgroupHeader{}, fmt.Errorf("moqt/message: SUBGROUP_HEADER subgroup ID: %w", err)
}
h.SubgroupID = subgroupID
}
if h.InlinePriority {
// Publisher Priority is a single byte (§11.4.2).
var buf [1]byte
if _, err := io.ReadFull(r, buf[:]); err != nil {
return SubgroupHeader{}, fmt.Errorf("moqt/message: SUBGROUP_HEADER publisher priority: %w", err)
}
h.PublisherPriority = buf[0]
}
return h, nil
}
package message
import (
"fmt"
"github.com/floatdrop/moq-go/pkg/moqt/wire"
)
// Object Status values for objects with an empty payload (§11.2.1.1).
const (
ObjectStatusNormal uint64 = 0x0 // a normal object (carries a payload)
ObjectStatusEndOfGroup uint64 = 0x3 // last object in the Group
ObjectStatusEndOfTrack uint64 = 0x4 // last object in the Track
)
// SubgroupObject represents a single object serialized on a SUBGROUP_HEADER
// stream after the SubgroupHeader (§11.4.2, Figure 25).
type SubgroupObject struct {
// ObjectIDDelta is always present on the wire. For the first object in
// the stream it is the absolute Object ID; for subsequent objects it is
// (currentID - previousID - 1), so sequential IDs all encode as 0.
ObjectIDDelta uint64
// Properties is present when SubgroupHeader.Properties == true.
// Encoded as a length-prefixed blob (§11.2.1.2).
// Must be non-nil (even if empty) when the header has Properties == true.
Properties []byte
// Payload is the object body. When non-empty, ObjectStatus is ignored.
// Encoded on the wire as: Object Payload Length (vi64) + bytes.
Payload []byte
// ObjectStatus is only written when len(Payload) == 0.
// Values: 0x0 Normal, 0x3 EndOfGroup, 0x4 EndOfTrack (§11.2.1.1).
ObjectStatus uint64
}
// Append serializes the SubgroupObject to the wire writer.
// The hasProperties parameter indicates whether the parent SubgroupHeader
// had the Properties bit set, which determines if Properties are included.
func (o *SubgroupObject) Append(w *wire.Writer, hasProperties bool) {
// Object ID Delta is always present (§11.4.2)
w.Varint(o.ObjectIDDelta)
// Properties are present only if the stream header has Properties == true
if hasProperties {
w.VarintBytes(o.Properties)
}
w.Varint(uint64(len(o.Payload)))
// Object Status is present only when Payload Length == 0
if len(o.Payload) == 0 {
w.Varint(o.ObjectStatus)
} else {
w.FixedBytes(o.Payload)
}
}
// Parse deserializes a SubgroupObject from r.
// r may be a *wire.Reader (in-memory) or a *wire.StreamReader (streaming).
// The hasProperties parameter indicates whether the parent SubgroupHeader
// had the Properties bit set, which determines if Properties are included.
func (o *SubgroupObject) Parse(r wire.Decoder, hasProperties bool) error {
delta, err := r.Varint()
if err != nil {
return fmt.Errorf("moqt/message: object ID delta: %w", err)
}
o.ObjectIDDelta = delta
// Properties are present only if the stream header has Properties == true
if hasProperties {
props, err := r.VarintBytes()
if err != nil {
return fmt.Errorf("moqt/message: properties: %w", err)
}
o.Properties = props
} else {
o.Properties = nil
}
payloadLength, err := r.Varint()
if err != nil {
return fmt.Errorf("moqt/message: payload length: %w", err)
}
// Object Status is present only when Payload Length == 0
if payloadLength == 0 {
status, err := r.Varint()
if err != nil {
return fmt.Errorf("moqt/message: object status: %w", err)
}
o.ObjectStatus = status
o.Payload = nil
} else {
//nolint:gosec // G115: payloadLength is a QUIC varint; StreamReader.FixedBytes enforces MaxStreamFieldSize, Reader is buffer-bounded.
payload, err := r.FixedBytes(int(payloadLength))
if err != nil {
return fmt.Errorf("moqt/message: payload: %w", err)
}
o.Payload = payload
o.ObjectStatus = 0
}
return nil
}
// Validate checks the SubgroupObject for protocol violations.
func (o *SubgroupObject) Validate() error {
// Object Status can only be 0x0 (Normal), 0x3 (EndOfGroup), or 0x4 (EndOfTrack)
if len(o.Payload) == 0 {
switch o.ObjectStatus {
case ObjectStatusNormal, ObjectStatusEndOfGroup, ObjectStatusEndOfTrack:
// Valid status values
default:
return fmt.Errorf("moqt/message: invalid object status 0x%X", o.ObjectStatus)
}
}
return nil
}
// IsEndOfGroup reports whether this object signals End of Group (status 0x3).
func (o *SubgroupObject) IsEndOfGroup() bool {
return len(o.Payload) == 0 && o.ObjectStatus == ObjectStatusEndOfGroup
}
// IsEndOfTrack reports whether this object signals End of Track (status 0x4).
func (o *SubgroupObject) IsEndOfTrack() bool {
return len(o.Payload) == 0 && o.ObjectStatus == ObjectStatusEndOfTrack
}
// IsTerminal reports whether this object is a terminal status object
// (EndOfGroup or EndOfTrack) after which no further objects may appear on the
// same Subgroup stream (§11.4.3); a later object is a malformed track (§2.4.2).
func (o *SubgroupObject) IsTerminal() bool {
return o.IsEndOfGroup() || o.IsEndOfTrack()
}
package message
import "github.com/floatdrop/moq-go/pkg/moqt/wire"
// Subscribe is the SUBSCRIBE message (§10.7).
type Subscribe struct {
RequestID uint64
Namespace wire.TrackNamespace
Name []byte
Parameters Parameters
}
func (m *Subscribe) Type() Type { return TypeSubscribe }
func (m *Subscribe) GetRequestID() uint64 { return m.RequestID }
func (m *Subscribe) SetRequestID(id uint64) { m.RequestID = id }
func (m *Subscribe) Append(w *wire.Writer) {
w.Varint(m.RequestID)
w.TrackNamespace(m.Namespace)
w.VarintBytes(m.Name)
m.Parameters.append(w)
}
func (m *Subscribe) Parse(r *wire.Reader) error {
s := r.Scanner()
s.Varint(&m.RequestID)
s.TrackNamespace(&m.Namespace)
s.VarintBytes(&m.Name)
if err := s.Err(); err != nil {
return err
}
return m.Parameters.parse(r)
}
// SubscribeOK is the SUBSCRIBE_OK message (§10.8). Track Properties span the
// remaining bytes; we currently treat them as opaque.
type SubscribeOK struct {
TrackAlias uint64
Parameters Parameters
TrackProperties []byte
}
func (m *SubscribeOK) Type() Type { return TypeSubscribeOK }
func (m *SubscribeOK) Append(w *wire.Writer) {
w.Varint(m.TrackAlias)
m.Parameters.append(w)
w.FixedBytes(m.TrackProperties)
}
func (m *SubscribeOK) Parse(r *wire.Reader) error {
s := r.Scanner()
s.Varint(&m.TrackAlias)
if err := s.Err(); err != nil {
return err
}
if err := m.Parameters.parse(r); err != nil {
return err
}
m.TrackProperties = r.RemainingBytes()
return nil
}
// Validate enforces the §2.4.1 Full Track Name size limit; ParsePayload
// invokes it automatically after decoding a SUBSCRIBE frame.
func (m *Subscribe) Validate() error {
return validateFullTrackName(m.Namespace, m.Name)
}
package message
import "time"
// DeliveryTimeouts holds the effective delivery timeout pair for one
// subscription per §8. Zero values mean "no timeout".
//
// Both values are expressed as time.Duration (internally milliseconds on the
// wire). A value of 0 means the timeout is disabled for that dimension.
type DeliveryTimeouts struct {
Object time.Duration // OBJECT_DELIVERY_TIMEOUT (§10.2.4)
Subgroup time.Duration // SUBGROUP_DELIVERY_TIMEOUT (§10.2.3)
}
// FillTimeoutFromParam extracts the FILL_TIMEOUT parameter (§10.2.5) from ps
// and converts it from milliseconds to time.Duration. Returns 0 if the
// parameter is absent. FILL_TIMEOUT MAY appear in a FETCH message; it is the
// maximum total duration a relay should spend waiting for upstream sources to
// provide objects that are not immediately available.
func FillTimeoutFromParam(ps Parameters) time.Duration {
d, _ := FillTimeoutFromParamOK(ps)
return d
}
// FillTimeoutFromParamOK is [FillTimeoutFromParam] with presence reported
// separately. §10.2.5 gives an explicit value of 0 a meaning of its own — "the
// relay MUST NOT wait for upstream delivery and MUST report any unavailable
// Objects as Timed-Out gaps" — which a bare zero return cannot distinguish
// from the parameter being absent.
func FillTimeoutFromParamOK(ps Parameters) (d time.Duration, ok bool) {
p, ok := ps.Find(ParamFillTimeout)
if !ok {
return 0, false
}
return MillisecondTimeout(p.Varint), true
}
// MillisecondTimeout converts a varint millisecond count — the form every §8
// timeout takes on the wire, whether it arrives as a Message Parameter
// (§10.2.3 / §10.2.4 / §10.2.5) or a Track/Object Property (§12.1 / §12.2) — to
// a time.Duration. Exported so every decoder of these values agrees by
// construction rather than by copies of the same multiplication.
//
//nolint:gosec // G115: a timeout in ms; an out-of-range value yields a wrong duration, not a memory-safety issue.
func MillisecondTimeout(ms uint64) time.Duration { return time.Duration(ms) * time.Millisecond }
// ObjectDeliveryTimeoutFromParam extracts OBJECT_DELIVERY_TIMEOUT (§10.2.4)
// from ps, converting from milliseconds. Returns 0 when absent (§8: 0 disables
// the timeout).
func ObjectDeliveryTimeoutFromParam(ps Parameters) time.Duration {
p, ok := ps.Find(ParamObjectDeliveryTimeout)
if !ok {
return 0
}
return MillisecondTimeout(p.Varint)
}
// SubgroupDeliveryTimeoutFromParam extracts SUBGROUP_DELIVERY_TIMEOUT (§10.2.3)
// from ps, converting from milliseconds. Returns 0 when absent.
func SubgroupDeliveryTimeoutFromParam(ps Parameters) time.Duration {
p, ok := ps.Find(ParamSubgroupDeliveryTimeout)
if !ok {
return 0
}
return MillisecondTimeout(p.Varint)
}
// DeliveryTimeoutsFromParams extracts both delivery timeouts (§10.2.3/§10.2.4)
// from ps — the form a subscriber communicates them in (§8).
func DeliveryTimeoutsFromParams(ps Parameters) DeliveryTimeouts {
return DeliveryTimeouts{
Object: ObjectDeliveryTimeoutFromParam(ps),
Subgroup: SubgroupDeliveryTimeoutFromParam(ps),
}
}
// effectiveDim combines one timeout dimension per §8: "If both the publisher's
// value and the subscriber's value are non-zero, the smaller of the two is
// used." A zero value means "no timeout", so it never wins over a non-zero one.
func effectiveDim(publisher, subscriber time.Duration) time.Duration {
switch {
case publisher == 0:
return subscriber
case subscriber == 0:
return publisher
default:
return min(publisher, subscriber)
}
}
// Effective resolves the timeouts a publisher enforces for a subscription per
// §8: the receiver holds the publisher's values (Track Property, or the
// first-object Object Property override — see [DeliveryTimeouts.ApplyObjectProperties]),
// sub holds the subscriber's Message-Parameter values, and each dimension is
// the smaller of the two non-zero values.
func (d DeliveryTimeouts) Effective(sub DeliveryTimeouts) DeliveryTimeouts {
return DeliveryTimeouts{
Object: effectiveDim(d.Object, sub.Object),
Subgroup: effectiveDim(d.Subgroup, sub.Subgroup),
}
}
// ApplyObjectProperties returns d with any OBJECT_DELIVERY_TIMEOUT (§12.2) or
// SUBGROUP_DELIVERY_TIMEOUT (§12.1) present in rawProps overriding the
// corresponding dimension. rawProps is the Object-Properties blob of the FIRST
// object in a subgroup (§12.1/§12.2: on the first object these override the
// Track-level value for that subgroup; on any other object they are ignored, so
// callers must invoke this only for the first object). A property present with
// value 0 overrides to "disabled"; an absent property leaves d's dimension
// unchanged. Malformed props leave d unchanged.
func (d DeliveryTimeouts) ApplyObjectProperties(rawProps []byte) DeliveryTimeouts {
if len(rawProps) == 0 {
return d
}
pairs, err := ParseTrackProperties(rawProps) // generic KV-pair decode; scope is the caller's
if err != nil {
return d
}
for _, kv := range pairs {
switch kv.Type {
case PropertyObjectDeliveryTimeout:
d.Object = MillisecondTimeout(kv.IntVal)
case PropertySubgroupDeliveryTimeout:
d.Subgroup = MillisecondTimeout(kv.IntVal)
}
}
return d
}
package message
import (
"fmt"
"github.com/floatdrop/moq-go/pkg/moqt/wire"
)
// AliasType identifies the serialization and processing behavior of a Token
// per §10.2.2.
type AliasType uint64
const (
// AliasTypeDelete (0x0): Alias only. Retire the alias and its associated
// token from the cache.
AliasTypeDelete AliasType = 0x0
// AliasTypeRegister (0x1): Alias + Type + Value. Register the alias in
// the token cache for the duration of the session (or until deleted).
AliasTypeRegister AliasType = 0x1
// AliasTypeUseAlias (0x2): Alias only. Resolve to the (Type, Value)
// previously registered under this alias.
AliasTypeUseAlias AliasType = 0x2
// AliasTypeUseValue (0x3): Type + Value only. Use the token directly;
// no alias is stored.
AliasTypeUseValue AliasType = 0x3
)
// String returns a human-readable name for the alias type.
func (a AliasType) String() string {
switch a {
case AliasTypeDelete:
return "DELETE"
case AliasTypeRegister:
return "REGISTER"
case AliasTypeUseAlias:
return "USE_ALIAS"
case AliasTypeUseValue:
return "USE_VALUE"
}
return fmt.Sprintf("AliasType(0x%X)", uint64(a))
}
// Token is the Token structure from §10.2.2.
//
// Wire format (within the outer KindBytes length-prefixed parameter value):
//
// Token {
// Alias Type (vi64),
// [Token Alias (vi64),] -- DELETE, REGISTER, USE_ALIAS
// [Token Type (vi64),] -- REGISTER, USE_VALUE
// [Token Value (..)] -- REGISTER, USE_VALUE; raw bytes to end of value
// }
//
// TokenValue has no inner length prefix; it occupies the remainder of the
// outer KindBytes parameter value.
type Token struct {
AliasType AliasType
TokenAlias uint64 // present for DELETE, REGISTER, USE_ALIAS
TokenType uint64 // present for REGISTER, USE_VALUE
TokenValue []byte // present for REGISTER, USE_VALUE
}
// Append serialises t into w. The caller is responsible for the outer
// KindBytes length prefix (handled by params.go via VarintBytes).
func (t *Token) Append(w *wire.Writer) {
w.Varint(uint64(t.AliasType))
switch t.AliasType {
case AliasTypeDelete:
w.Varint(t.TokenAlias)
case AliasTypeRegister:
w.Varint(t.TokenAlias)
w.Varint(t.TokenType)
w.FixedBytes(t.TokenValue)
case AliasTypeUseAlias:
w.Varint(t.TokenAlias)
case AliasTypeUseValue:
w.Varint(t.TokenType)
w.FixedBytes(t.TokenValue)
}
}
// Bytes returns the serialised Token as a byte slice, suitable for use as the
// value of a KindBytes AUTHORIZATION_TOKEN parameter.
func (t *Token) Bytes() []byte {
var w wire.Writer
t.Append(&w)
return w.Bytes()
}
// Parse deserialises a Token from raw — the raw bytes of a KindBytes parameter
// value. Returns an error (caller should map to KEY_VALUE_FORMATTING_ERROR) if
// the bytes are malformed.
func (t *Token) Parse(raw []byte) error {
r := wire.NewReader(raw)
at, err := r.Varint()
if err != nil {
return fmt.Errorf("moqt/message: token alias type: %w", err)
}
t.AliasType = AliasType(at)
switch t.AliasType {
case AliasTypeDelete:
alias, err := r.Varint()
if err != nil {
return fmt.Errorf("moqt/message: token alias (DELETE): %w", err)
}
t.TokenAlias = alias
case AliasTypeRegister:
alias, err := r.Varint()
if err != nil {
return fmt.Errorf("moqt/message: token alias (REGISTER): %w", err)
}
tokenType, err := r.Varint()
if err != nil {
return fmt.Errorf("moqt/message: token type (REGISTER): %w", err)
}
// TokenValue occupies the remainder of the parameter value.
t.TokenAlias = alias
t.TokenType = tokenType
t.TokenValue = r.RemainingBytes()
case AliasTypeUseAlias:
alias, err := r.Varint()
if err != nil {
return fmt.Errorf("moqt/message: token alias (USE_ALIAS): %w", err)
}
t.TokenAlias = alias
case AliasTypeUseValue:
tokenType, err := r.Varint()
if err != nil {
return fmt.Errorf("moqt/message: token type (USE_VALUE): %w", err)
}
t.TokenType = tokenType
t.TokenValue = r.RemainingBytes()
default:
return fmt.Errorf("moqt/message: unknown token alias type 0x%X", at)
}
return nil
}
// AuthorizationTokenParam builds a typed AUTHORIZATION_TOKEN parameter
// (§10.2.2) from a Token. The Token is serialised to bytes and stored as a
// KindBytes parameter.
func AuthorizationTokenParam(t Token) Parameter {
return BytesParam(ParamAuthorizationToken, t.Bytes())
}
// TokensFromParam extracts and parses all AUTHORIZATION_TOKEN parameters from
// ps. The spec allows the parameter to be repeated within a message (§10.2.2:
// "The AUTHORIZATION TOKEN parameter MAY be repeated within a message as long
// as the combination of Token Type and Token Value are unique after resolving
// any aliases"). Returns an error if any Token is malformed.
func TokensFromParam(ps Parameters) ([]Token, error) {
var tokens []Token
for _, p := range ps {
if p.Type != ParamAuthorizationToken {
continue
}
var t Token
if err := t.Parse(p.Bytes); err != nil {
return nil, fmt.Errorf("moqt/message: AUTHORIZATION_TOKEN: %w", err)
}
tokens = append(tokens, t)
}
return tokens, nil
}
package message
import "github.com/floatdrop/moq-go/pkg/moqt/wire"
// TrackStatus is the TRACK_STATUS message (§10.15). It queries the status
// of a track without creating a subscription. The message format is identical
// to SUBSCRIBE, but subscriber-specific parameters (like SUBSCRIBER_PRIORITY)
// must not be included.
type TrackStatus struct {
RequestID uint64
Namespace wire.TrackNamespace
Name []byte
Parameters Parameters
}
// Type returns the wire type ID for TRACK_STATUS.
func (m *TrackStatus) Type() Type { return TypeTrackStatus }
func (m *TrackStatus) GetRequestID() uint64 { return m.RequestID }
func (m *TrackStatus) SetRequestID(id uint64) { m.RequestID = id }
// Append serializes the TRACK_STATUS message to w.
func (m *TrackStatus) Append(w *wire.Writer) {
w.Varint(m.RequestID)
w.TrackNamespace(m.Namespace)
w.VarintBytes(m.Name)
m.Parameters.append(w)
}
// Parse deserializes the TRACK_STATUS message from r.
func (m *TrackStatus) Parse(r *wire.Reader) error {
s := r.Scanner()
s.Varint(&m.RequestID)
s.TrackNamespace(&m.Namespace)
s.VarintBytes(&m.Name)
if err := s.Err(); err != nil {
return err
}
return m.Parameters.parse(r)
}
// TrackStatusOK is the TRACK_STATUS_OK response (§10.15). Per the spec,
// TRACK_STATUS_OK is a REQUEST_OK (type 0x07) sent in response to TRACK_STATUS.
// It carries the same parameters and Track Properties as SUBSCRIBE_OK, but
// without a Track Alias since no subscription is created.
//
// Use RequestOK directly when sending; TrackStatusOK is a convenience alias
// that wraps RequestOK for clarity at call sites.
type TrackStatusOK = RequestOK
// Validate enforces the §2.4.1 Full Track Name size limit; ParsePayload
// invokes it automatically after decoding a TRACK_STATUS frame.
func (m *TrackStatus) Validate() error {
return validateFullTrackName(m.Namespace, m.Name)
}
// Package message implements MoQT control- and request-stream message types
// per draft-ietf-moq-transport-20. Each Message exposes a wire Type and
// Append/Parse methods over wire.Writer/Reader.
//
// Marshal writes a complete control-message frame (Type + Length + Payload).
// Parse reads the payload only; the caller is expected to have already read
// the frame header via wire.ReadFrame and dispatched on Type.
package message
import (
"fmt"
"io"
"github.com/floatdrop/moq-go/pkg/moqt/wire"
)
// Type is the wire type ID for a MoQT message (§10, table 5).
type Type uint64
const (
TypeSetup Type = 0x2F00
TypeGoaway Type = 0x10
TypeSubscribe Type = 0x03
TypeSubscribeOK Type = 0x04
TypePublish Type = 0x1D
TypePublishStateNotify Type = 0x22
TypePublishDone Type = 0x0B
TypeRequestUpdate Type = 0x02
TypeRequestOK Type = 0x07
TypeRequestError Type = 0x05
TypeFetch Type = 0x16
TypeFetchOK Type = 0x18
TypeTrackStatus Type = 0x0D
TypePublishNamespace Type = 0x06
TypeNamespace Type = 0x08
TypeNamespaceDone Type = 0x0E
TypeSubscribeNamespace Type = 0x50
TypeSubscribeTracks Type = 0x51
TypePublishSkipped Type = 0x0F
)
// Message is the interface implemented by all in-scope MoQT control- and
// request-stream messages.
type Message interface {
// Type returns the wire type ID.
Type() Type
// Append serializes the message payload to w.
Append(w *wire.Writer)
// Parse deserializes the message payload from r. r is expected to be
// bounded to the payload length (i.e. the wire-level frame length).
Parse(r *wire.Reader) error
}
// WithRequestID is implemented by messages that carry a Request ID as their
// first field (§10.1). These are the messages that can appear as the first
// message on a request stream: SUBSCRIBE, PUBLISH, FETCH, TRACK_STATUS,
// PUBLISH_NAMESPACE, SUBSCRIBE_NAMESPACE, SUBSCRIBE_TRACKS, and
// REQUEST_UPDATE.
type WithRequestID interface {
Message
// GetRequestID returns the Request ID carried by this message.
GetRequestID() uint64
// SetRequestID overwrites the Request ID carried by this message. The
// session uses it to assign a freshly allocated ID (§10.1) after a
// request stream is opened, so a failed open consumes no ID.
SetRequestID(uint64)
}
// Marshal writes m as a complete control-message frame to dst.
func Marshal(dst io.Writer, m Message) error {
w := wire.NewWriter(nil)
m.Append(w)
return wire.WriteFrame(dst, uint64(m.Type()), w.Bytes())
}
// Parse reads a single control-message frame from src and returns a typed
// Message. Unknown message types are returned as ErrUnknownType.
func Parse(src io.Reader) (Message, error) {
t, payload, err := wire.ReadFrame(src)
if err != nil {
return nil, err
}
return ParsePayload(Type(t), payload)
}
// ParsePayload constructs a Message of the given Type and parses payload into
// it. Use when the caller has already read the frame header.
func ParsePayload(t Type, payload []byte) (Message, error) {
m, err := newMessage(t)
if err != nil {
return nil, err
}
r := wire.NewReader(payload)
if err := m.Parse(r); err != nil {
return nil, fmt.Errorf("moqt/message: parsing %s: %w", t, err)
}
if !r.Empty() {
return nil, fmt.Errorf("moqt/message: %s has %d trailing bytes", t, r.Remaining())
}
if v, ok := m.(validator); ok {
if err := v.Validate(); err != nil {
return nil, fmt.Errorf("moqt/message: validating %s: %w", t, err)
}
}
return m, nil
}
// validator is implemented by messages that enforce field-level invariants the
// wire decoder cannot catch on its own (e.g. a FETCH whose full track name
// exceeds §2.4.1's 4,096-byte cap). ParsePayload invokes Validate after a
// successful decode so a malformed-but-decodable control message is rejected at
// the message boundary — the session layer treats the resulting error as a
// PROTOCOL_VIOLATION — rather than propagating bad state inward.
type validator interface {
Validate() error
}
// ErrUnknownType is returned for a message type not implemented by this
// package. Per §10 the receiver MUST close the session with
// PROTOCOL_VIOLATION; callers translate accordingly.
type ErrUnknownType Type
func (e ErrUnknownType) Error() string {
return fmt.Sprintf("moqt/message: unknown type %#x", uint64(e))
}
func newMessage(t Type) (Message, error) {
switch t {
case TypeSetup:
return &Setup{}, nil
case TypeGoaway:
return &Goaway{}, nil
case TypeSubscribe:
return &Subscribe{}, nil
case TypeSubscribeOK:
return &SubscribeOK{}, nil
case TypePublish:
return &Publish{}, nil
case TypePublishStateNotify:
return &PublishStateNotify{}, nil
case TypePublishDone:
return &PublishDone{}, nil
case TypeRequestUpdate:
return &RequestUpdate{}, nil
case TypeRequestOK:
return &RequestOK{}, nil
case TypeRequestError:
return &RequestError{}, nil
case TypeFetch:
return &Fetch{}, nil
case TypeFetchOK:
return &FetchOK{}, nil
case TypeTrackStatus:
return &TrackStatus{}, nil
case TypePublishNamespace:
return &PublishNamespace{}, nil
case TypeNamespace:
return &Namespace{}, nil
case TypeNamespaceDone:
return &NamespaceDone{}, nil
case TypeSubscribeNamespace:
return &SubscribeNamespace{}, nil
case TypeSubscribeTracks:
return &SubscribeTracks{}, nil
case TypePublishSkipped:
return &PublishSkipped{}, nil
}
return nil, ErrUnknownType(t)
}
// String returns a short identifier for the message type.
func (t Type) String() string {
switch t {
case TypeSetup:
return "SETUP"
case TypeGoaway:
return "GOAWAY"
case TypeSubscribe:
return "SUBSCRIBE"
case TypeSubscribeOK:
return "SUBSCRIBE_OK"
case TypePublish:
return "PUBLISH"
case TypePublishStateNotify:
return "PUBLISH_STATE_NOTIFY"
case TypePublishDone:
return "PUBLISH_DONE"
case TypeRequestUpdate:
return "REQUEST_UPDATE"
case TypeRequestOK:
return "REQUEST_OK"
case TypeRequestError:
return "REQUEST_ERROR"
case TypeFetch:
return "FETCH"
case TypeFetchOK:
return "FETCH_OK"
case TypeTrackStatus:
return "TRACK_STATUS"
case TypePublishNamespace:
return "PUBLISH_NAMESPACE"
case TypeNamespace:
return "NAMESPACE"
case TypeNamespaceDone:
return "NAMESPACE_DONE"
case TypeSubscribeNamespace:
return "SUBSCRIBE_NAMESPACE"
case TypeSubscribeTracks:
return "SUBSCRIBE_TRACKS"
case TypePublishSkipped:
return "PUBLISH_SKIPPED"
}
return fmt.Sprintf("Type(%#x)", uint64(t))
}
package msf
import (
"bytes"
"encoding/json"
"fmt"
)
// Catalog is an MSF catalog document (§5). A Catalog is either:
//
// - An independent catalog: Version is set and Tracks lists the full
// output of the publisher (§5.1).
// - A delta update: DeltaUpdate carries an ordered list of operations
// and Version / Tracks MUST be absent (§5.3).
//
// Catalog preserves producer-defined fields not described by the draft
// in Extras (§5.1). Unknown fields round-trip verbatim. Fields not
// listed in the draft and not present in Extras are silently dropped on
// re-serialisation.
//
// As of draft-01 a delta update is expressed as the deltaUpdate array
// (§5.1.6): an ordered sequence of [DeltaOp] objects, each naming an
// "op" ("add"/"remove"/"clone") and a list of track objects. [Apply]
// replays the operations in order per §5.3.
//
//nolint:recvcheck // MarshalJSON must stay on a value receiver so non-pointer Catalog values marshal; the mutating/validating methods must stay on pointers.
type Catalog struct {
Version string `json:"version,omitempty"`
GeneratedAt int64 `json:"generatedAt,omitempty"`
IsComplete bool `json:"isComplete,omitempty"`
// Tracks is required in independent catalogs (§5.1.4). Empty
// slices (terminator catalogs per §11.3) and nil slices are
// emitted differently: see [Catalog.MarshalJSON].
Tracks []Track `json:"tracks"`
// PublishTracks declares tracks the subscriber may publish to,
// such as logs or metrics (§5.1.5).
PublishTracks []Track `json:"publishTracks,omitempty"`
// DeltaUpdate, when non-nil, marks this catalog as a delta update
// (§5.1.6). It is an ordered list of operations applied by [Apply].
DeltaUpdate []DeltaOp `json:"deltaUpdate,omitempty"`
// InitDataList holds initialization payloads referenced by tracks
// via Track.InitRef (§5.1.7). Per §5.1.7 it SHOULD appear after the
// tracks array in the document.
InitDataList []InitData `json:"initDataList,omitempty"`
// ContentProtections declares DRM/CENC key-acquisition metadata
// referenced by tracks via Track.ContentProtectionRefIDs
// (draft-ietf-moq-cmsf-01 §4.1.1). Per §4.1.1, content protection
// information MUST NOT be duplicated at the track level.
ContentProtections []ContentProtection `json:"contentProtections,omitempty"`
// Extras holds producer-defined catalog-root fields. Keys MUST NOT
// collide with the known field names; this is the producer's
// responsibility (§5.1).
Extras map[string]any `json:"-"`
}
// IsDelta reports whether the catalog is a delta update (§5.1.6) rather
// than an independent catalog.
func (c *Catalog) IsDelta() bool {
return c.DeltaUpdate != nil
}
// DeltaOp is one entry in a catalog's deltaUpdate array (§5.1.6). Op is
// one of [DeltaOpAdd], [DeltaOpRemove] or [DeltaOpClone]; Tracks is the
// list of track objects the operation applies, in document order.
type DeltaOp struct {
Op string `json:"op"`
Tracks []Track `json:"tracks"`
}
// InitData is one entry in a catalog's initDataList (§5.1.7). Type is
// the reference type ([InitDataTypeInline] is the only one defined) and
// Data carries the payload as defined by that type.
type InitData struct {
ID string `json:"id"`
Type string `json:"type"`
Data string `json:"data"`
}
// Buffers describes a track's target jitter/forward buffers (§5.2.9).
// All keys are optional; absent keys leave the player free to choose.
type Buffers struct {
Target *uint32 `json:"target,omitempty"`
Min *uint32 `json:"min,omitempty"`
Max *uint32 `json:"max,omitempty"`
}
// Accessibility is one accessibility descriptor embedded in a track
// (§5.2.44): a scheme identifier and a scheme-specific value.
type Accessibility struct {
Scheme string `json:"scheme"`
Value string `json:"value"`
}
// Track is a single entry in a Catalog's Tracks / PublishTracks array
// or in a [DeltaOp]'s Tracks list (§5.2.1). Most fields are optional;
// required fields depend on the role this track plays:
//
// - Independent catalog tracks: Name, Packaging, IsLive required.
// - add / clone operation entries: Name required.
// - remove operation entries: Name required, all other fields MUST be
// absent (§5.1.6).
// - clone operation entries: ParentName required (§5.1.6).
//
// Pointer-typed fields (IsLive, TargetLatency, RenderGroup, AltGroup,
// TemporalID, SpatialID, Buffers, Template, MaxGrpSapStartingType,
// MaxObjSapStartingType) distinguish "field absent"
// from "field set to zero/false". The remaining numeric / string fields
// use omitempty because zero is never a valid catalog value (e.g.
// bitrate=0).
type Track struct {
Name string `json:"name,omitempty"`
Namespace string `json:"namespace,omitempty"`
Packaging string `json:"packaging,omitempty"`
EventType string `json:"eventType,omitempty"`
IsLive *bool `json:"isLive,omitempty"`
TargetLatency *uint32 `json:"targetLatency,omitempty"`
Buffers *Buffers `json:"buffers,omitempty"`
Role string `json:"role,omitempty"`
Label string `json:"label,omitempty"`
RenderGroup *int `json:"renderGroup,omitempty"`
AltGroup *int `json:"altGroup,omitempty"`
InitRef string `json:"initRef,omitempty"`
Depends []string `json:"depends,omitempty"`
Template *MediaTimelineTemplate `json:"template,omitempty"`
TemporalID *int `json:"temporalId,omitempty"`
SpatialID *int `json:"spatialId,omitempty"`
Codec string `json:"codec,omitempty"`
Mimetype string `json:"mimetype,omitempty"`
Framerate float64 `json:"framerate,omitempty"`
Timescale uint32 `json:"timescale,omitempty"`
Bitrate uint64 `json:"bitrate,omitempty"`
AvgBitrate uint64 `json:"avgBitrate,omitempty"`
MaxGopDuration uint64 `json:"maxGopDuration,omitempty"`
MaxGroupDuration uint64 `json:"maxGroupDuration,omitempty"`
Width uint32 `json:"width,omitempty"`
Height uint32 `json:"height,omitempty"`
Samplerate uint32 `json:"samplerate,omitempty"`
ChannelConfig string `json:"channelConfig,omitempty"`
DisplayWidth uint32 `json:"displayWidth,omitempty"`
DisplayHeight uint32 `json:"displayHeight,omitempty"`
Lang string `json:"lang,omitempty"`
ParentName string `json:"parentName,omitempty"`
ParentNamespace string `json:"parentNamespace,omitempty"`
TrackDuration uint64 `json:"trackDuration,omitempty"`
ConnectionURI string `json:"connectionUri,omitempty"`
Token string `json:"token,omitempty"`
EncryptionScheme string `json:"encryptionScheme,omitempty"`
CipherSuite string `json:"cipherSuite,omitempty"`
KeyID string `json:"keyId,omitempty"`
TrackBaseKey string `json:"trackBaseKey,omitempty"`
AuthInfo map[string]any `json:"authInfo,omitempty"`
Accessibility []Accessibility `json:"accessibility,omitempty"`
// MaxGrpSapStartingType and MaxObjSapStartingType bound the stream
// access point type a Group / Object may start with
// (draft-ietf-moq-cmsf-01 §3.5.2.1, §3.5.2.2). Valid range 0-3.
MaxGrpSapStartingType *int `json:"maxGrpSapStartingType,omitempty"`
MaxObjSapStartingType *int `json:"maxObjSapStartingType,omitempty"`
// ContentProtectionRefIDs references Catalog.ContentProtections
// entries by RefID (CMSF §4.1.2). Presence means the track is
// CENC-encrypted and a subscriber MUST acquire licenses before
// decryption.
ContentProtectionRefIDs []string `json:"contentProtectionRefIDs,omitempty"`
// Extras holds producer-defined per-track fields (§5.6.6 example).
// Keys MUST NOT collide with known field names.
Extras map[string]any `json:"-"`
}
// knownCatalogFields lists every JSON key produced by Catalog's typed
// fields. Used during UnmarshalJSON to separate known fields from
// Extras.
var knownCatalogFields = map[string]struct{}{
"version": {},
"generatedAt": {},
"isComplete": {},
"tracks": {},
"publishTracks": {},
"deltaUpdate": {},
"initDataList": {},
"contentProtections": {},
}
// knownTrackFields lists every JSON key produced by Track's typed
// fields.
var knownTrackFields = map[string]struct{}{
"name": {},
"namespace": {},
"packaging": {},
"eventType": {},
"isLive": {},
"targetLatency": {},
"buffers": {},
"role": {},
"label": {},
"renderGroup": {},
"altGroup": {},
"initRef": {},
"depends": {},
"template": {},
"temporalId": {},
"spatialId": {},
"codec": {},
"mimetype": {},
"framerate": {},
"timescale": {},
"bitrate": {},
"avgBitrate": {},
"maxGopDuration": {},
"maxGroupDuration": {},
"width": {},
"height": {},
"samplerate": {},
"channelConfig": {},
"displayWidth": {},
"displayHeight": {},
"lang": {},
"parentName": {},
"parentNamespace": {},
"trackDuration": {},
"connectionUri": {},
"token": {},
"encryptionScheme": {},
"cipherSuite": {},
"keyId": {},
"trackBaseKey": {},
"authInfo": {},
"accessibility": {},
"maxGrpSapStartingType": {},
"maxObjSapStartingType": {},
"contentProtectionRefIDs": {},
}
// trackAlias decouples the JSON tag-driven marshaller from the
// Catalog/Track methods so MarshalJSON / UnmarshalJSON do not recurse.
type trackAlias Track
// catalogAlias plays the same role as trackAlias for Catalog.
type catalogAlias Catalog
// MarshalJSON emits the typed Track fields and merges Extras. If a key
// in Extras shadows a typed field the typed field wins; the collision
// is silently resolved in favour of the typed value because §5.1 makes
// collision the producer's responsibility.
func (t Track) MarshalJSON() ([]byte, error) {
return mergeMarshal(trackAlias(t), t.Extras, knownTrackFields)
}
// UnmarshalJSON parses the typed Track fields and stores any other
// keys in Extras.
func (t *Track) UnmarshalJSON(data []byte) error {
var alias trackAlias
if err := strictUnmarshal(data, &alias); err != nil {
return fmt.Errorf("moqt/msf: track: %w", err)
}
*t = Track(alias)
extras, err := extractExtras(data, knownTrackFields)
if err != nil {
return fmt.Errorf("moqt/msf: track extras: %w", err)
}
t.Extras = extras
return nil
}
// MarshalJSON emits the typed Catalog fields and merges Extras.
//
// MarshalJSON enforces the §5.1.4 / §5.3 rules around the tracks key:
//
// - Independent catalogs (DeltaUpdate==nil) always include "tracks";
// a nil slice is emitted as the empty array [] expected by the
// §11.3 terminator example.
// - Delta updates (DeltaUpdate!=nil) MUST NOT include "tracks" (§5.3);
// MarshalJSON drops the key.
func (c Catalog) MarshalJSON() ([]byte, error) {
if c.IsDelta() {
// Strip Tracks so the alias marshaller emits "tracks": null,
// then post-process to drop the key entirely. Using a custom
// post-process keeps the typed-fields path symmetrical with
// independent catalogs.
c.Tracks = nil
data, err := mergeMarshal(catalogAlias(c), c.Extras, knownCatalogFields)
if err != nil {
return nil, err
}
return stripNullTracks(data)
}
if c.Tracks == nil {
c.Tracks = []Track{}
}
return mergeMarshal(catalogAlias(c), c.Extras, knownCatalogFields)
}
// stripNullTracks removes a "tracks": null entry from the top-level
// JSON object. Used by MarshalJSON for delta catalogs.
func stripNullTracks(data []byte) ([]byte, error) {
var m map[string]json.RawMessage
if err := json.Unmarshal(data, &m); err != nil {
return nil, err
}
if raw, ok := m["tracks"]; ok && bytes.Equal(raw, []byte("null")) {
delete(m, "tracks")
}
return json.Marshal(m)
}
// UnmarshalJSON parses the typed Catalog fields and stores unknown
// catalog-root keys in Extras.
func (c *Catalog) UnmarshalJSON(data []byte) error {
var alias catalogAlias
if err := strictUnmarshal(data, &alias); err != nil {
return fmt.Errorf("moqt/msf: catalog: %w", err)
}
*c = Catalog(alias)
extras, err := extractExtras(data, knownCatalogFields)
if err != nil {
return fmt.Errorf("moqt/msf: catalog extras: %w", err)
}
c.Extras = extras
return nil
}
// mergeMarshal serialises v (which must have JSON tags matching the
// known field set) and merges entries from extras whose keys are not
// already produced by v. Keys in extras that collide with v's known
// fields are silently dropped.
func mergeMarshal(v any, extras map[string]any, known map[string]struct{}) ([]byte, error) {
base, err := json.Marshal(v)
if err != nil {
return nil, err
}
if len(extras) == 0 {
return base, nil
}
// Decode base back into a map so we can re-emit in deterministic
// order. The size cost is acceptable for catalog documents.
var merged map[string]json.RawMessage
if err := json.Unmarshal(base, &merged); err != nil {
return nil, err
}
for k, val := range extras {
if _, isKnown := known[k]; isKnown {
continue
}
raw, err := json.Marshal(val)
if err != nil {
return nil, fmt.Errorf("moqt/msf: marshal extras[%q]: %w", k, err)
}
merged[k] = raw
}
return json.Marshal(merged)
}
// extractExtras returns the entries in data whose keys are not in known.
// Returns nil (not empty map) when there are no extras, so callers can
// omit the field entirely on a fresh struct.
func extractExtras(data []byte, known map[string]struct{}) (map[string]any, error) {
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return nil, err
}
var extras map[string]any
for k, v := range raw {
if _, isKnown := known[k]; isKnown {
continue
}
var decoded any
if err := json.Unmarshal(v, &decoded); err != nil {
return nil, fmt.Errorf("extras[%q]: %w", k, err)
}
if extras == nil {
extras = map[string]any{}
}
extras[k] = decoded
}
return extras, nil
}
// strictUnmarshal decodes data into v. It uses a Decoder so future
// additions (e.g. DisallowUnknownFields) can be enabled without
// touching every call site.
func strictUnmarshal(data []byte, v any) error {
dec := json.NewDecoder(bytes.NewReader(data))
return dec.Decode(v)
}
package msf
import "fmt"
// Scheme values for ContentProtection.Scheme (draft-ietf-moq-cmsf-01
// §4.1.1.3, Table 3). SchemeCBCS is RECOMMENDED for better hardware
// decoder compatibility.
const (
SchemeCENC = "cenc"
SchemeCBCS = "cbcs"
)
// Well-known DRM system IDs for DRMSystem.SystemID (CMSF §4.1.1.4.1,
// Table 4).
const (
DRMSystemIDWidevine = "edef8ba9-79d6-4ace-a3c8-27dcd51d21ed"
DRMSystemIDPlayReady = "9a04f079-9840-4286-ab92-e65be0885f95"
DRMSystemIDFairPlay = "94ce86fb-07ff-4f43-adb8-93d2fa968ca2"
DRMSystemIDClearKey = "1077efec-c0b2-4d02-ace3-3c1e52e2fb4b"
)
// EventTypeCMSFSAP is the eventType value for a SAP Type timeline
// track (CMSF §3.6.1): a track with Packaging ==
// [PackagingEventTimeline] and EventType == EventTypeCMSFSAP. Its
// records convey the distribution of Stream Access Point types and
// their earliest presentation times; use [SAPRecord] to encode and
// decode them.
const EventTypeCMSFSAP = "org.ietf.moq.cmsf.sap"
// ContentProtection is one root-level entry in a catalog's
// contentProtections array (CMSF §4.1.1). Tracks reference an entry by
// RefID via Track.ContentProtectionRefIDs; per §4.1.1, content
// protection information MUST NOT be duplicated at the track level —
// all tracks reference these root-level entries.
//
// CMSF §4.2 additionally requires the initialization data of a
// protected track to carry the 'sinf'/'schm'/'schi'/'tenc' boxes. That
// data is opaque Base64 ISO BMFF in InitData.Data, which this package
// does not parse, so the requirement is the producer's to meet.
type ContentProtection struct {
RefID string `json:"refID"`
DefaultKID []string `json:"defaultKID"`
Scheme string `json:"scheme"`
DRMSystem DRMSystem `json:"drmSystem"`
// Extras holds producer-defined fields on this entry, mirroring
// [Catalog].Extras and [Track].Extras so a re-serialised catalog
// preserves keys this implementation does not know.
Extras map[string]any `json:"-"`
}
// DRMSystem describes one DRM system's key-acquisition metadata within
// a ContentProtection entry (CMSF §4.1.1.4).
type DRMSystem struct {
SystemID string `json:"systemID"`
// LAURL and CertURL are §4.1.1.4.2 and §4.1.1.4.3, whose JSON keys
// the §5 examples fix.
//
// §4.1.1.4.4's Authorization URL has no typed field: the section
// never names its JSON key and no example carries one, and since
// laURL/certURL are abbreviations rather than section-title camel
// case, any spelling this package chose would be a guess a future
// revision could contradict. Extras carries it losslessly under
// whatever key the producer used, so nothing is dropped; a typed
// field can be added once a draft names one.
LAURL *URLRef `json:"laURL,omitempty"`
CertURL *URLRef `json:"certURL,omitempty"`
PSSH string `json:"pssh,omitempty"`
Robustness string `json:"robustness,omitempty"`
// Extras holds producer-defined fields on this object.
Extras map[string]any `json:"-"`
}
// URLRef is a {url, type} pair used by DRMSystem.LAURL and CertURL
// (CMSF §4.1.1.4.2, §4.1.1.4.3). URL is required whenever the enclosing
// object is present; Type is optional and its meaning is per-field
// (license protocol, certificate MIME type).
type URLRef struct {
URL string `json:"url"`
Type string `json:"type,omitempty"`
}
// knownContentProtectionFields lists every JSON key produced by
// ContentProtection's typed fields.
var knownContentProtectionFields = map[string]struct{}{
"refID": {},
"defaultKID": {},
"scheme": {},
"drmSystem": {},
}
// knownDRMSystemFields lists every JSON key produced by DRMSystem's
// typed fields.
var knownDRMSystemFields = map[string]struct{}{
"systemID": {},
"laURL": {},
"certURL": {},
"pssh": {},
"robustness": {},
}
// contentProtectionAlias and drmSystemAlias play the same role as
// [trackAlias]: they decouple the JSON tag-driven marshaller from the
// MarshalJSON / UnmarshalJSON methods so the calls do not recurse.
type (
contentProtectionAlias ContentProtection
drmSystemAlias DRMSystem
)
// MarshalJSON emits the typed fields and merges Extras.
func (p ContentProtection) MarshalJSON() ([]byte, error) {
return mergeMarshal(contentProtectionAlias(p), p.Extras, knownContentProtectionFields)
}
// UnmarshalJSON parses the typed fields and stores any other keys in
// Extras.
func (p *ContentProtection) UnmarshalJSON(data []byte) error {
var alias contentProtectionAlias
if err := strictUnmarshal(data, &alias); err != nil {
return fmt.Errorf("moqt/msf: contentProtection: %w", err)
}
*p = ContentProtection(alias)
extras, err := extractExtras(data, knownContentProtectionFields)
if err != nil {
return fmt.Errorf("moqt/msf: contentProtection extras: %w", err)
}
p.Extras = extras
return nil
}
// MarshalJSON emits the typed fields and merges Extras.
func (d DRMSystem) MarshalJSON() ([]byte, error) {
return mergeMarshal(drmSystemAlias(d), d.Extras, knownDRMSystemFields)
}
// UnmarshalJSON parses the typed fields and stores any other keys in
// Extras.
func (d *DRMSystem) UnmarshalJSON(data []byte) error {
var alias drmSystemAlias
if err := strictUnmarshal(data, &alias); err != nil {
return fmt.Errorf("moqt/msf: drmSystem: %w", err)
}
*d = DRMSystem(alias)
extras, err := extractExtras(data, knownDRMSystemFields)
if err != nil {
return fmt.Errorf("moqt/msf: drmSystem extras: %w", err)
}
d.Extras = extras
return nil
}
package msf
import (
"encoding/json"
"fmt"
)
// SAPRecord is one decoded record of a SAP Type timeline track
// (CMSF §3.6.1). On the wire it is an Event Timeline record indexed by
// Location ('l') whose data field is a two-integer JSON array:
//
// { "l": [GroupID, ObjectID], "data": [SAPType, EPT] }
type SAPRecord struct {
GroupID uint64
ObjectID uint64
// SAPType is 0-3. 0 means the Object does not start with an ISOBMFF
// stream access point; 1, 2 and 3 mean it begins with a SAP of that
// type. When the Object is the first in its Group the value MUST be
// 1 or 2.
SAPType int
// EPT is the earliest media presentation timestamp, rounded to the
// nearest millisecond, of all media samples in the Object the
// record's location identifies.
EPT int64
}
// validate enforces CMSF §3.6.1's constraints on the SAP type.
func (r SAPRecord) validate() error {
if r.SAPType < 0 || r.SAPType > 3 {
return fmt.Errorf("moqt/msf: sap record: sapType %d out of range 0-3 (CMSF §3.6.1)", r.SAPType)
}
// §3.6.1: "When the Object is the first Object in the Group, the
// value MUST be equal to 1 or 2." This restates §3.4's requirement
// that every Group begin with a SAP type 1 or 2 Object.
//
// Object ID 0 is the only first-in-Group case a single record can
// prove: [MoQTransport] §2.1 lets Object IDs start above 0 and skip
// values, so a Group whose first Object is, say, 5 is
// indistinguishable here from a mid-Group record. Checking it would
// need the whole Group, and a timeline document may legitimately
// begin mid-Group, so the stricter check would reject conformant
// input. Producers remain responsible for §3.4 in that case.
if r.ObjectID == 0 && r.SAPType != 1 && r.SAPType != 2 {
return fmt.Errorf(
"moqt/msf: sap record: group %d starts with sapType %d, MUST be 1 or 2 (CMSF §3.6.1)",
r.GroupID, r.SAPType)
}
return nil
}
// EventRecord encodes r as the Event Timeline record CMSF §3.6.1
// defines. It reports an error if r violates the section's SAP-type
// constraints.
func (r SAPRecord) EventRecord() (EventRecord, error) {
if err := r.validate(); err != nil {
return EventRecord{}, err
}
data, err := json.Marshal([2]int64{int64(r.SAPType), r.EPT})
if err != nil {
return EventRecord{}, err
}
return EventRecord{
Index: EventIndexLocation,
GroupID: r.GroupID,
ObjectID: r.ObjectID,
Data: data,
}, nil
}
// ParseSAPRecord decodes one record of a SAP Type timeline track,
// enforcing CMSF §3.6.1: the record MUST be indexed by Location and
// its data field MUST be two integers whose first is a valid SAP type.
func ParseSAPRecord(rec EventRecord) (SAPRecord, error) {
if rec.Index != EventIndexLocation {
return SAPRecord{}, fmt.Errorf(
"moqt/msf: sap record: index must be 'l' for Location, got %d (CMSF §3.6.1)", rec.Index)
}
var pair []int64
if err := json.Unmarshal(rec.Data, &pair); err != nil {
return SAPRecord{}, fmt.Errorf("moqt/msf: sap record data: %w", err)
}
if len(pair) != 2 {
return SAPRecord{}, fmt.Errorf(
"moqt/msf: sap record data: expected 2 items, got %d (CMSF §3.6.1)", len(pair))
}
out := SAPRecord{
GroupID: rec.GroupID,
ObjectID: rec.ObjectID,
SAPType: int(pair[0]),
EPT: pair[1],
}
if err := out.validate(); err != nil {
return SAPRecord{}, err
}
return out, nil
}
package msf
import (
"errors"
"fmt"
"maps"
"reflect"
"slices"
)
// Apply replays delta against base and returns the resulting catalog
// per §5.3. Apply does not mutate base or delta.
//
// Operations are processed in the order they appear in
// delta.DeltaUpdate; within each operation, its Tracks are applied in
// order. This matches the document order §5.3 requires.
//
// Errors:
// - ErrNotDelta if delta is not a delta update.
// - A descriptive error if any operation violates §5.3 (e.g.
// adding a track whose Namespace+Name already exists, cloning
// from a missing parent).
func Apply(base, delta Catalog) (Catalog, error) {
if !delta.IsDelta() {
return Catalog{}, ErrNotDelta
}
out := cloneCatalog(base)
// §5.3 restricts deltaUpdate to track operations and forbids only
// the tracks and version fields at the root, so a delta MAY carry
// the root-level arrays a newly added track references. It has to:
// CMSF §3.1 requires every CMAF track to name an initDataList entry
// through initRef, and CMSF §4.1.2 requires a protected track's
// contentProtectionRefIDs to resolve. Merge them before replaying
// the operations so an added track can reference them.
if err := mergeInitDataList(&out, delta.InitDataList); err != nil {
return Catalog{}, err
}
if err := mergeContentProtections(&out, delta.ContentProtections); err != nil {
return Catalog{}, err
}
for i, op := range delta.DeltaUpdate {
switch op.Op {
case DeltaOpAdd:
for j, tr := range op.Tracks {
if err := applyAdd(&out, tr); err != nil {
return Catalog{}, fmt.Errorf("moqt/msf: deltaUpdate[%d].tracks[%d]: %w", i, j, err)
}
}
case DeltaOpRemove:
for j, tr := range op.Tracks {
if err := applyRemove(&out, tr); err != nil {
return Catalog{}, fmt.Errorf("moqt/msf: deltaUpdate[%d].tracks[%d]: %w", i, j, err)
}
}
case DeltaOpClone:
for j, tr := range op.Tracks {
if err := applyClone(&out, tr); err != nil {
return Catalog{}, fmt.Errorf("moqt/msf: deltaUpdate[%d].tracks[%d]: %w", i, j, err)
}
}
default:
return Catalog{}, fmt.Errorf("moqt/msf: deltaUpdate[%d]: unknown op %q", i, op.Op)
}
}
out.DeltaUpdate = nil
if delta.GeneratedAt != 0 {
out.GeneratedAt = delta.GeneratedAt
}
// Whether a track's initRef and contentProtectionRefIDs resolve is
// cross-document state: the entries may come from the base, from
// this delta, or from an earlier one. [Catalog.Validate] cannot see
// that, so checking it is Apply's job.
if err := validateTrackReferences(&out); err != nil {
return Catalog{}, err
}
return out, nil
}
// ErrNotDelta is returned by [Apply] when the delta argument is not a
// delta update (deltaUpdate absent).
var ErrNotDelta = errors.New("moqt/msf: catalog is not a delta update")
// mergeInitDataList folds a delta's initDataList entries (§5.1.7) into
// out. Re-sending an identical entry is a no-op; redefining an existing
// id with different content is rejected, because §5.1.7 requires the id
// to be unique within the scope of the catalog.
func mergeInitDataList(out *Catalog, add []InitData) error {
for _, entry := range add {
if entry.ID == "" {
return errors.New("moqt/msf: delta initDataList: id is required (§5.1.7)")
}
i := slices.IndexFunc(out.InitDataList, func(e InitData) bool { return e.ID == entry.ID })
if i < 0 {
out.InitDataList = append(out.InitDataList, entry)
continue
}
if out.InitDataList[i] != entry {
return fmt.Errorf("moqt/msf: delta initDataList: id %q redefined (§5.1.7)", entry.ID)
}
}
return nil
}
// mergeContentProtections folds a delta's contentProtections entries
// (CMSF §4.1.1) into out under the same rule as [mergeInitDataList]:
// identical re-sends are idempotent, conflicting redefinitions of a
// refID are rejected.
func mergeContentProtections(out *Catalog, add []ContentProtection) error {
for _, entry := range cloneContentProtections(add) {
if entry.RefID == "" {
return errors.New("moqt/msf: delta contentProtections: refID is required (CMSF §4.1.1.1)")
}
i := slices.IndexFunc(out.ContentProtections, func(e ContentProtection) bool {
return e.RefID == entry.RefID
})
if i < 0 {
out.ContentProtections = append(out.ContentProtections, entry)
continue
}
if !reflect.DeepEqual(out.ContentProtections[i], entry) {
return fmt.Errorf(
"moqt/msf: delta contentProtections: refID %q redefined (CMSF §4.1.1.1)", entry.RefID)
}
}
return nil
}
// applyAdd processes one add-operation track. §5.3 — adding a track
// whose (Namespace, Name) already exists is rejected; the registry has
// a fixed-attribute invariant per §5.3.
func applyAdd(out *Catalog, add Track) error {
if add.Name == "" {
return errors.New("add entry missing name")
}
for _, existing := range out.Tracks {
if sameTrackID(existing, add) {
return fmt.Errorf("track %q (ns=%q) already exists", add.Name, add.Namespace)
}
}
out.Tracks = append(out.Tracks, add)
return nil
}
// applyRemove drops the named track from out.Tracks. §5.1.6 — only
// Name is required, Namespace is optional. The match is exact when
// Namespace is provided, else by Name alone.
func applyRemove(out *Catalog, rm Track) error {
if rm.Name == "" {
return errors.New("remove entry missing name")
}
for i, existing := range out.Tracks {
if rm.Namespace != "" && existing.Namespace != rm.Namespace {
continue
}
if existing.Name != rm.Name {
continue
}
out.Tracks = append(out.Tracks[:i], out.Tracks[i+1:]...)
return nil
}
// §5.1.6 doesn't explicitly require erroring on a missing remove,
// but rejecting it surfaces producer mistakes early.
return fmt.Errorf("no such track %q (ns=%q)", rm.Name, rm.Namespace)
}
// applyClone creates a new track that inherits the attributes of its
// parent (looked up by ParentName and optional ParentNamespace) and
// overrides any explicitly-set fields on the clone entry. §5.3 — the
// clone MUST have a different Track Name.
func applyClone(out *Catalog, clone Track) error {
if clone.ParentName == "" {
return errors.New("clone entry: parentName required")
}
if clone.Name == "" {
return errors.New("clone entry: name required")
}
parent, ok := findTrack(out, clone.ParentName, clone.ParentNamespace)
if !ok {
return fmt.Errorf("clone entry: parent %q not found", clone.ParentName)
}
// Start from a deep copy of the parent then overlay non-zero fields
// from the clone definition. ParentName/ParentNamespace are consumed
// and not carried onto the resulting track. The copy has to be deep:
// a clone entry that omits a slice field inherits the parent's, and
// the two tracks must not share its backing array.
merged := cloneTrack(parent)
overlayTrack(&merged, clone)
merged.Name = clone.Name
merged.ParentName = ""
merged.ParentNamespace = ""
if sameTrackID(parent, merged) {
return fmt.Errorf("clone entry: clone name %q matches parent", merged.Name)
}
for _, existing := range out.Tracks {
if sameTrackID(existing, merged) {
return fmt.Errorf("clone entry: resulting track %q (ns=%q) already exists",
merged.Name, merged.Namespace)
}
}
out.Tracks = append(out.Tracks, merged)
return nil
}
// overlayTrack copies set fields from src onto dst. Pointer fields and
// slices/maps are taken if non-nil; scalars are taken if non-zero. The
// rule is "if the producer set it on the clone, prefer the clone".
// Split across three helpers to keep each within gocyclo's bound.
func overlayTrack(dst *Track, src Track) {
overlayTrackStrings(dst, src)
overlayTrackNumbers(dst, src)
overlayTrackComposite(dst, src)
}
func overlayTrackStrings(dst *Track, src Track) {
if src.Namespace != "" {
dst.Namespace = src.Namespace
}
if src.Packaging != "" {
dst.Packaging = src.Packaging
}
if src.EventType != "" {
dst.EventType = src.EventType
}
if src.Role != "" {
dst.Role = src.Role
}
if src.Label != "" {
dst.Label = src.Label
}
if src.InitRef != "" {
dst.InitRef = src.InitRef
}
if src.Codec != "" {
dst.Codec = src.Codec
}
if src.Mimetype != "" {
dst.Mimetype = src.Mimetype
}
if src.ChannelConfig != "" {
dst.ChannelConfig = src.ChannelConfig
}
if src.Lang != "" {
dst.Lang = src.Lang
}
if src.ConnectionURI != "" {
dst.ConnectionURI = src.ConnectionURI
}
if src.Token != "" {
dst.Token = src.Token
}
if src.EncryptionScheme != "" {
dst.EncryptionScheme = src.EncryptionScheme
}
if src.CipherSuite != "" {
dst.CipherSuite = src.CipherSuite
}
if src.KeyID != "" {
dst.KeyID = src.KeyID
}
if src.TrackBaseKey != "" {
dst.TrackBaseKey = src.TrackBaseKey
}
}
func overlayTrackNumbers(dst *Track, src Track) {
if src.Framerate != 0 {
dst.Framerate = src.Framerate
}
if src.Timescale != 0 {
dst.Timescale = src.Timescale
}
if src.Bitrate != 0 {
dst.Bitrate = src.Bitrate
}
if src.AvgBitrate != 0 {
dst.AvgBitrate = src.AvgBitrate
}
if src.MaxGopDuration != 0 {
dst.MaxGopDuration = src.MaxGopDuration
}
if src.MaxGroupDuration != 0 {
dst.MaxGroupDuration = src.MaxGroupDuration
}
if src.Width != 0 {
dst.Width = src.Width
}
if src.Height != 0 {
dst.Height = src.Height
}
if src.Samplerate != 0 {
dst.Samplerate = src.Samplerate
}
if src.DisplayWidth != 0 {
dst.DisplayWidth = src.DisplayWidth
}
if src.DisplayHeight != 0 {
dst.DisplayHeight = src.DisplayHeight
}
if src.TrackDuration != 0 {
dst.TrackDuration = src.TrackDuration
}
}
func overlayTrackComposite(dst *Track, src Track) {
if src.IsLive != nil {
v := *src.IsLive
dst.IsLive = &v
}
if src.TargetLatency != nil {
v := *src.TargetLatency
dst.TargetLatency = &v
}
if src.Buffers != nil {
b := *src.Buffers
dst.Buffers = &b
}
if src.RenderGroup != nil {
v := *src.RenderGroup
dst.RenderGroup = &v
}
if src.AltGroup != nil {
v := *src.AltGroup
dst.AltGroup = &v
}
if src.Depends != nil {
dst.Depends = slices.Clone(src.Depends)
}
if src.Template != nil {
v := *src.Template
dst.Template = &v
}
if src.TemporalID != nil {
v := *src.TemporalID
dst.TemporalID = &v
}
if src.SpatialID != nil {
v := *src.SpatialID
dst.SpatialID = &v
}
if src.MaxGrpSapStartingType != nil {
v := *src.MaxGrpSapStartingType
dst.MaxGrpSapStartingType = &v
}
if src.MaxObjSapStartingType != nil {
v := *src.MaxObjSapStartingType
dst.MaxObjSapStartingType = &v
}
if src.ContentProtectionRefIDs != nil {
dst.ContentProtectionRefIDs = slices.Clone(src.ContentProtectionRefIDs)
}
if src.AuthInfo != nil {
dst.AuthInfo = cloneExtras(src.AuthInfo)
}
if src.Accessibility != nil {
dst.Accessibility = slices.Clone(src.Accessibility)
}
if src.Extras != nil {
dst.Extras = cloneExtras(src.Extras)
}
}
func sameTrackID(a, b Track) bool {
return a.Name == b.Name && a.Namespace == b.Namespace
}
func findTrack(c *Catalog, name, namespace string) (Track, bool) {
for _, t := range c.Tracks {
if t.Name != name {
continue
}
if namespace != "" && t.Namespace != namespace {
continue
}
return t, true
}
return Track{}, false
}
func cloneCatalog(c Catalog) Catalog {
out := c
out.Tracks = cloneTracks(c.Tracks)
out.PublishTracks = cloneTracks(c.PublishTracks)
out.InitDataList = slices.Clone(c.InitDataList)
out.ContentProtections = cloneContentProtections(c.ContentProtections)
out.Extras = cloneExtras(c.Extras)
out.DeltaUpdate = nil
return out
}
func cloneTracks(in []Track) []Track {
if in == nil {
return nil
}
out := slices.Clone(in)
for i := range out {
out[i] = cloneTrack(out[i])
}
return out
}
// cloneTrack deep-copies a track's maps and slices so the copy shares
// no backing storage with the original.
func cloneTrack(in Track) Track {
out := in
out.Extras = cloneExtras(in.Extras)
out.AuthInfo = cloneExtras(in.AuthInfo)
out.Depends = slices.Clone(in.Depends)
out.Accessibility = slices.Clone(in.Accessibility)
out.ContentProtectionRefIDs = slices.Clone(in.ContentProtectionRefIDs)
return out
}
// cloneContentProtections deep-copies a catalog's contentProtections
// array (CMSF §4.1.1) for [cloneCatalog].
func cloneContentProtections(in []ContentProtection) []ContentProtection {
if in == nil {
return nil
}
out := slices.Clone(in)
for i := range out {
out[i].DefaultKID = slices.Clone(out[i].DefaultKID)
out[i].Extras = cloneExtras(out[i].Extras)
ds := &out[i].DRMSystem
ds.LAURL = cloneURLRef(ds.LAURL)
ds.CertURL = cloneURLRef(ds.CertURL)
ds.Extras = cloneExtras(ds.Extras)
}
return out
}
// cloneURLRef copies a [DRMSystem] URL object so the clone does not
// alias the original.
func cloneURLRef(in *URLRef) *URLRef {
if in == nil {
return nil
}
return new(*in)
}
func cloneExtras(in map[string]any) map[string]any {
if in == nil {
return nil
}
out := make(map[string]any, len(in))
maps.Copy(out, in)
return out
}
package msf
import (
"encoding/json"
"fmt"
)
// EventIndex identifies which time/location field anchors an Event
// Timeline record per §8.1. Exactly one of the three index fields
// ('t', 'l', 'm') MUST be present in each record.
type EventIndex uint8
const (
// EventIndexWallclock means the record is anchored by 't': a
// wallclock time in milliseconds since the Unix epoch.
EventIndexWallclock EventIndex = iota + 1
// EventIndexLocation means the record is anchored by 'l': a
// [Group ID, Object ID] tuple.
EventIndexLocation
// EventIndexMediaPTS means the record is anchored by 'm': a
// media PTS value in milliseconds.
EventIndexMediaPTS
)
// EventRecord is one entry in an Event Timeline track (§8.1).
//
// Only the fields relevant to Index are read on encode and populated
// on decode. Time carries the value for EventIndexWallclock and
// EventIndexMediaPTS; GroupID + ObjectID carry the location for
// EventIndexLocation.
//
// Data is the opaque application-defined payload whose schema is
// declared by the catalog's EventType field for this track (§5.2.5).
type EventRecord struct {
Index EventIndex
Time int64
GroupID uint64
ObjectID uint64
Data json.RawMessage
}
// EventTimeline is the array of records produced by an Event Timeline
// track (§8.1).
type EventTimeline []EventRecord
// MarshalJSON encodes the timeline per §8.1.
func (e EventTimeline) MarshalJSON() ([]byte, error) {
out := make([]map[string]json.RawMessage, len(e))
for i, r := range e {
entry := map[string]json.RawMessage{}
switch r.Index {
case EventIndexWallclock:
b, err := json.Marshal(r.Time)
if err != nil {
return nil, err
}
entry["t"] = b
case EventIndexLocation:
b, err := json.Marshal([2]uint64{r.GroupID, r.ObjectID})
if err != nil {
return nil, err
}
entry["l"] = b
case EventIndexMediaPTS:
b, err := json.Marshal(r.Time)
if err != nil {
return nil, err
}
entry["m"] = b
default:
return nil, fmt.Errorf("moqt/msf: event record %d: unknown Index %d", i, r.Index)
}
if r.Data == nil {
entry["data"] = json.RawMessage("null")
} else {
entry["data"] = r.Data
}
out[i] = entry
}
return json.Marshal(out)
}
// UnmarshalJSON parses an Event Timeline document. Each record MUST
// have exactly one of t/l/m and a data field.
func (e *EventTimeline) UnmarshalJSON(data []byte) error {
var raw []map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return fmt.Errorf("moqt/msf: event timeline: %w", err)
}
out := make(EventTimeline, 0, len(raw))
for i, entry := range raw {
rec := EventRecord{}
nIndexes := 0
if t, ok := entry["t"]; ok {
if err := json.Unmarshal(t, &rec.Time); err != nil {
return fmt.Errorf("moqt/msf: event record %d t: %w", i, err)
}
rec.Index = EventIndexWallclock
nIndexes++
}
if l, ok := entry["l"]; ok {
var loc []uint64
if err := json.Unmarshal(l, &loc); err != nil {
return fmt.Errorf("moqt/msf: event record %d l: %w", i, err)
}
if len(loc) != 2 {
return fmt.Errorf(
"moqt/msf: event record %d: l must have 2 items, got %d", i, len(loc))
}
rec.GroupID = loc[0]
rec.ObjectID = loc[1]
rec.Index = EventIndexLocation
nIndexes++
}
if m, ok := entry["m"]; ok {
if err := json.Unmarshal(m, &rec.Time); err != nil {
return fmt.Errorf("moqt/msf: event record %d m: %w", i, err)
}
rec.Index = EventIndexMediaPTS
nIndexes++
}
if nIndexes != 1 {
return fmt.Errorf(
"moqt/msf: event record %d: must have exactly one of t/l/m, got %d", i, nIndexes)
}
if d, ok := entry["data"]; ok {
rec.Data = append(json.RawMessage(nil), d...)
}
out = append(out, rec)
}
*e = out
return nil
}
package msf
import (
"errors"
"fmt"
"sync/atomic"
"time"
"github.com/floatdrop/moq-go/pkg/moqt/message"
"github.com/floatdrop/moq-go/pkg/moqt/wire"
)
// GroupSequencer issues monotonically increasing MOQT Group IDs for a
// single track per §6.1. The initial value is the current Unix
// millisecond, which makes Group IDs across application restarts
// non-decreasing and avoids collisions as long as a publisher emits
// fewer than 1000 groups per second.
//
// GroupSequencer is safe for concurrent use.
type GroupSequencer struct {
next atomic.Uint64
}
// NewGroupSequencer returns a sequencer seeded with the current
// wallclock as Unix milliseconds. The first call to [Next] returns
// that seed and increments internal state.
func NewGroupSequencer() *GroupSequencer {
s := &GroupSequencer{}
s.next.Store(uint64(time.Now().UnixMilli()))
return s
}
// NewGroupSequencerAt returns a sequencer seeded at the given start ID.
// Useful for tests and for callers that maintain their own time source.
func NewGroupSequencerAt(start uint64) *GroupSequencer {
s := &GroupSequencer{}
s.next.Store(start)
return s
}
// Next returns the next Group ID and advances the sequencer.
func (s *GroupSequencer) Next() uint64 {
// atomic.Uint64.Add returns the new value, so to mirror the
// "return current, then increment" semantics we subtract 1.
return s.next.Add(1) - 1
}
// Peek returns the value that the next call to Next would produce
// without advancing the sequencer.
func (s *GroupSequencer) Peek() uint64 {
return s.next.Load()
}
// PriorGapHeader returns the KV pair a publisher attaches to its first
// Object after a republish so subscribers can distinguish an
// intentional Group ID gap (e.g. encoder restart) from missing data.
// See §6.1 of the MSF draft and PRIOR_GROUP_ID_GAP in §12.8 of
// MoQ Transport.
//
// prev is the last Group ID the publisher emitted before the gap;
// curr is the first Group ID after the gap. Returns an error if
// curr <= prev (no gap) or if prev+1 == curr (no gap, just the next
// sequential ID).
func PriorGapHeader(prev, curr uint64) (wire.KVPair, error) {
if curr <= prev {
return wire.KVPair{}, fmt.Errorf(
"moqt/msf: PriorGapHeader: curr (%d) must be > prev (%d)", curr, prev)
}
gap := curr - prev - 1
if gap == 0 {
return wire.KVPair{}, errors.New("moqt/msf: PriorGapHeader: curr is the immediate successor of prev (no gap)")
}
return wire.KVPair{
Type: message.PropertyPriorGroupIDGap,
IntVal: gap,
}, nil
}
package msf
import (
"encoding/json"
"fmt"
)
// MediaTimelineRecord is one entry in a Media Timeline track (§7.1).
// On the wire each record is a JSON array of three items:
//
// [ MediaPTS, [GroupID, ObjectID], Wallclock ]
//
// MediaPTS is the media presentation timestamp, rounded to the nearest
// millisecond, of the first media sample in the referenced Object.
// Wallclock is the time of encoding in milliseconds since the Unix
// epoch; for VOD or unknown wallclocks it is 0 (§7.1).
type MediaTimelineRecord struct {
MediaPTS int64
GroupID uint64
ObjectID uint64
Wallclock int64
}
// MediaTimeline is the array of records produced by a Media Timeline
// track. Independent Objects MUST carry the full history since the
// start of the track (§7.3); incremental updates MAY carry only the
// records since the last Object in the same Group.
type MediaTimeline []MediaTimelineRecord
// MarshalJSON encodes the timeline per §7.1.
func (m MediaTimeline) MarshalJSON() ([]byte, error) {
if m == nil {
return []byte("[]"), nil
}
out := make([][3]json.RawMessage, len(m))
for i, r := range m {
pts, err := json.Marshal(r.MediaPTS)
if err != nil {
return nil, err
}
loc, err := json.Marshal([2]uint64{r.GroupID, r.ObjectID})
if err != nil {
return nil, err
}
wc, err := json.Marshal(r.Wallclock)
if err != nil {
return nil, err
}
out[i] = [3]json.RawMessage{pts, loc, wc}
}
return json.Marshal(out)
}
// UnmarshalJSON parses a Media Timeline document.
func (m *MediaTimeline) UnmarshalJSON(data []byte) error {
var raw []json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return fmt.Errorf("moqt/msf: media timeline: %w", err)
}
out := make(MediaTimeline, 0, len(raw))
for i, rec := range raw {
var triple []json.RawMessage
if err := json.Unmarshal(rec, &triple); err != nil {
return fmt.Errorf("moqt/msf: media timeline record %d: %w", i, err)
}
if len(triple) != 3 {
return fmt.Errorf(
"moqt/msf: media timeline record %d: expected 3 items, got %d", i, len(triple))
}
var (
pts int64
loc []uint64
wc int64
)
if err := json.Unmarshal(triple[0], &pts); err != nil {
return fmt.Errorf("moqt/msf: media timeline record %d pts: %w", i, err)
}
if err := json.Unmarshal(triple[1], &loc); err != nil {
return fmt.Errorf("moqt/msf: media timeline record %d location: %w", i, err)
}
if len(loc) != 2 {
return fmt.Errorf(
"moqt/msf: media timeline record %d: location must have 2 items, got %d", i, len(loc))
}
if err := json.Unmarshal(triple[2], &wc); err != nil {
return fmt.Errorf("moqt/msf: media timeline record %d wallclock: %w", i, err)
}
out = append(out, MediaTimelineRecord{
MediaPTS: pts,
GroupID: loc[0],
ObjectID: loc[1],
Wallclock: wc,
})
}
*m = out
return nil
}
// Since returns the records in m whose MediaPTS is strictly greater
// than afterPTS. This produces the "records since the last media
// timeline Object" body for the second-and-later Objects in a Group
// per §7.3.
func (m MediaTimeline) Since(afterPTS int64) MediaTimeline {
for i, r := range m {
if r.MediaPTS > afterPTS {
return m[i:]
}
}
return nil
}
// MediaTimelineTemplate is the inline media timeline template carried
// by the Track.Template field (§5.2.15, §7.4). It describes a regular,
// predictable relationship between media time, MOQT Location and
// wallclock time for fixed-duration segments, replacing an explicit
// media timeline track.
//
// On the wire it is a JSON array of six mandatory values in fixed order
// (§7.4.1):
//
// [ startMediaTime, deltaMediaTime,
// [startGroupID, startObjectID], [deltaGroupID, deltaObjectID],
// startWallclock, deltaWallclock ]
type MediaTimelineTemplate struct {
StartMediaTime int64
DeltaMediaTime int64
StartGroupID uint64
StartObjectID uint64
DeltaGroupID int64
DeltaObjectID int64
StartWallclock int64
DeltaWallclock int64
}
// At computes the media timeline entry for the zero-based index n using
// the formulas in §7.4.1.
func (t MediaTimelineTemplate) At(n int64) MediaTimelineRecord {
return MediaTimelineRecord{
MediaPTS: t.StartMediaTime + n*t.DeltaMediaTime,
GroupID: addDelta(t.StartGroupID, n*t.DeltaGroupID),
ObjectID: addDelta(t.StartObjectID, n*t.DeltaObjectID),
Wallclock: t.StartWallclock + n*t.DeltaWallclock,
}
}
// addDelta adds a signed delta to a MOQT Group/Object ID. Both operands
// are bounded by the MOQT wire format (62-bit varints), so the
// round-trip through int64 cannot overflow in practice.
//
//nolint:gosec // G115: Group/Object IDs and deltas are bounded by the 62-bit MOQT varint range.
func addDelta(base uint64, delta int64) uint64 {
return uint64(int64(base) + delta)
}
// MarshalJSON encodes the template as the six-element array of §7.4.1.
func (t MediaTimelineTemplate) MarshalJSON() ([]byte, error) {
//nolint:gosec // G115: Group/Object IDs are bounded by the 62-bit MOQT varint range.
loc := [2]int64{int64(t.StartGroupID), int64(t.StartObjectID)}
delta := [2]int64{t.DeltaGroupID, t.DeltaObjectID}
out := []any{t.StartMediaTime, t.DeltaMediaTime, loc, delta, t.StartWallclock, t.DeltaWallclock}
return json.Marshal(out)
}
// UnmarshalJSON parses the six-element template array of §7.4.1.
func (t *MediaTimelineTemplate) UnmarshalJSON(data []byte) error {
var raw []json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return fmt.Errorf("moqt/msf: media timeline template: %w", err)
}
if len(raw) != 6 {
return fmt.Errorf("moqt/msf: media timeline template: expected 6 items, got %d", len(raw))
}
var (
startMedia, deltaMedia int64
startLoc, deltaLoc []int64
startWallclock, deltaWall int64
)
if err := json.Unmarshal(raw[0], &startMedia); err != nil {
return fmt.Errorf("moqt/msf: media timeline template startMediaTime: %w", err)
}
if err := json.Unmarshal(raw[1], &deltaMedia); err != nil {
return fmt.Errorf("moqt/msf: media timeline template deltaMediaTime: %w", err)
}
if err := json.Unmarshal(raw[2], &startLoc); err != nil {
return fmt.Errorf("moqt/msf: media timeline template startLocation: %w", err)
}
if len(startLoc) != 2 {
return fmt.Errorf("moqt/msf: media timeline template startLocation: expected 2 items, got %d", len(startLoc))
}
if err := json.Unmarshal(raw[3], &deltaLoc); err != nil {
return fmt.Errorf("moqt/msf: media timeline template deltaLocation: %w", err)
}
if len(deltaLoc) != 2 {
return fmt.Errorf("moqt/msf: media timeline template deltaLocation: expected 2 items, got %d", len(deltaLoc))
}
if err := json.Unmarshal(raw[4], &startWallclock); err != nil {
return fmt.Errorf("moqt/msf: media timeline template startWallclock: %w", err)
}
if err := json.Unmarshal(raw[5], &deltaWall); err != nil {
return fmt.Errorf("moqt/msf: media timeline template deltaWallclock: %w", err)
}
*t = MediaTimelineTemplate{
StartMediaTime: startMedia,
DeltaMediaTime: deltaMedia,
//nolint:gosec // G115: Group/Object IDs are bounded by the 62-bit MOQT varint range.
StartGroupID: uint64(startLoc[0]),
//nolint:gosec // G115: Group/Object IDs are bounded by the 62-bit MOQT varint range.
StartObjectID: uint64(startLoc[1]),
DeltaGroupID: deltaLoc[0],
DeltaObjectID: deltaLoc[1],
StartWallclock: startWallclock,
DeltaWallclock: deltaWall,
}
return nil
}
package msf
import (
"encoding/hex"
"errors"
"fmt"
"reflect"
)
// Validate enforces the rules from §5.1 and §5.2 that can be checked
// from a Catalog value alone. It does NOT validate cross-document
// state (e.g. whether a delta's parentName exists in some prior
// catalog) — that is [Apply]'s responsibility.
//
// Validate returns nil for valid catalogs and a descriptive error for
// the first violation it encounters.
func (c *Catalog) Validate() error {
if c.IsDelta() {
return c.validateDelta()
}
return c.validateIndependent()
}
func (c *Catalog) validateIndependent() error {
if c.Version != Version {
// Per §5.1.1: subscriber MUST NOT parse unknown versions. We
// still emit an error so producers learn about the mismatch.
if c.Version == "" {
return errors.New("moqt/msf: version is required (§5.1.1)")
}
return fmt.Errorf("moqt/msf: unsupported version %q (expected %q)", c.Version, Version)
}
// §5.1.2 — generatedAt SHOULD NOT be included when isLive is false
// for every track. We treat the SHOULD as advisory and skip it.
// Per-track cross-field constraints.
for i, tr := range c.Tracks {
if err := validateTrack(tr); err != nil {
return fmt.Errorf("moqt/msf: tracks[%d]: %w", i, err)
}
}
if err := validateTargetLatencyGroups(
c.Tracks,
"renderGroup",
func(t Track) *int { return t.RenderGroup },
); err != nil {
return err
}
if err := validateTargetLatencyGroups(c.Tracks, "altGroup", func(t Track) *int { return t.AltGroup }); err != nil {
return err
}
if err := validateContentProtections(c); err != nil {
return err
}
if err := validateTrackReferences(c); err != nil {
return err
}
return nil
}
// validateContentProtections enforces draft-ietf-moq-cmsf-01 §4.1.1's
// per-entry required fields and refID uniqueness.
func validateContentProtections(c *Catalog) error {
seen := make(map[string]struct{}, len(c.ContentProtections))
for i, cp := range c.ContentProtections {
if cp.RefID == "" {
return fmt.Errorf("moqt/msf: contentProtections[%d]: refID is required (CMSF §4.1.1.1)", i)
}
if _, dup := seen[cp.RefID]; dup {
return fmt.Errorf("moqt/msf: contentProtections[%d]: duplicate refID %q (CMSF §4.1.1.1)", i, cp.RefID)
}
seen[cp.RefID] = struct{}{}
if err := validateContentProtection(cp); err != nil {
return fmt.Errorf("moqt/msf: contentProtections[%d]: %w", i, err)
}
}
return nil
}
// validateContentProtection checks one entry's required fields, the
// §4.1.1.3 scheme enumeration and the UUID forms §4.1.1.2 / §4.1.1.4.1
// require.
func validateContentProtection(cp ContentProtection) error {
if len(cp.DefaultKID) == 0 {
return errors.New("defaultKID is required (CMSF §4.1.1.2)")
}
for j, kid := range cp.DefaultKID {
if !isUUID(kid) {
return fmt.Errorf("defaultKID[%d] %q is not a UUID string (CMSF §4.1.1.2)", j, kid)
}
}
switch cp.Scheme {
case "":
return errors.New("scheme is required (CMSF §4.1.1.3)")
case SchemeCENC, SchemeCBCS:
default:
return fmt.Errorf("unknown scheme %q (CMSF §4.1.1.3, Table 3)", cp.Scheme)
}
return validateDRMSystem(cp.DRMSystem)
}
// validateDRMSystem checks the §4.1.1.4 DRM System object: the
// systemID UUID (§4.1.1.4.1) and the required url of every URL object
// that is present (§4.1.1.4.2, §4.1.1.4.3).
func validateDRMSystem(ds DRMSystem) error {
if ds.SystemID == "" {
return errors.New("drmSystem.systemID is required (CMSF §4.1.1.4.1)")
}
if !isUUID(ds.SystemID) {
return fmt.Errorf("drmSystem.systemID %q is not a UUID string (CMSF §4.1.1.4.1)", ds.SystemID)
}
urls := []struct {
name string
ref *URLRef
sec string
}{
{"laURL", ds.LAURL, "§4.1.1.4.2"},
{"certURL", ds.CertURL, "§4.1.1.4.3"},
}
for _, u := range urls {
if u.ref != nil && u.ref.URL == "" {
return fmt.Errorf("drmSystem.%s: url is required (CMSF %s)", u.name, u.sec)
}
}
return nil
}
// validateTrackReferences enforces the catalog's two referential
// integrity rules: Track.InitRef names an initDataList entry (§5.2.13,
// CMSF §3.1) and every Track.ContentProtectionRefIDs entry names a
// contentProtections entry (CMSF §4.1.2).
func validateTrackReferences(c *Catalog) error {
initIDs := make(map[string]struct{}, len(c.InitDataList))
for _, init := range c.InitDataList {
initIDs[init.ID] = struct{}{}
}
refIDs := make(map[string]struct{}, len(c.ContentProtections))
for _, cp := range c.ContentProtections {
refIDs[cp.RefID] = struct{}{}
}
for i, tr := range c.Tracks {
if tr.InitRef != "" {
if _, ok := initIDs[tr.InitRef]; !ok {
return fmt.Errorf(
"moqt/msf: tracks[%d]: initRef %q has no initDataList entry (§5.2.13)", i, tr.InitRef)
}
}
for _, ref := range tr.ContentProtectionRefIDs {
if _, ok := refIDs[ref]; !ok {
return fmt.Errorf(
"moqt/msf: tracks[%d]: contentProtectionRefIDs references unknown refID %q (CMSF §4.1.1, §4.1.2)",
i, ref)
}
}
}
return nil
}
// isUUID reports whether s has the
// "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" form that CMSF §4.1.1.2 and
// §4.1.1.4.1 require of key and DRM system identifiers.
func isUUID(s string) bool {
if len(s) != 36 || s[8] != '-' || s[13] != '-' || s[18] != '-' || s[23] != '-' {
return false
}
_, err := hex.DecodeString(s[:8] + s[9:13] + s[14:18] + s[19:23] + s[24:])
return err == nil
}
func (c *Catalog) validateDelta() error {
if c.Version != "" {
return errors.New("moqt/msf: delta update must not contain version (§5.3)")
}
if c.Tracks != nil {
return errors.New("moqt/msf: delta update must not contain tracks (§5.3)")
}
if len(c.DeltaUpdate) == 0 {
return errors.New("moqt/msf: delta update must contain at least one operation (§5.3)")
}
// A delta MAY carry the root-level arrays its added tracks
// reference; [Apply] merges them. Their own field rules still hold.
if err := validateContentProtections(c); err != nil {
return err
}
for i, op := range c.DeltaUpdate {
if err := validateDeltaOp(op); err != nil {
return fmt.Errorf("moqt/msf: deltaUpdate[%d]: %w", i, err)
}
}
return nil
}
func validateDeltaOp(op DeltaOp) error {
switch op.Op {
case DeltaOpAdd:
for i, tr := range op.Tracks {
if err := validateTrack(tr); err != nil {
return fmt.Errorf("tracks[%d]: %w", i, err)
}
}
case DeltaOpRemove:
for i, tr := range op.Tracks {
if tr.Name == "" {
return fmt.Errorf("tracks[%d]: name required (§5.1.6)", i)
}
if !isOnlyNameAndNamespace(tr) {
return fmt.Errorf("tracks[%d]: only name and namespace allowed (§5.1.6)", i)
}
}
case DeltaOpClone:
for i, tr := range op.Tracks {
if tr.ParentName == "" {
return fmt.Errorf("tracks[%d]: parentName required (§5.1.6)", i)
}
if tr.Name == "" {
return fmt.Errorf("tracks[%d]: name required (§5.3)", i)
}
}
default:
return fmt.Errorf("unknown op %q (§5.1.6)", op.Op)
}
return nil
}
func validateTrack(t Track) error {
if t.Name == "" {
return errors.New("name is required (§5.2.3)")
}
if t.Packaging == "" {
return errors.New("packaging is required (§5.2.4)")
}
switch t.Packaging {
case PackagingLOC, PackagingMediaTimeline, PackagingEventTimeline,
PackagingMoQLog, PackagingMoQMetrics, PackagingCMAF:
default:
return fmt.Errorf("unknown packaging %q (§5.2.4)", t.Packaging)
}
if t.IsLive == nil {
return errors.New("isLive is required (§5.2.7)")
}
live := *t.IsLive
if t.Packaging == PackagingEventTimeline {
if t.EventType == "" {
return errors.New("eventType is required when packaging=eventtimeline (§5.2.5)")
}
} else if t.EventType != "" {
return errors.New("eventType MUST NOT be used unless packaging=eventtimeline (§5.2.5)")
}
// §5.2.8 / §5.2.9 — targetLatency and buffers are mutually
// exclusive within a single track.
if t.TargetLatency != nil && t.Buffers != nil {
return errors.New("targetLatency and buffers are mutually exclusive (§5.2.8, §5.2.9)")
}
if live && t.TrackDuration != 0 {
return errors.New("trackDuration MUST NOT be present when isLive is true (§5.2.35)")
}
return validateSapStartingTypes(t)
}
// validateSapStartingTypes bounds the two CMSF §3.5.2 track fields.
// §3.5.2.1 and §3.5.2.2 define them as plain numbers without stating a
// range, so the bounds come from the sections that constrain the SAP
// types themselves.
func validateSapStartingTypes(t Track) error {
// §3.6.1 defines the SAP type value space CMSF signals as 0-3, so
// neither field can name a type outside it.
if t.MaxGrpSapStartingType != nil && (*t.MaxGrpSapStartingType < 0 || *t.MaxGrpSapStartingType > 3) {
return fmt.Errorf("maxGrpSapStartingType %d out of range 0-3 (CMSF §3.6.1)", *t.MaxGrpSapStartingType)
}
if t.MaxObjSapStartingType != nil && (*t.MaxObjSapStartingType < 0 || *t.MaxObjSapStartingType > 3) {
return fmt.Errorf("maxObjSapStartingType %d out of range 0-3 (CMSF §3.6.1)", *t.MaxObjSapStartingType)
}
// §3.4 additionally requires every Group to "begin with an Object
// containing a stream access point (SAP) type 1 or 2", so on a CMAF
// track the maximum type a Group starts with is 1 or 2. §3.4 sits
// under §3 "CMAF Packaging" and says nothing about the Groups of a
// track using any other packaging, so the tighter bound applies
// only here.
if t.Packaging == PackagingCMAF && t.MaxGrpSapStartingType != nil &&
(*t.MaxGrpSapStartingType < 1 || *t.MaxGrpSapStartingType > 2) {
return fmt.Errorf(
"maxGrpSapStartingType %d out of range 1-2 for packaging %q (CMSF §3.4)",
*t.MaxGrpSapStartingType, PackagingCMAF)
}
return nil
}
// validateTargetLatencyGroups enforces §5.2.8's requirement that all
// tracks belonging to the same render/altGroup share the same
// targetLatency (treating nil as a distinct "absent" value).
func validateTargetLatencyGroups(tracks []Track, groupName string, accessor func(Track) *int) error {
groups := map[int]*uint32{}
groupsSeen := map[int]bool{}
for i, tr := range tracks {
gp := accessor(tr)
if gp == nil {
continue
}
g := *gp
if !groupsSeen[g] {
groups[g] = tr.TargetLatency
groupsSeen[g] = true
continue
}
if !targetLatencyEqual(groups[g], tr.TargetLatency) {
return fmt.Errorf(
"moqt/msf: tracks[%d] %s=%d targetLatency mismatch within group (§5.2.8)",
i, groupName, g)
}
}
return nil
}
func targetLatencyEqual(a, b *uint32) bool {
if a == nil && b == nil {
return true
}
if a == nil || b == nil {
return false
}
return *a == *b
}
// isOnlyNameAndNamespace reports whether tr has no fields set beyond
// Name and Namespace. Used by §5.1.6: remove-operation entries MUST
// hold only Name and may hold Namespace. Clearing those two fields and
// comparing against the zero Track keeps this robust as Track grows.
func isOnlyNameAndNamespace(tr Track) bool {
tr.Name = ""
tr.Namespace = ""
return reflect.DeepEqual(tr, Track{})
}
package msf
import "time"
// BeginBroadcast returns the initial independent catalog a publisher
// emits before any media-track objects per §11.2. The Version, the
// GeneratedAt wallclock, and the supplied tracks make up the catalog;
// callers serialise it (via [encoding/json.Marshal]) and write the
// result as the first Object on the catalog track.
//
// generatedAt is the wallclock the publisher wants recorded. Pass a
// zero [time.Time] to use [time.Now]. For VOD catalogs §5.1.2 says
// generatedAt SHOULD NOT be included if isLive is false; the
// VOD-conversion helper [EndBroadcastToVOD] honours that.
func BeginBroadcast(tracks []Track, generatedAt time.Time) Catalog {
if generatedAt.IsZero() {
generatedAt = time.Now()
}
out := Catalog{
Version: Version,
GeneratedAt: generatedAt.UnixMilli(),
}
if len(tracks) > 0 {
out.Tracks = append([]Track(nil), tracks...)
}
return out
}
// EndBroadcastTerminate returns the §11.3 final independent catalog
// with isComplete=true and an empty Tracks array. After emitting this
// catalog object the publisher MUST also end each active publication
// with PUBLISH_DONE status 0x2 Track Ended (moqt.PublishDoneTrackEnded;
// the MSF draft's older text calls it SUBSCRIBE_DONE) — this helper
// only constructs the catalog body.
func EndBroadcastTerminate(generatedAt time.Time) Catalog {
if generatedAt.IsZero() {
generatedAt = time.Now()
}
return Catalog{
Version: Version,
GeneratedAt: generatedAt.UnixMilli(),
IsComplete: true,
Tracks: []Track{},
}
}
// EndBroadcastToVOD returns the §11.3 catalog that converts a previously
// live broadcast into a VOD asset. Every track in prev has its IsLive
// flipped to false and is annotated with the duration from durations
// (keyed by track Name). Tracks present in prev but missing from
// durations are passed through with IsLive=false and TrackDuration
// left unset.
//
// The returned catalog is independent (not a delta). The publisher
// emits this catalog on the catalog track to signal the live-to-VOD
// transition. Per §5.1.2 generatedAt SHOULD NOT be included when
// isLive is false; this helper omits it for that reason.
func EndBroadcastToVOD(prev Catalog, durations map[string]uint64) Catalog {
out := cloneCatalog(prev)
out.IsComplete = false
out.GeneratedAt = 0
live := false
for i := range out.Tracks {
out.Tracks[i].IsLive = &live
out.Tracks[i].TargetLatency = nil
if d, ok := durations[out.Tracks[i].Name]; ok {
out.Tracks[i].TrackDuration = d
}
}
return out
}
package session
import (
"context"
"errors"
"fmt"
"io"
"slices"
"sync"
"github.com/floatdrop/moq-go/pkg/moqt"
"github.com/floatdrop/moq-go/pkg/moqt/message"
)
// RequestBroker owns an established request stream's read side and
// serializes its writes, so REQUEST_UPDATE (§10.9) and long-lived follow-up
// traffic can safely coexist. [Session.UpdateRequest] reads its response
// directly off the stream and therefore cannot run concurrently with any
// other reader; once a request outlives its initial response — a relay's
// upstream subscription, a publisher answering subscriber updates — exactly
// one reader must own the stream, and that reader is [RequestBroker.Serve]:
//
// - REQUEST_OK / REQUEST_ERROR answer in-flight [RequestBroker.Update]
// calls, including §10.9's coalescing rule (a peer may answer N
// pipelined updates with a single REQUEST_ERROR, which fails them all).
// - AUTHORIZATION_TOKEN parameters on follow-ups are resolved through the
// session token cache (§10.2.2); a cache fault closes the session with
// the mandated code.
// - A peer REQUEST_UPDATE is answered with the single REQUEST_OK §10.9
// mandates; the broker applies no parameters.
// - Everything else (PUBLISH_DONE, unsolicited responses, …) is handed to
// Serve's callback.
//
// Obtain one from a typed request handle's Broker method (e.g.
// [Publication.Broker]) or [Session.NewRequestBroker]; from then on every
// write to the stream must go through the broker ([RequestBroker.Update],
// [RequestBroker.WriteMessage], or broker-aware handle methods such as
// [Publication.Done]) — session streams do not serialize concurrent writers.
type RequestBroker struct {
stream Stream
sess *Session
// mu serializes stream writes and guards the waiter queue. It is
// deliberately held across the REQUEST_UPDATE write: §10.9 responses
// arrive in request order, so the waiter queue order must match the
// write order.
mu sync.Mutex
waiters []chan updateResult
// updatesClosed is latched when the stream's reader exits (or Close is
// called); subsequent Update calls fail immediately instead of queueing
// a waiter nothing will ever answer. Plain WriteMessage stays allowed —
// e.g. a PUBLISH_DONE after the peer tore its side down.
updatesClosed bool
streamClosed bool
}
// updateResult carries one §10.9 response to a waiting Update call.
type updateResult struct {
ok *message.RequestOK
err error
}
// ErrRequestStreamClosed is returned by [RequestBroker.Update] when the
// request stream's reader has exited (peer FIN/reset or session shutdown) —
// no further REQUEST_UPDATE can be answered.
var ErrRequestStreamClosed = errors.New("moqt/session: request stream closed")
// NewRequestBroker builds a [RequestBroker] for an established request
// stream. Typed request handles expose a Broker method that fills this in;
// use this constructor for accept-side streams (a [Request] this endpoint
// accepted).
func (s *Session) NewRequestBroker(stream Stream) *RequestBroker {
return &RequestBroker{stream: stream, sess: s}
}
// mapUpdateResponse converts a §10.9 response message into the
// (*message.RequestOK, error) shape Update-style callers return: REQUEST_OK
// passes through, REQUEST_ERROR becomes a *RequestRejectedError, anything
// else is a protocol-shape error.
func mapUpdateResponse(msg message.Message) (*message.RequestOK, error) {
switch m := msg.(type) {
case *message.RequestOK:
return m, nil
case *message.RequestError:
return nil, &RequestRejectedError{Code: m.ErrorCode, Reason: m.ErrorReason}
default:
return nil, fmt.Errorf("moqt/session: unexpected %s in REQUEST_UPDATE response", msg.Type())
}
}
// Update sends a REQUEST_UPDATE (§10.9) on the request stream and awaits the
// single REQUEST_OK / REQUEST_ERROR the spec mandates, delivered by the
// [RequestBroker.Serve] reader. params carries only the fields to change;
// any parameter omitted keeps its prior value on the peer.
//
// A REQUEST_ERROR is surfaced as a *RequestRejectedError. On ctx expiry the
// waiter is removed from the queue, so a peer that never answers cannot
// permanently shift response routing for later updates. (If the response is
// merely late, routing for updates written after the removal shifts by one —
// the lesser evil versus permanent poisoning; conforming peers answer.)
//
// Known limitation: the REQUEST_UPDATE write itself runs under the write
// lock and is not ctx-bounded — a peer that stalls stream flow control
// blocks Update until the session dies and errors the write.
func (b *RequestBroker) Update(ctx context.Context, params message.Parameters) (*message.RequestOK, error) {
ch := make(chan updateResult, 1)
b.mu.Lock()
if b.updatesClosed {
b.mu.Unlock()
return nil, ErrRequestStreamClosed
}
// Write while holding mu: it serializes writers on the stream AND keeps
// the waiter queue order equal to the write order, which is what lets
// the reader pair each §10.9 response with its update. The ID is
// allocated under the same lock so IDs appear on this stream in
// increasing order — §10.1: REQUEST_UPDATE consumes a fresh Request ID
// from the sender's space (the stream, not the ID, names the request
// being updated; a reused ID is a session-fatal duplicate).
err := message.Marshal(b.stream, &message.RequestUpdate{
RequestID: b.sess.AllocRequestID(),
Parameters: params,
})
if err == nil {
b.waiters = append(b.waiters, ch)
}
b.mu.Unlock()
if err != nil {
return nil, fmt.Errorf("moqt/session: write REQUEST_UPDATE: %w", err)
}
select {
case res := <-ch:
return res.ok, res.err
case <-ctx.Done():
b.mu.Lock()
if i := slices.Index(b.waiters, ch); i >= 0 {
b.waiters = slices.Delete(b.waiters, i, i+1)
}
b.mu.Unlock()
return nil, ctx.Err()
}
}
// WriteMessage marshals a control message onto the request stream under the
// same lock that serializes Update's REQUEST_UPDATE writes.
func (b *RequestBroker) WriteMessage(msg message.Message) error {
b.mu.Lock()
defer b.mu.Unlock()
return message.Marshal(b.stream, msg)
}
// WriteMessageAfterSetup runs setup and then marshals msg, both under the
// write lock. It exists for responder-side visibility ordering: setup
// typically publishes state that lets other goroutines write to this stream
// through the broker (e.g. a relay registering an upstream subscription
// that a concurrent propagation path may immediately Update). Running both
// under the lock guarantees that msg is the stream's next message — a write
// triggered by the new visibility serializes behind it — while the peer
// cannot observe msg before setup completed. A setup error aborts the
// write. setup must not use the broker or write to the stream itself, and
// must not acquire locks that stream writers hold while using the broker.
func (b *RequestBroker) WriteMessageAfterSetup(setup func() error, msg message.Message) error {
b.mu.Lock()
defer b.mu.Unlock()
if err := setup(); err != nil {
return err
}
return message.Marshal(b.stream, msg)
}
// writeThenClose marshals msg and FINs the send side under the write lock —
// the broker-aware backend of terminal handle methods like
// [Publication.Done].
func (b *RequestBroker) writeThenClose(msg message.Message) error {
b.mu.Lock()
defer b.mu.Unlock()
if err := message.Marshal(b.stream, msg); err != nil {
return err
}
return b.stream.Close()
}
// route delivers a REQUEST_OK / REQUEST_ERROR read off the stream to
// in-flight Update calls: a REQUEST_OK answers the oldest waiter; a
// REQUEST_ERROR answers ALL of them, because §10.9 lets the peer coalesce
// pipelined updates and "only a single REQUEST_ERROR will be sent" for the
// batch. It reports whether any waiter consumed the message; false means
// none was pending (an unsolicited response Serve hands to its callback).
func (b *RequestBroker) route(msg message.Message) bool {
b.mu.Lock()
if len(b.waiters) == 0 {
b.mu.Unlock()
return false
}
var recipients []chan updateResult
if _, isErr := msg.(*message.RequestError); isErr {
recipients, b.waiters = b.waiters, nil
} else {
recipients, b.waiters = b.waiters[:1], b.waiters[1:]
}
b.mu.Unlock()
ok, err := mapUpdateResponse(msg)
res := updateResult{ok: ok, err: err}
for _, ch := range recipients {
ch <- res
}
return true
}
// closeUpdates latches the broker shut for updates and fails every pending
// Update with [ErrRequestStreamClosed]. Idempotent; Serve calls it on exit
// and Close calls it as part of full teardown.
func (b *RequestBroker) closeUpdates() {
b.mu.Lock()
waiters := b.waiters
b.waiters = nil
b.updatesClosed = true
b.mu.Unlock()
for _, ch := range waiters {
ch <- updateResult{err: ErrRequestStreamClosed}
}
}
// Close tears the request stream down: pending and future Updates fail with
// [ErrRequestStreamClosed], the read side is reset with code (unblocking a
// running Serve), and the send side is FIN'd — closing the request stream is
// how a requester ends the request (§10.7). Serialized against in-flight
// writes; idempotent. Must not be called with locks that Serve's callback
// might need held.
func (b *RequestBroker) Close(code moqt.StreamResetCode) {
b.closeUpdates()
b.mu.Lock()
defer b.mu.Unlock()
if b.streamClosed {
return
}
b.streamClosed = true
b.stream.CancelRead(uint64(code))
_ = b.stream.Close()
}
// Serve owns every read on the request stream until the peer tears it down
// (EOF / reset), ctx is cancelled (the read side is then reset to unblock
// the parse), or onMsg returns false. On exit, pending and future Update
// calls fail with [ErrRequestStreamClosed].
//
// Responses route to Update waiters; token parameters go through the
// session's token cache (a cache fault closes the session with the §10.2.2
// code and ends Serve); peer REQUEST_UPDATEs are acknowledged with
// REQUEST_OK. Every other message — and any unsolicited response — is passed
// to onMsg (nil means "discard"); return false from onMsg to stop serving.
//
// A malformed follow-up (any non-EOF parse error) resets the read side with
// INTERNAL_ERROR so the peer learns reads stopped instead of filling flow
// control into a void. Serve returns nil on a clean FIN or an onMsg stop,
// ctx.Err() on cancellation, and the read/token error otherwise.
func (b *RequestBroker) Serve(ctx context.Context, onMsg func(message.Message) bool) error {
defer b.closeUpdates()
stop := context.AfterFunc(ctx, func() {
b.stream.CancelRead(uint64(moqt.StreamResetSessionClosed))
})
defer stop()
// §10.3.1.7: per-stream MAX_REQUEST_UPDATES enforcement. One limiter per
// stream, since the limit is scoped to a single request stream.
updates := b.sess.NewRequestUpdateLimiter()
for {
msg, err := message.Parse(b.stream)
if err != nil {
switch {
case ctx.Err() != nil:
return ctx.Err()
case errors.Is(err, io.EOF):
return nil
default:
// Covers peer resets too (a STOP_SENDING on an
// already-reset stream is a transport no-op).
b.stream.CancelRead(uint64(moqt.StreamResetInternalError))
return err
}
}
// §10.2.2: follow-ups may REGISTER/DELETE token aliases; skipping
// this would silently desynchronize the peer's view of the token
// cache. A cache fault is session-fatal with the mandated code.
if _, err := b.sess.ProcessFollowupTokens(msg); err != nil {
if tce, ok := errors.AsType[*TokenCacheError](err); ok {
_ = b.sess.Close(tce.Code, tce.Error())
}
return err
}
switch m := msg.(type) {
case *message.RequestOK, *message.RequestError:
if b.route(msg) {
continue
}
// Unsolicited response — surface via onMsg below.
case *message.RequestUpdate:
// §10.1: a REQUEST_UPDATE consumes a Request ID from the
// sender's space; a wrong-parity or duplicate ID is
// session-fatal.
if err := b.sess.CheckPeerRequestID(m.RequestID); err != nil {
_ = b.sess.Close(moqt.SessionInvalidRequestID, err.Error())
return err
}
// §10.3.1.7: reject a REQUEST_UPDATE that exceeds the per-stream
// MAX_REQUEST_UPDATES limit before acting on it.
if err := updates.Received(); err != nil {
_ = b.sess.Close(moqt.SessionTooManyRequestUpdates, err.Error())
return err
}
// §10.9: the receiver of a REQUEST_UPDATE "MUST respond with
// exactly one REQUEST_OK or REQUEST_ERROR". The broker keeps
// no mutable per-request parameters, so the update is
// acknowledged without further action; onMsg still observes it.
if err := b.WriteMessage(&message.RequestOK{}); err != nil {
return fmt.Errorf("moqt/session: write REQUEST_UPDATE_OK: %w", err)
}
updates.Responded()
}
if onMsg != nil && !onMsg(msg) {
return nil
}
}
}
package session
import (
"errors"
"fmt"
"io"
"github.com/floatdrop/moq-go/pkg/moqt"
"github.com/floatdrop/moq-go/pkg/moqt/message"
)
// sendControl queues a control message for the send loop. Blocks if the queue
// is full or the session is done.
func (s *Session) sendControl(msg message.Message) error {
select {
case s.controlOut <- msg:
return nil
case <-s.done:
return errors.New("moqt/session: closed")
}
}
// controlSendLoop serializes writes onto the send-control stream. It exits on
// session shutdown or on the first write error.
func (s *Session) controlSendLoop() {
for {
select {
case msg := <-s.controlOut:
if err := message.Marshal(s.sendCtrl, msg); err != nil {
if s.sessionDoneAlready() {
return
}
_ = s.Close(moqt.SessionInternalError, "control send failure")
return
}
case <-s.done:
return
}
}
}
// controlRecvLoop reads framed control messages off the recv-control stream
// and dispatches them. The loop owns shutdown on read failure unless the
// session is already terminating.
func (s *Session) controlRecvLoop() {
for {
msg, err := message.Parse(s.recvCtrl)
if err != nil {
if s.sessionDoneAlready() {
return
}
if errors.Is(err, io.EOF) {
// Peer closed the control stream cleanly. §3.3 forbids
// this during the session lifetime; treat it as a
// protocol violation.
_ = s.Close(moqt.SessionProtocolViolation, "peer closed control stream")
return
}
_ = s.Close(moqt.SessionProtocolViolation, err.Error())
return
}
if err := s.dispatchControl(msg); err != nil {
if s.sessionDoneAlready() {
return
}
// Every violation dispatchControl can report is a §3.5
// PROTOCOL_VIOLATION: the only messages it accepts after SETUP are
// GOAWAY (§10.4), a duplicate SETUP, and anything table 5 in §10
// disallows outright. A rule mandating a different close code would
// need the handler to carry it out of here.
_ = s.Close(moqt.SessionProtocolViolation, err.Error())
return
}
}
}
func (s *Session) sessionDoneAlready() bool {
select {
case <-s.done:
return true
default:
return false
}
}
// dispatchControl handles a single control-stream message after SETUP. Per
// table 5 in §10, only GOAWAY is valid on the control stream after SETUP for
// the messages in scope; anything else is a protocol violation.
func (s *Session) dispatchControl(msg message.Message) error {
switch m := msg.(type) {
case *message.Goaway:
return s.handleGoaway(m)
case *message.Setup:
return errors.New("duplicate SETUP on control stream")
default:
return fmt.Errorf("unexpected %s on control stream", msg.Type())
}
}
package session
import (
"context"
"fmt"
"github.com/floatdrop/moq-go/pkg/moqt"
"github.com/floatdrop/moq-go/pkg/moqt/message"
"github.com/floatdrop/moq-go/pkg/moqt/wire"
)
// paddingDatagramType is the MoQT PADDING datagram type (§11.5.2).
const paddingDatagramType uint64 = 0x132B3E29
// ReceiveDatagram blocks until a QUIC DATAGRAM frame arrives from the peer,
// parses it, and returns the contained ObjectDatagram. PADDING datagrams
// (§11.3) are silently consumed and the call retries. Unknown datagram types
// close the session with PROTOCOL_VIOLATION per §11.
//
// Transport-level errors (session closed, ctx cancelled) are returned
// unwrapped so the caller can distinguish them from parse failures.
func (s *Session) ReceiveDatagram(ctx context.Context) (*message.ObjectDatagram, error) {
for {
raw, err := s.conn.ReceiveDatagram(ctx)
if err != nil {
return nil, err
}
// Peek the type varint to dispatch. ObjectDatagram.Parse will re-read
// it from a fresh Reader, so we only need the value here.
peek := wire.NewReader(raw)
typ, err := peek.Varint()
if err != nil {
return nil, s.closeProtocolViolation(
fmt.Errorf("moqt/session: datagram type varint: %w", err))
}
switch {
case message.IsValidDatagramType(typ):
obj := &message.ObjectDatagram{}
if err := obj.Parse(wire.NewReader(raw)); err != nil {
return nil, s.closeProtocolViolation(
fmt.Errorf("moqt/session: parse OBJECT_DATAGRAM: %w", err))
}
return obj, nil
case typ == paddingDatagramType:
// §11.5.2: receiver MUST discard all data in a padding datagram.
continue
default:
return nil, s.closeProtocolViolation(
fmt.Errorf("moqt/session: unknown datagram type %#x", typ))
}
}
}
// SendDatagram serializes d and sends it as a single QUIC DATAGRAM
// frame. Returns an error if d fails validation or the payload exceeds the
// negotiated max_datagram_frame_size (the transport returns an error in that
// case; per §11.3 the object is silently dropped at the sender).
//
// SendDatagram is the publisher-side counterpart of [Session.ReceiveDatagram].
func (s *Session) SendDatagram(d *message.ObjectDatagram) error {
if err := d.Validate(); err != nil {
return fmt.Errorf("moqt/session: SendDatagram: %w", err)
}
// Reuse a pooled writer rather than allocating one per datagram. The
// transport copies the bytes before SendDatagram returns (quic-go and the
// in-process pipe both make a copy), so the buffer is free to recycle.
w, _ := writerPool.Get().(*wire.Writer)
w.Reset()
d.Append(w)
err := s.conn.SendDatagram(w.Bytes())
writerPool.Put(w)
return err
}
// closeProtocolViolation closes the session with PROTOCOL_VIOLATION and
// returns err so callers can return it directly.
func (s *Session) closeProtocolViolation(err error) error {
_ = s.Close(moqt.SessionProtocolViolation, err.Error())
return err
}
package session
import (
"bufio"
"context"
"errors"
"fmt"
"github.com/floatdrop/moq-go/pkg/moqt"
"github.com/floatdrop/moq-go/pkg/moqt/message"
"github.com/floatdrop/moq-go/pkg/moqt/track"
"github.com/floatdrop/moq-go/pkg/moqt/wire"
)
// ErrPaddingStream is returned by AcceptDataStream when a padding uni-stream
// (§11.5, type 0x132B3E28) is received. Callers SHOULD loop and call
// AcceptDataStream again.
var ErrPaddingStream = errors.New("moqt/session: padding stream received (ignorable)")
// ---------------------------------------------------------------------------
// DataStream — sealed interface returned by AcceptDataStream
// ---------------------------------------------------------------------------
// DataStream is the sealed interface returned by AcceptDataStream. The
// concrete type is either *IncomingSubgroupStream or *IncomingFetchStream;
// callers type-switch to obtain the typed stream.
//
// Read is included so that io.Copy / io.ReadAll work directly on the
// interface without a type assertion.
type DataStream interface {
// Read returns body bytes that follow the parsed header.
Read(p []byte) (int, error)
// Cancel resets the stream with the given application code (§3.3.4).
Cancel(code moqt.StreamResetCode)
// isDataStream seals the interface to this package.
isDataStream()
}
// ---------------------------------------------------------------------------
// IncomingSubgroupStream
// ---------------------------------------------------------------------------
// IncomingSubgroupStream is an accepted inbound SUBGROUP_HEADER uni-stream
// whose leading header has already been parsed. The remaining bytes are the
// body, consumed via [IncomingSubgroupStream.ReadObject] (raw, delta-encoded
// ObjectID), [IncomingSubgroupStream.ReadDecoded] (absolute IDs with state
// carried across calls), or Read (raw bytes). The peer's FIN surfaces as
// io.EOF from any of these methods.
type IncomingSubgroupStream struct {
// Header is the parsed SUBGROUP_HEADER (§11.4.2).
Header message.SubgroupHeader
src ReceiveStream
br *bufio.Reader
// rd is a StreamReader bound to br once at construction and reused by
// every ReadObject call. Allocating it per-object showed up as the
// single largest allocation site on the fanout read path (it escapes
// to the heap because it's passed as the wire.Decoder interface to
// SubgroupObject.Parse).
rd *wire.StreamReader
// Decoder state for ReadDecoded.
decPrevObject uint64
decHavePrev bool
decSubgroupID uint64 // resolved per §11.4.2 (zero / first-object / explicit)
decSubgroupResolved bool
// sess is the owning session, used by TrackKey to resolve Header.TrackAlias
// against the inbound alias registry live, at call time.
sess *Session
}
// TrackKey returns the track this subgroup belongs to, resolved from the
// stream's §11.1 Track Alias (Header.TrackAlias) via the inbound alias registry
// the session populates on SUBSCRIBE_OK and [Request.AcceptPublish]. The second
// result is false when the alias is not registered, in which case callers fall
// back to Header.TrackAlias and their own mapping.
//
// Resolution is live: it queries the registry at call time, not at accept time.
// So if a subgroup stream is accepted before the SUBSCRIBE_OK that binds its
// alias (a legitimate §11.1 ordering), a TrackKey call once the alias has been
// registered resolves correctly rather than being pinned to a stale snapshot.
func (s *IncomingSubgroupStream) TrackKey() (track.Key, bool) {
return s.sess.LookupInboundTrackAlias(s.Header.TrackAlias)
}
func (s *IncomingSubgroupStream) isDataStream() {}
// Read returns body bytes that follow the parsed header. Prefer ReadObject
// for correctly-framed object access.
func (s *IncomingSubgroupStream) Read(p []byte) (int, error) { return s.br.Read(p) }
// Cancel resets the stream with the given application code (§3.3.4).
func (s *IncomingSubgroupStream) Cancel(code moqt.StreamResetCode) {
s.src.CancelRead(uint64(code))
}
// ReadObject reads the next framed SubgroupObject from the stream body.
// Returns (nil, io.EOF) when the peer has FIN'd the stream cleanly. The
// returned [message.SubgroupObject] holds the raw §11.4.2 ObjectIDDelta;
// use [IncomingSubgroupStream.ReadDecoded] when you want absolute IDs and
// implicit SubgroupID resolution done for you.
func (s *IncomingSubgroupStream) ReadObject() (*message.SubgroupObject, error) {
obj := &message.SubgroupObject{}
if err := obj.Parse(s.rd, s.Header.Properties); err != nil {
return nil, err
}
if err := obj.Validate(); err != nil {
return nil, fmt.Errorf("moqt/session: subgroup object: %w", err)
}
return obj, nil
}
// DecodedSubgroupObject is the absolute-coordinates view of one §11.4.2
// SubgroupObject. ReadDecoded reconstructs the absolute ObjectID from the
// per-object delta + the running previous ObjectID, and resolves the
// SubgroupID from the header's [message.SubgroupIDMode]:
//
// - SubgroupIDImplicitZero → SubgroupID = 0
// - SubgroupIDImplicitFirstObject → SubgroupID = first object's ObjectID
// - SubgroupIDExplicit → SubgroupID = header's value
//
// GroupID is constant for the stream (from the header) and is copied
// onto every decoded object so callers can pass the decoded value alone
// without also threading the stream header through their pipeline.
type DecodedSubgroupObject struct {
GroupID uint64
SubgroupID uint64
ObjectID uint64
ObjectStatus uint64
Properties []byte
Payload []byte
}
// ReadDecoded reads the next SubgroupObject and resolves §11.4.2 deltas
// into absolute coordinates, carrying decoder state across calls.
// Returns (nil, io.EOF) on clean stream FIN.
//
// The first object's ObjectIDDelta is its absolute ObjectID; subsequent
// objects' deltas encode (currentID - prevID - 1) so consecutive IDs all
// encode as zero.
func (s *IncomingSubgroupStream) ReadDecoded() (*DecodedSubgroupObject, error) {
raw, err := s.ReadObject()
if err != nil {
return nil, err
}
var objectID uint64
if !s.decHavePrev {
objectID = raw.ObjectIDDelta
} else {
objectID = s.decPrevObject + raw.ObjectIDDelta + 1
}
// Resolve the §11.4.2 SubgroupID mode once per stream. For
// SubgroupIDImplicitFirstObject the resolution depends on the
// first object's absolute ID, which is why we do it lazily here.
if !s.decSubgroupResolved {
switch s.Header.SubgroupIDMode {
case message.SubgroupIDImplicitZero:
s.decSubgroupID = 0
case message.SubgroupIDImplicitFirstObject:
s.decSubgroupID = objectID
case message.SubgroupIDExplicit:
s.decSubgroupID = s.Header.SubgroupID
}
s.decSubgroupResolved = true
}
d := &DecodedSubgroupObject{
GroupID: s.Header.GroupID,
SubgroupID: s.decSubgroupID,
ObjectID: objectID,
ObjectStatus: raw.ObjectStatus,
Properties: raw.Properties,
Payload: raw.Payload,
}
s.decPrevObject = objectID
s.decHavePrev = true
return d, nil
}
// ---------------------------------------------------------------------------
// IncomingFetchStream
// ---------------------------------------------------------------------------
// IncomingFetchStream is an accepted inbound FETCH_HEADER uni-stream whose
// leading header has already been parsed. The remaining bytes are the body,
// consumed via [IncomingFetchStream.ReadObject] (raw, delta-encoded fields),
// [IncomingFetchStream.ReadDecoded] (absolute IDs, with state carried across
// calls), or Read (raw bytes). The peer's FIN surfaces as io.EOF from any of
// these methods.
type IncomingFetchStream struct {
// Header is the parsed FETCH_HEADER (§11.4.4).
Header message.FetchHeader
src ReceiveStream
br *bufio.Reader
// rd is a StreamReader bound to br once at construction and reused by
// every ReadObject call — see [IncomingSubgroupStream.rd].
rd *wire.StreamReader
// GroupOrder tells [IncomingFetchStream.ReadDecoded] how to
// interpret cross-group GroupIDDeltas (§11.4.4.1): ascending →
// newGroup = prevGroup + delta + 1; descending → newGroup =
// prevGroup - delta - 1. The §11.4.4 wire format does not encode
// the direction; the caller knows it from the GROUP_ORDER
// parameter it sent in FETCH (or from the publisher default).
// Defaults to ascending when unset (zero value).
GroupOrder message.GroupOrder
// Decoder state used by ReadDecoded — running absolute values
// carried across objects so each call only has to apply the
// current object's deltas.
//
// decHavePrev means a prior Group/Object ID exists — a real object OR
// a §11.4.4.2 End-of-Range marker (markers ARE the prior for the
// Group/Object dimension). decHaveActual means a real object was
// decoded: only then do a prior Subgroup ID and prior Priority exist —
// §11.4.4.2: "If there was no prior Object, using a flag that
// references the prior Subgroup ID [or Priority] is a
// PROTOCOL_VIOLATION."
decPrevGroup uint64
decPrevObject uint64
decPrevSubgroup uint64
decPrevPriority uint8
decHavePrev bool
decHaveActual bool
}
func (s *IncomingFetchStream) isDataStream() {}
// Read returns body bytes that follow the parsed header. Prefer ReadObject
// for correctly-framed object access.
func (s *IncomingFetchStream) Read(p []byte) (int, error) { return s.br.Read(p) }
// Cancel resets the stream with the given application code (§3.3.4).
func (s *IncomingFetchStream) Cancel(code moqt.StreamResetCode) {
s.src.CancelRead(uint64(code))
}
// ReadObject reads the next framed FetchObject from the stream body.
// Returns (nil, io.EOF) when the peer has FIN'd the stream cleanly. Fields
// on the returned [message.FetchObject] are raw — GroupIDDelta and
// ObjectIDDelta carry §11.4.4 wire deltas, not absolute IDs. Use
// [IncomingFetchStream.ReadDecoded] when you want absolute IDs reconstructed
// for you.
func (s *IncomingFetchStream) ReadObject() (*message.FetchObject, error) {
obj := &message.FetchObject{}
if err := obj.Parse(s.rd); err != nil {
return nil, err
}
if err := obj.Validate(); err != nil {
return nil, fmt.Errorf("moqt/session: fetch object: %w", err)
}
return obj, nil
}
// DecodedFetchObject is the absolute-coordinates view of one §11.4.4
// FetchObject. ReadDecoded reconstructs GroupID / ObjectID / SubgroupID /
// PublisherPriority from the wire deltas + previous objects so the caller
// doesn't have to maintain state itself.
//
// End-of-range markers (§11.4.4.2) surface via EndOfNonExistentRange,
// EndOfUnknownRange or EndOfTimedOutRange; for those, GroupID / ObjectID hold the absolute range
// boundary the marker carries and the payload / properties fields are zero.
type DecodedFetchObject struct {
GroupID uint64
ObjectID uint64
SubgroupID uint64
PublisherPriority uint8
Properties []byte
Payload []byte
// Datagram reports the §11.4.4.1 Datagram bit (0x40): the object was
// published with Forwarding Preference "Datagram" and has no Subgroup
// ID (SubgroupID is 0).
Datagram bool
EndOfNonExistentRange bool // §11.4.4.2 flag 0x8C
EndOfUnknownRange bool // §11.4.4.2 flag 0x10C
EndOfTimedOutRange bool // §11.4.4.2 flag 0x20C (draft-20)
}
// IsEndOfRange reports whether this is any §11.4.4.2 end-of-range marker rather
// than a delivered Object. Markers describe a span of Locations — non-existent,
// unknown, or timed out — and carry no payload, subgroup, priority or
// properties, so most consumers want to skip them as a group. Prefer this over
// testing the three flags individually: draft-20 added the third one, and every
// place that had enumerated the first two silently started treating it as an
// Object.
func (d *DecodedFetchObject) IsEndOfRange() bool {
return d.EndOfNonExistentRange || d.EndOfUnknownRange || d.EndOfTimedOutRange
}
// ReadDecoded reads the next FetchObject and resolves §11.4.4 deltas
// into absolute coordinates, carrying decoder state across calls.
// Returns (nil, io.EOF) on clean stream FIN.
//
// Subgroup-ID encoding modes (§11.4.4.1): Zero, Prior, PriorPlusOne, and
// Explicit are all resolved against the previous object's SubgroupID.
// Priority is inherited from the previous object when the per-object
// PRIORITY flag is absent.
//
// The first object on the stream carries absolute GroupID / ObjectID
// directly in the delta fields (per §11.4.4); subsequent objects' deltas
// are interpreted using [IncomingFetchStream.GroupOrder] for cross-group
// transitions.
func (s *IncomingFetchStream) ReadDecoded() (*DecodedFetchObject, error) {
raw, err := s.ReadObject()
if err != nil {
return nil, err
}
// §11.4.4.2: end-of-range markers carry absolute Group/Object IDs in
// the otherwise-delta fields — and those values become the "prior
// Group ID and prior Object ID" for the next object. The prior
// Subgroup ID / Priority stay those of the last ACTUAL object
// (decHaveActual tracks whether one exists).
if raw.IsEndOfRange() {
s.decPrevGroup = raw.GroupIDDelta
s.decPrevObject = raw.ObjectIDDelta
s.decHavePrev = true
return &DecodedFetchObject{
GroupID: raw.GroupIDDelta,
ObjectID: raw.ObjectIDDelta,
EndOfNonExistentRange: raw.IsEndOfNonExistentRange(),
EndOfUnknownRange: raw.IsEndOfUnknownRange(),
EndOfTimedOutRange: raw.IsEndOfTimedOutRange(),
}, nil
}
d := &DecodedFetchObject{
Datagram: raw.IsDatagram(),
Properties: raw.Properties,
Payload: raw.ObjectPayload,
}
// §11.4.4 / §11.4.4.2: flags that reference the prior Object's
// Subgroup ID or Priority are a PROTOCOL_VIOLATION until a real object
// has been decoded — the very first object, and any object whose only
// predecessor is an End-of-Range marker, must spell both out. (When
// the Datagram bit is set the subgroup mode bits are ignored,
// §11.4.4.1.)
if !s.decHaveActual {
if !raw.IsDatagram() {
if m := raw.SubgroupMode(); m == message.FetchSubgroupIDPrior ||
m == message.FetchSubgroupIDPriorPlusOne {
return nil, fmt.Errorf(
"moqt/session: fetch object references prior subgroup with no prior object (flags 0x%X)",
raw.SerializationFlags)
}
}
if raw.SerializationFlags&message.FetchFlagPriority == 0 {
return nil, fmt.Errorf(
"moqt/session: fetch object references prior priority with no prior object (flags 0x%X)",
raw.SerializationFlags)
}
}
// Group / Object reconstruction.
switch {
case !s.decHavePrev:
// §11.4.4: the first object MUST include both a Group ID Delta and
// an Object ID Delta (its absolute IDs). If it instead uses a flag
// that references the prior object, that is a PROTOCOL_VIOLATION.
// (An End-of-Range marker counts as a prior for this dimension —
// decHavePrev is already true then.)
if raw.SerializationFlags&message.FetchFlagGroupIDDelta == 0 ||
raw.SerializationFlags&message.FetchFlagObjectIDDelta == 0 {
return nil, fmt.Errorf(
"moqt/session: first fetch object missing Group/Object ID delta (flags 0x%X)",
raw.SerializationFlags)
}
// First object: deltas carry absolute IDs (§11.4.4).
d.GroupID = raw.GroupIDDelta
d.ObjectID = raw.ObjectIDDelta
case raw.SerializationFlags&message.FetchFlagGroupIDDelta != 0:
// Cross-group: apply direction.
if s.decGroupOrder() == message.GroupOrderDescending {
d.GroupID = s.decPrevGroup - raw.GroupIDDelta - 1
} else {
d.GroupID = s.decPrevGroup + raw.GroupIDDelta + 1
}
d.ObjectID = raw.ObjectIDDelta
default:
// Same group, possibly consecutive. ObjectIDDelta is the
// gap (zero implied when the flag is absent).
d.GroupID = s.decPrevGroup
if raw.SerializationFlags&message.FetchFlagObjectIDDelta != 0 {
d.ObjectID = s.decPrevObject + raw.ObjectIDDelta + 1
} else {
d.ObjectID = s.decPrevObject + 1
}
}
// SubgroupID reconstruction per §11.4.4.1 modes. Datagram objects
// (bit 0x40) have no Subgroup ID and the mode bits are ignored; they
// also don't become the "prior Object's Subgroup ID" for later objects
// (the spec is silent here; this mirrors the §11.4.4.2 rule that the
// prior Subgroup ID comes from the last actual subgroup object).
if !d.Datagram {
switch raw.SubgroupMode() {
case message.FetchSubgroupIDZero:
d.SubgroupID = 0
case message.FetchSubgroupIDPrior:
d.SubgroupID = s.decPrevSubgroup
case message.FetchSubgroupIDPriorPlusOne:
d.SubgroupID = s.decPrevSubgroup + 1
case message.FetchSubgroupIDExplicit:
d.SubgroupID = raw.SubgroupID
}
s.decPrevSubgroup = d.SubgroupID
}
// Priority: inherit from previous unless explicitly set on this object.
if raw.SerializationFlags&message.FetchFlagPriority != 0 {
d.PublisherPriority = raw.PublisherPriority
} else {
d.PublisherPriority = s.decPrevPriority
}
// Advance decoder state (decPrevSubgroup advances above, with the same
// datagram guard as its reconstruction).
s.decPrevGroup = d.GroupID
s.decPrevObject = d.ObjectID
s.decPrevPriority = d.PublisherPriority
s.decHavePrev = true
s.decHaveActual = true
return d, nil
}
// decGroupOrder returns the caller-configured GroupOrder, defaulting to
// ascending when the zero value is set. Ascending matches the relay's
// default FETCH response order, so most callers can ignore the field.
func (s *IncomingFetchStream) decGroupOrder() message.GroupOrder {
if s.GroupOrder == message.GroupOrderDescending {
return message.GroupOrderDescending
}
return message.GroupOrderAscending
}
// ---------------------------------------------------------------------------
// Session method — accept inbound data streams
// ---------------------------------------------------------------------------
// AcceptDataStream blocks until the peer opens the next data uni-stream,
// parses its leading header, and returns it wrapped so the caller can
// consume the body. The concrete type is either *IncomingSubgroupStream or
// *IncomingFetchStream; callers type-switch to obtain the typed stream.
//
// Per-stream parse failures (unknown Type, malformed varint, truncated
// header) reset the underlying stream before returning so the caller can
// keep looping. The returned errors carry the parse outcome:
// - *message.ReservedSubgroupIDModeError when the leading Type matches the
// SUBGROUP_HEADER pattern but carries the reserved SUBGROUP_ID_MODE 0b11
// (§11.4.2) — callers MUST close the session with PROTOCOL_VIOLATION;
// - *message.UnknownDataStreamTypeError when the leading Type isn't
// recognized;
// - ErrPaddingStream when a padding stream (§11.5.1) is received — callers
// SHOULD loop and call AcceptDataStream again;
// - a wrapped parser error otherwise.
//
// Transport-level errors (session closed, ctx cancelled) come through
// unwrapped from the underlying conn and signal the loop should terminate.
func (s *Session) AcceptDataStream(ctx context.Context) (DataStream, error) {
src, err := s.conn.AcceptUniStream(ctx)
if err != nil {
return nil, err
}
// The header reads below are context-free stream I/O; bridge ctx with
// CancelRead (the readResponse pattern) so a peer that opens a stream
// but stalls mid-header cannot wedge the accept loop past cancellation.
stop := context.AfterFunc(ctx, func() {
src.CancelRead(uint64(moqt.StreamResetCancelled))
})
defer stop()
br := bufio.NewReader(src)
typ, err := message.ReadDataStreamType(br)
if err != nil {
src.CancelRead(uint64(moqt.StreamResetInternalError))
if ctx.Err() != nil {
return nil, ctx.Err()
}
return nil, fmt.Errorf("moqt/session: read data stream type: %w", err)
}
switch {
case message.IsSubgroupHeaderType(typ):
hdr, err := message.ReadSubgroupHeader(br, typ)
if err != nil {
src.CancelRead(uint64(moqt.StreamResetInternalError))
if ctx.Err() != nil {
return nil, ctx.Err()
}
return nil, fmt.Errorf("moqt/session: read SUBGROUP_HEADER: %w", err)
}
in := &IncomingSubgroupStream{Header: hdr, src: src, br: br, rd: wire.NewStreamReader(br), sess: s}
return in, nil
case message.IsFetchHeaderType(typ):
hdr, err := message.ReadFetchHeader(br)
if err != nil {
src.CancelRead(uint64(moqt.StreamResetInternalError))
if ctx.Err() != nil {
return nil, ctx.Err()
}
return nil, fmt.Errorf("moqt/session: read FETCH_HEADER: %w", err)
}
return &IncomingFetchStream{Header: hdr, src: src, br: br, rd: wire.NewStreamReader(br)}, nil
case typ == message.PaddingStreamType:
// §11.5.1: padding streams MUST be silently discarded. CancelRead
// (STOP_SENDING) abandons the stream and frees its flow control.
src.CancelRead(uint64(moqt.StreamResetInternalError))
return nil, ErrPaddingStream
case message.IsReservedSubgroupHeaderType(typ):
// §11.4.2: SUBGROUP_ID_MODE 0b11 is reserved — MUST be treated as
// a session-level PROTOCOL_VIOLATION. This is distinct from an
// unknown stream type (which may be ignorable / GREASE).
src.CancelRead(uint64(moqt.StreamResetInternalError))
return nil, &message.ReservedSubgroupIDModeError{Type: typ}
default:
src.CancelRead(uint64(moqt.StreamResetInternalError))
return nil, &message.UnknownDataStreamTypeError{Type: typ}
}
}
package session
import (
"context"
"errors"
"fmt"
"sync"
"time"
"github.com/floatdrop/moq-go/pkg/moqt"
"github.com/floatdrop/moq-go/pkg/moqt/message"
"github.com/floatdrop/moq-go/pkg/moqt/wire"
)
// ErrDeliveryTimeout is returned by OutgoingSubgroupStream.WriteObject,
// WriteObjectAt or WriteObjectReceivedAt when the OBJECT_DELIVERY_TIMEOUT has
// been exceeded for the object being written. The stream is reset with
// StreamResetDeliveryTimeout before this error is returned.
//
// Not returned by Write: the raw path cannot see object boundaries or receipt
// times, so it enforces no object timeout at all — see its own doc.
var ErrDeliveryTimeout = errors.New("moqt/session: object delivery timeout exceeded")
// ErrObjectIDNotIncreasing is returned by
// [OutgoingSubgroupStream.WriteObjectAt] when the supplied absolute Object ID
// is not strictly greater than the previous object's on the same stream. The
// §11.4.2 delta encoding is (currentID - previousID - 1), so Object IDs within
// a subgroup MUST strictly increase; WriteObjectAt rejects a violation instead
// of emitting an underflowed delta. Nothing is written and the stream stays
// usable.
var ErrObjectIDNotIncreasing = errors.New("moqt/session: subgroup object ID not strictly increasing")
// writerPool is a sync.Pool for wire.Writer instances to reduce allocations
// in WriteObject calls. The benchmark shows significant allocations from
// creating new Writer instances for each object.
var writerPool = sync.Pool{
New: func() any {
return wire.NewWriter(nil)
},
}
// ---------------------------------------------------------------------------
// OutgoingSubgroupStream
// ---------------------------------------------------------------------------
// OutgoingSubgroupStream is an outbound SUBGROUP_HEADER uni-stream whose
// leading header has already been written. WriteObject appends a framed
// SubgroupObject; Write appends raw body bytes; Close FINs the stream
// cleanly; Cancel resets it.
//
// If delivery timeouts are configured via WithDeliveryTimeouts:
// - OBJECT_DELIVERY_TIMEOUT: checked before every object is passed to the
// transport, against that object's own receipt time. If the elapsed time
// exceeds the timeout the stream is reset with StreamResetDeliveryTimeout
// and ErrDeliveryTimeout is returned.
// - SUBGROUP_DELIVERY_TIMEOUT: a timer is started when Close() is called.
// If the timer fires before the transport acknowledges all data
// (SendStream.Context() done), the stream is reset.
type OutgoingSubgroupStream struct {
header message.SubgroupHeader
dst SendStream
// pubTimeouts and subTimeouts are the two halves §8 resolves separately:
// the publisher's Track Property values (possibly overridden by the first
// object's Object Properties) and the subscriber's Message Parameter
// values. They are kept apart until the first object arrives because the
// override applies to the publisher's half ALONE — merging early and
// overriding the merged value would silently discard a subscriber timeout
// shorter than the publisher's override.
pubTimeouts message.DeliveryTimeouts
subTimeouts message.DeliveryTimeouts
objectTimeout time.Duration // resolved; 0 = disabled
subgroupTimeout time.Duration // resolved; 0 = disabled
// sawFirstObject gates the §12.1/§12.2 first-object delivery-timeout
// override: only the first object of the subgroup may override the
// Track-level timeouts, so the override is applied at most once.
sawFirstObject bool
// Encoder state for WriteObjectAt: the running absolute Object ID so each
// call only has to apply the §11.4.2 delta. encHavePrev is false until the
// first object is written (its delta is the absolute ID).
encPrevObject uint64
encHavePrev bool
}
// WithDeliveryTimeouts returns a shallow copy of s configured with the §8
// delivery timeouts. Zero values disable the corresponding timeout.
//
// The two halves are supplied separately because §8 does not treat them
// symmetrically: "the publisher's value is the Object Property when present on
// the first object of the subgroup, and the Track Property otherwise. If both
// the publisher's value and the subscriber's value are non-zero, the smaller
// of the two is used." The override therefore resolves within the publisher's
// half, and only the result is compared against the subscriber's. A caller
// that pre-merges the two loses that ordering: a first-object override would
// replace a subscriber timeout it was never allowed to outrank.
//
// A publisher with no subscriber-supplied values passes the zero
// DeliveryTimeouts as subscriber, which never wins over a non-zero publisher
// value.
func (s *OutgoingSubgroupStream) WithDeliveryTimeouts(
publisher, subscriber message.DeliveryTimeouts,
) *OutgoingSubgroupStream {
cp := *s
cp.pubTimeouts = publisher
cp.subTimeouts = subscriber
// Resolved now so a subgroup whose first object carries no override — the
// common case — enforces the right values from its very first write.
eff := publisher.Effective(subscriber)
cp.objectTimeout = eff.Object
cp.subgroupTimeout = eff.Subgroup
return &cp
}
// WriteObject serializes obj onto the stream with correct wire framing.
// The hasProperties flag is taken from the stored SubgroupHeader automatically.
//
// OBJECT_DELIVERY_TIMEOUT is measured from the moment this call is made, which
// is the correct §8 reading for an original publisher handing over an object as
// it is produced. A relay — which received the object earlier, and may have
// spent the interval blocked on some other subscriber — must use
// [OutgoingSubgroupStream.WriteObjectReceivedAt] instead, or the object's age
// is measured from the wrong end.
func (s *OutgoingSubgroupStream) WriteObject(obj *message.SubgroupObject) error {
return s.WriteObjectReceivedAt(time.Now(), obj)
}
// WriteObjectReceivedAt is [OutgoingSubgroupStream.WriteObject] with the
// object's §8 receipt time supplied by the caller: "the time at which the first
// payload byte of every object has been either received from the upstream
// subscription, or provided by the original publisher application".
//
// The clock is per object, not per stream. An object that reaches the transport
// promptly passes however long the stream has already been open, and one that
// queued behind a blocked write fails however new the stream is — which is the
// whole point, since a stream-lifetime cap would reset healthy subscribers for
// no reason other than having stayed subscribed.
func (s *OutgoingSubgroupStream) WriteObjectReceivedAt(
receivedAt time.Time,
obj *message.SubgroupObject,
) error {
// §12.1/§12.2: the first object of a subgroup may carry
// OBJECT/SUBGROUP_DELIVERY_TIMEOUT as Object Properties that override the
// Track-level values for this subgroup; the same properties on any later
// object "is ignored". The override applies to the publisher's half only,
// and the subscriber's values are compared against the result — see
// WithDeliveryTimeouts. Resolved before the timeout check so the overridden
// value takes effect immediately, and before Close reads subgroupTimeout.
//
// "First object of the subgroup", not "first object on this stream": the two
// diverge whenever a stream does not begin at the subgroup's start — a
// subscriber that joined mid-subgroup, or a §11.4.3 gap-reopen. §11.4.2's
// FIRST_OBJECT bit is exactly that distinction, and ReplayingSubgroup is its
// inverse, so a replay stream must not treat whatever object it happens to
// start with as carrying the subgroup's override.
if !s.sawFirstObject && s.header.Properties && !s.header.ReplayingSubgroup {
eff := s.pubTimeouts.ApplyObjectProperties(obj.Properties).Effective(s.subTimeouts)
s.objectTimeout = eff.Object
s.subgroupTimeout = eff.Subgroup
}
s.sawFirstObject = true
if err := s.checkObjectTimeout(receivedAt); err != nil {
return err
}
wr, _ := writerPool.Get().(*wire.Writer)
wr.Reset()
obj.Append(wr, s.header.Properties)
_, err := s.dst.Write(wr.Bytes())
writerPool.Put(wr)
return err
}
// WriteObjectAt writes obj with its §11.4.2 ObjectIDDelta computed from the
// absolute objectID and the stream's running previous Object ID — the encoding
// mirror of [IncomingSubgroupStream.ReadDecoded]. The caller supplies absolute
// Object IDs (the way applications think about them); obj.ObjectIDDelta is
// ignored and overwritten. For the first object on the stream the delta is the
// absolute ID; for each later object it is (objectID - previousID - 1).
//
// Object IDs within a subgroup MUST strictly increase (the delta would
// otherwise underflow). If objectID is not greater than the previous object's,
// WriteObjectAt writes nothing, leaves the stream usable, and returns
// [ErrObjectIDNotIncreasing]. OBJECT_DELIVERY_TIMEOUT is enforced exactly as in
// [OutgoingSubgroupStream.WriteObject].
//
// Use the lower-level [OutgoingSubgroupStream.WriteObject] when you want to set
// ObjectIDDelta yourself.
func (s *OutgoingSubgroupStream) WriteObjectAt(objectID uint64, obj *message.SubgroupObject) error {
if s.encHavePrev {
if objectID <= s.encPrevObject {
return fmt.Errorf("%w: object ID %d not greater than previous %d",
ErrObjectIDNotIncreasing, objectID, s.encPrevObject)
}
obj.ObjectIDDelta = objectID - s.encPrevObject - 1
} else {
obj.ObjectIDDelta = objectID
}
if err := s.WriteObject(obj); err != nil {
return err
}
s.encPrevObject = objectID
s.encHavePrev = true
return nil
}
// Write appends raw body bytes after the previously-written header. Prefer
// WriteObject for correctly-framed object access; Write is an escape hatch
// for callers that manage framing themselves.
//
// OBJECT_DELIVERY_TIMEOUT is NOT enforced here. §8 measures it per object,
// from the moment that object was received, and a caller that manages its own
// framing is the only party that knows where one object ends and the next
// begins — bytes handed to Write carry no such boundary. A caller that wants
// the timeout enforced should use
// [OutgoingSubgroupStream.WriteObjectReceivedAt], which has both facts.
// SUBGROUP_DELIVERY_TIMEOUT still applies, since Close observes it.
func (s *OutgoingSubgroupStream) Write(p []byte) (int, error) {
return s.dst.Write(p)
}
// checkObjectTimeout enforces OBJECT_DELIVERY_TIMEOUT against one object's
// receipt time, per §8: "the implementation MUST check the time elapsed since
// the first byte of the object before attempting to pass it to the underlying
// transport for transmission; if the time elapsed exceeds
// OBJECT_DELIVERY_TIMEOUT, it MUST reset the underlying transport stream with
// the reset stream code DELIVERY_TIMEOUT".
func (s *OutgoingSubgroupStream) checkObjectTimeout(receivedAt time.Time) error {
if s.objectTimeout <= 0 {
return nil
}
elapsed := time.Since(receivedAt)
if elapsed > s.objectTimeout {
s.dst.CancelWrite(uint64(moqt.StreamResetDeliveryTimeout))
return fmt.Errorf("%w (elapsed %s, limit %s)",
ErrDeliveryTimeout, elapsed, s.objectTimeout)
}
return nil
}
// Close FINs the send side cleanly. Callers must have no concurrent Writes
// in flight.
//
// If SUBGROUP_DELIVERY_TIMEOUT is set, Close starts a background goroutine
// that resets the stream if the transport has not acknowledged all data
// within the timeout duration. "All data acknowledged" is signalled by
// SendStream.Context() being done.
func (s *OutgoingSubgroupStream) Close() error {
err := s.dst.Close()
if s.subgroupTimeout > 0 {
streamCtx := s.dst.Context()
timeout := s.subgroupTimeout
dst := s.dst
go func() {
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case <-streamCtx.Done():
// All data acknowledged (or stream already reset) — nothing to do.
case <-timer.C:
// Timer fired before ACK: reset the stream per §8.
dst.CancelWrite(uint64(moqt.StreamResetDeliveryTimeout))
}
}()
}
return err
}
// Cancel resets the stream with the given application code (§3.3.4).
func (s *OutgoingSubgroupStream) Cancel(code moqt.StreamResetCode) {
s.dst.CancelWrite(uint64(code))
}
// SetSendPriority forwards the composite §7.2 scheduling key to the underlying
// transport when it supports per-stream prioritisation (i.e. implements
// [PrioritizedSendStream]). Adapters that don't satisfy the interface
// silently no-op. See [StreamPriority] and [PrioritizedSendStream] for the
// full contract.
func (s *OutgoingSubgroupStream) SetSendPriority(priority StreamPriority) {
if p, ok := s.dst.(PrioritizedSendStream); ok {
p.SetSendPriority(priority)
}
}
// MarkReliable marks the bytes written to this subgroup stream so far (the
// SUBGROUP_HEADER plus any objects) as reliably delivered even if the stream is
// later reset, when the transport supports the RESET_STREAM_AT extension (i.e.
// the underlying stream implements [ReliableResetStream]). This implements the
// §11.4.3 guidance that a reset data stream's reliable_size should cover at
// least the header. It is a no-op when the transport lacks the extension.
func (s *OutgoingSubgroupStream) MarkReliable() {
if r, ok := s.dst.(ReliableResetStream); ok {
r.SetReliableBoundary()
}
}
// ---------------------------------------------------------------------------
// OutgoingFetchStream
// ---------------------------------------------------------------------------
// OutgoingFetchStream is an outbound FETCH_HEADER uni-stream whose leading
// header has already been written. WriteObject appends a framed FetchObject;
// Write appends raw body bytes; Close FINs the stream cleanly; Cancel resets
// it. Fetch streams do not carry delivery timeouts.
type OutgoingFetchStream struct {
dst SendStream
}
// WriteObject serializes obj onto the stream with correct wire framing.
func (s *OutgoingFetchStream) WriteObject(obj *message.FetchObject) error {
wr, _ := writerPool.Get().(*wire.Writer)
wr.Reset()
obj.Append(wr)
_, err := s.dst.Write(wr.Bytes())
writerPool.Put(wr)
return err
}
// Write appends raw body bytes after the previously-written header. Prefer
// WriteObject for correctly-framed object access.
func (s *OutgoingFetchStream) Write(p []byte) (int, error) { return s.dst.Write(p) }
// Close FINs the send side cleanly.
func (s *OutgoingFetchStream) Close() error { return s.dst.Close() }
// Cancel resets the stream with the given application code (§3.3.4).
func (s *OutgoingFetchStream) Cancel(code moqt.StreamResetCode) {
s.dst.CancelWrite(uint64(code))
}
// ---------------------------------------------------------------------------
// Session method — open outbound data streams
// ---------------------------------------------------------------------------
// OpenSubgroup opens an outbound SUBGROUP_HEADER uni-stream (§11.4.2),
// writes the full header (Type, Track Alias, Group ID, optional Subgroup ID,
// optional Publisher Priority), and returns the body writer. The caller MUST
// Close to FIN the stream once all objects have been written, or Cancel to
// reset.
func (s *Session) OpenSubgroup(h message.SubgroupHeader) (*OutgoingSubgroupStream, error) {
return s.OpenSubgroupContext(context.Background(), h)
}
// OpenSubgroupContext is [Session.OpenSubgroup] with a cancellation bound on
// the header write: writing the SUBGROUP_HEADER blocks on the receiver's
// flow control, so a peer that stops reading can wedge the caller
// indefinitely. Cancelling ctx resets the nascent stream and unblocks the
// write. ctx does not govern the returned stream's later writes — bound
// those separately (e.g. a context.AfterFunc calling Cancel).
func (s *Session) OpenSubgroupContext(
ctx context.Context,
h message.SubgroupHeader,
) (*OutgoingSubgroupStream, error) {
dst, err := s.conn.OpenUniStream()
if err != nil {
return nil, err
}
stop := context.AfterFunc(ctx, func() {
dst.CancelWrite(uint64(moqt.StreamResetCancelled))
})
if err := message.WriteSubgroupHeader(dst, h); err != nil {
stop()
dst.CancelWrite(uint64(moqt.StreamResetInternalError))
if ctx.Err() != nil {
return nil, fmt.Errorf("moqt/session: write SUBGROUP_HEADER: %w", ctx.Err())
}
return nil, fmt.Errorf("moqt/session: write SUBGROUP_HEADER: %w", err)
}
if !stop() {
// The AfterFunc already ran: ctx was cancelled while (or just
// after) the header write went through — the stream is reset.
return nil, fmt.Errorf("moqt/session: write SUBGROUP_HEADER: %w", ctx.Err())
}
return &OutgoingSubgroupStream{header: h, dst: dst}, nil
}
package session
import (
"context"
"errors"
"sync"
"github.com/floatdrop/moq-go/pkg/moqt"
)
// SubgroupHandler handles one inbound SUBGROUP_HEADER stream that a [Demux]
// routed to it by §11.1 Track Alias. It is invoked synchronously — usually by
// [Demux.Run], but also by [Demux.HandleTrack], which hands over any streams
// parked before the handler existed on the goroutine that registers it. Those
// two can therefore overlap: a handler holding per-track state of its own must
// synchronise it. Spawn a goroutine inside the handler when streams must be
// processed concurrently (see [Demux.Run]).
type SubgroupHandler func(*IncomingSubgroupStream)
// FetchHandler handles one inbound FETCH_HEADER stream that a [Demux] routed to
// it by §11.5 Request ID. Invoked synchronously by [Demux.Run].
type FetchHandler func(*IncomingFetchStream)
// Demux routes the data streams accepted from a [Session] to per-track and
// per-request handlers, replacing the hand-rolled "AcceptDataStream loop +
// type-switch + Track-Alias match" a subscriber otherwise writes.
//
// Subgroup streams are dispatched by their §11.1 Track Alias — the value a
// subscriber gets from [Subscription.TrackAlias]; FETCH streams by their §11.4.4
// Request ID — the ID the subscriber's FETCH was assigned. A FETCH stream with
// no registered handler is passed to the OnUnknown callback; an unmatched
// subgroup stream is parked instead, for the reasons below.
//
// Handlers may be registered or replaced at any time, including while
// [Demux.Run] is executing: a subscriber learns a track's alias only from its
// SUBSCRIBE_OK, which can arrive after Run has started. Registration is
// safe for concurrent use.
//
// A subgroup stream whose Track Alias has no handler yet is PARKED rather than
// passed to OnUnknown, and released to the handler [Demux.HandleTrack]
// registers for that alias. This is not a nicety: a publisher may start
// sending a track's subgroup streams as soon as it has accepted the SUBSCRIBE,
// which can be before SUBSCRIBE_OK has come back and named the alias to
// register under, so the first Groups of a live broadcast routinely arrive
// with nowhere to go. Resetting them loses that media, and against at least
// one CDN it did worse — two streams reset on arrival and the subscription
// then delivered nothing for the rest of the run.
//
// §11.4.2 says exactly what the choice is — "if an endpoint receives a
// subgroup with an unknown Track Alias, it MAY abandon the stream, or choose
// to buffer it for a brief period to handle reordering with the control
// message that establishes the Track Alias" — and abandoning was measured to
// cost whole runs.
//
// "A brief period" is what [parkLimit] bounds: at most that many streams wait
// per alias, past which the oldest is reset, and [Demux.Run] resets whatever
// is still parked when it returns. Without a bound a stream for an alias
// nobody ever resolves would sit open with its flow control withheld for the
// life of the session, where resetting it at least frees the peer.
//
// OnUnknown therefore sees FETCH streams with no registered handler, not
// subgroup streams.
//
// The zero value is not ready for use — construct with [NewDemux].
type Demux struct {
mu sync.Mutex
subgroup map[uint64]SubgroupHandler // keyed by Track Alias
fetch map[uint64]FetchHandler // keyed by Request ID
parked map[uint64][]*IncomingSubgroupStream
parkedN int // total across parked, kept in step with it
retired map[uint64]struct{} // aliases registered and then unregistered
onUnknown func(DataStream)
}
// parkLimit is how many subgroup streams may wait for one Track Alias at once,
// and parkTotalLimit how many may wait across all of them. The window being
// covered is a single control-message round trip, so a few Groups' worth is
// the right order; past either bound a stream is reset, which is §11.4.2's
// other option ("MAY abandon the stream").
//
// Both are counts, where §11.4.2 says "for a brief period" — time. Nothing
// here evicts on a timer: with fewer than parkLimit streams for an alias that
// is never claimed, they wait until [Demux.Run] returns. What the counts bound
// is how much can be pinned open at once, which is the part that matters for
// the deadlock §11.4.2 warns about.
//
// parkTotalLimit exists because per-alias bounding alone is not a bound: a
// peer opening subgroup streams for many bogus aliases would park parkLimit of
// each. A parked stream is header-parsed and then unread, so its body sits in
// the transport's receive buffers and consumes the CONNECTION-level window —
// and §11.4.2 continues, past the sentence quoted on [Demux]: "To prevent
// deadlocks, endpoints MUST allocate connection flow control to the control
// streams before allocating it to any data streams. Otherwise, a receiver
// might wait for a control message containing a Track Alias to release flow
// control, while the sender waits for flow control to send the message." That
// MUST binds the transport adapter, which is below this layer and does not
// currently enforce it; parkTotalLimit is what keeps Demux from being the
// thing that walks into it.
const (
parkLimit = 8
parkTotalLimit = 32
)
// NewDemux returns an empty Demux ready for handler registration.
func NewDemux() *Demux {
return &Demux{
subgroup: make(map[uint64]SubgroupHandler),
fetch: make(map[uint64]FetchHandler),
parked: make(map[uint64][]*IncomingSubgroupStream),
retired: make(map[uint64]struct{}),
}
}
// HandleTrack registers h for inbound subgroup streams whose Track Alias is
// alias — typically the [Subscription.TrackAlias] of a subscription this side
// opened. A nil h unregisters alias; registering an alias that already has a
// handler replaces it.
//
// Streams for alias that arrived before this call were parked (see [Demux])
// and are handed to h now, in arrival order, before HandleTrack returns.
// It reports how many — a caller whose output is timing-sensitive wants to
// know that those Groups arrived earlier than they were read.
func (d *Demux) HandleTrack(alias uint64, h SubgroupHandler) (released int) {
d.mu.Lock()
if h == nil {
delete(d.subgroup, alias)
// §11.1: "Objects can arrive after a subscription has been
// cancelled. Subscribers SHOULD retain sufficient state to quickly
// discard these unwanted Objects, rather than treating them as
// belonging to an unknown Track Alias." Retiring the alias is that
// state: what is already parked goes now, and anything later for
// this alias is discarded on arrival rather than parked, since the
// control message parking waits for is never coming.
d.retired[alias] = struct{}{}
stale := d.parked[alias]
d.dropParkedLocked(alias)
d.mu.Unlock()
for _, s := range stale {
s.Cancel(moqt.StreamResetCancelled)
}
return 0
}
delete(d.retired, alias) // re-subscribed under the same alias
d.subgroup[alias] = h
held := d.parked[alias]
d.dropParkedLocked(alias)
d.mu.Unlock()
// Outside the lock: a handler may block for the life of the stream.
for _, s := range held {
h(s)
}
return len(held)
}
// HandleFetch registers h for the inbound FETCH stream answering the FETCH with
// the given Request ID. A nil h unregisters it; re-registering replaces.
func (d *Demux) HandleFetch(requestID uint64, h FetchHandler) {
d.mu.Lock()
defer d.mu.Unlock()
if h == nil {
delete(d.fetch, requestID)
return
}
d.fetch[requestID] = h
}
// OnUnknown sets the callback invoked for an accepted FETCH stream that
// matches no registered handler. With no callback set (the default, or a nil
// f), such a stream is reset with StreamResetInternalError and dropped so it
// does not leak.
//
// It does NOT see subgroup streams. One whose Track Alias has no handler is
// parked (see [Demux]) rather than reported, because at that point it is far
// more likely to be early than unwanted. One arriving for an alias that was
// registered and then unregistered is reset immediately, per §11.1.
func (d *Demux) OnUnknown(f func(DataStream)) {
d.mu.Lock()
defer d.mu.Unlock()
d.onUnknown = f
}
// parkLocked holds s until [Demux.HandleTrack] claims its alias, resetting the
// oldest once parkLimit is exceeded. Caller holds d.mu.
func (d *Demux) parkLocked(s *IncomingSubgroupStream) bool {
alias := s.Header.TrackAlias
if _, retired := d.retired[alias]; retired {
return false // §11.1: discard promptly, do not buffer.
}
if d.parkedN >= parkTotalLimit {
return false // see parkTotalLimit.
}
d.parked[alias] = append(d.parked[alias], s)
d.parkedN++
for len(d.parked[alias]) > parkLimit {
d.parked[alias][0].Cancel(moqt.StreamResetCancelled)
d.parked[alias] = d.parked[alias][1:]
d.parkedN--
}
return true
}
// dropParkedLocked forgets alias's queue, keeping parkedN in step. It does not
// touch the streams; the caller owns them. Caller holds d.mu.
func (d *Demux) dropParkedLocked(alias uint64) {
d.parkedN -= len(d.parked[alias])
delete(d.parked, alias)
}
// discardParked resets everything still waiting for an alias nobody claimed,
// so a stream does not sit open holding its flow control for the life of the
// session. §3.3.4 asks for a relevant code, and a subscriber winding down is
// not an implementation fault: reporting one would have a peer's metrics blame
// this end for a normal end of run.
func (d *Demux) discardParked() {
d.mu.Lock()
defer d.mu.Unlock()
for _, held := range d.parked {
for _, s := range held {
s.Cancel(moqt.StreamResetCancelled)
}
}
clear(d.parked)
d.parkedN = 0
}
// Run accepts data streams from sess and dispatches each to its registered
// handler until ctx is cancelled or [Session.AcceptDataStream] returns a
// non-padding error, which Run returns. Padding streams (§11.5.1) are skipped.
//
// Dispatch is synchronous: a handler runs to completion before Run accepts the
// next stream, mirroring a hand-written accept loop. A handler that reads a
// long-lived stream therefore blocks the loop, so spawn a goroutine inside the
// handler when streams must be read concurrently.
func (d *Demux) Run(ctx context.Context, sess *Session) error {
defer d.discardParked()
for {
ds, err := sess.AcceptDataStream(ctx)
if err != nil {
if errors.Is(err, ErrPaddingStream) {
continue
}
return err
}
d.dispatch(ds)
}
}
// dispatch routes one accepted data stream to its registered handler, or to the
// unknown path when none matches.
func (d *Demux) dispatch(ds DataStream) {
switch s := ds.(type) {
case *IncomingSubgroupStream:
d.mu.Lock()
h := d.subgroup[s.Header.TrackAlias]
if h == nil {
// Early, not unwanted — unless the alias is retired or the
// park is full, in which case §11.4.2's other option applies.
parked := d.parkLocked(s)
d.mu.Unlock()
if !parked {
s.Cancel(moqt.StreamResetCancelled)
}
return
}
d.mu.Unlock()
h(s)
return
case *IncomingFetchStream:
d.mu.Lock()
h := d.fetch[s.Header.RequestID]
d.mu.Unlock()
if h != nil {
h(s)
return
}
}
d.unknown(ds)
}
func (d *Demux) unknown(ds DataStream) {
d.mu.Lock()
f := d.onUnknown
d.mu.Unlock()
if f != nil {
f(ds)
return
}
ds.Cancel(moqt.StreamResetInternalError)
}
package session
import (
"context"
"fmt"
"github.com/floatdrop/moq-go/pkg/moqt"
"github.com/floatdrop/moq-go/pkg/moqt/message"
)
// FetchRequest is a live FETCH operation. It owns the request stream
// (embedded, so Close / reads / message.Marshal work directly on it) plus the
// Request ID follow-up traffic needs, so the caller can send REQUEST_UPDATE via
// [FetchRequest.Update] without holding it separately. The response objects
// arrive on a separate FETCH_HEADER uni-stream (§11.4.4) via
// [Session.AcceptDataStream], not on the embedded stream. It is returned by
// [Session.Fetch].
type FetchRequest struct {
// requestHandle carries the FETCH request stream — still open for
// REQUEST_UPDATE follow-ups (Close it to cancel the fetch) — and
// provides Update.
requestHandle
// OK is the parsed FETCH_OK response — EndOfTrack, EndLocation,
// negotiated Parameters, and TrackProperties.
OK *message.FetchOK
}
// Fetch opens a FETCH request stream (§10.13) and awaits FETCH_OK or
// REQUEST_ERROR. The session assigns m.RequestID; the caller supplies the
// track name and, in a LOCATION_FILTER parameter, the range (§5.1.2).
//
// On success a [FetchRequest] is returned whose embedded stream stays open (the
// caller may send REQUEST_UPDATE via [FetchRequest.Update]) and whose OK holds
// the parsed FETCH_OK. The publisher will open a FETCH_HEADER uni-stream (§11.4.4)
// carrying the response objects; the caller receives that via AcceptDataStream.
//
// On REQUEST_ERROR the stream is closed and a *RequestRejectedError is
// returned.
func (s *Session) Fetch(ctx context.Context, m *message.Fetch) (*FetchRequest, error) {
return awaitRequestResponse(ctx, s, m,
func(stream Stream, ok *message.FetchOK) (*FetchRequest, error) {
// §2.5.1: reject tracks with unknown mandatory track properties.
if err := s.validateTrackProperties(ok.TrackProperties, "FETCH_OK"); err != nil {
_ = stream.Close()
return nil, err
}
return &FetchRequest{
Stream: stream,
s: s,
requestID: m.RequestID,
OK: ok,
}, nil
})
}
// FetchResponder is the publisher side of a FETCH (§10.13) this endpoint
// accepted via [Request.AcceptFetch] — the accept-side counterpart of
// [Session.Fetch]. FETCH_OK has already been written on the embedded request
// stream; the response objects are streamed on a separate FETCH_HEADER
// uni-stream (§11.4.4) opened via [FetchResponder.OpenFetchStream], which binds
// this fetch's Request ID automatically. The embedded request stream stays open
// for REQUEST_UPDATE follow-ups.
type FetchResponder struct {
// Stream is the FETCH request stream, still open for REQUEST_UPDATE
// follow-ups. Close it to end the fetch.
Stream
s *Session
requestID uint64
}
// OpenFetchStream opens the outbound FETCH_HEADER uni-stream (§11.4.4) carrying
// this fetch's response objects, with the Request ID bound automatically. The
// caller MUST Close the returned stream to FIN it once all objects are written,
// or Cancel to reset. It is [Session.OpenFetchStream] pre-bound to this fetch.
func (f *FetchResponder) OpenFetchStream() (*OutgoingFetchStream, error) {
return f.s.OpenFetchStream(message.FetchHeader{RequestID: f.requestID})
}
// AcceptFetch accepts an inbound FETCH (§10.13) and returns a [FetchResponder]
// for streaming the response objects — the accept-side counterpart of
// [Session.Fetch]. r.First MUST be a *message.Fetch.
//
// ok carries the FETCH_OK fields the caller wants to set (EndOfTrack,
// EndLocation, negotiated Parameters, TrackProperties); it may be nil for the
// all-default reply. AcceptFetch writes FETCH_OK and returns a responder whose
// [FetchResponder.OpenFetchStream] is pre-bound to this fetch's Request ID.
func (r *Request) AcceptFetch(ok *message.FetchOK) (*FetchResponder, error) {
f, isFetch := r.First.(*message.Fetch)
if !isFetch {
return nil, fmt.Errorf("moqt/session: AcceptFetch on a %s request", r.First.Type())
}
if ok == nil {
ok = &message.FetchOK{}
}
if err := message.Marshal(r.Stream, ok); err != nil {
return nil, fmt.Errorf("moqt/session: write FETCH_OK: %w", err)
}
return &FetchResponder{Stream: r.Stream, s: r.s, requestID: f.RequestID}, nil
}
// OpenFetchStream opens an outbound FETCH_HEADER uni-stream (§11.4.4),
// writes the header (Type + Request ID), and returns the body writer. The
// caller MUST Close to FIN the stream once all fetch objects have been
// written, or Cancel to reset.
func (s *Session) OpenFetchStream(h message.FetchHeader) (*OutgoingFetchStream, error) {
dst, err := s.conn.OpenUniStream()
if err != nil {
return nil, err
}
if err := message.WriteFetchHeader(dst, h); err != nil {
dst.CancelWrite(uint64(moqt.StreamResetInternalError))
return nil, fmt.Errorf("moqt/session: write FETCH_HEADER: %w", err)
}
return &OutgoingFetchStream{dst: dst}, nil
}
package session
import (
"errors"
"time"
"github.com/floatdrop/moq-go/pkg/moqt/message"
)
// GoawayReceived returns a channel that is closed when a GOAWAY arrives from
// the peer. After the channel closes, PeerGoaway returns the parsed message.
func (s *Session) GoawayReceived() <-chan struct{} { return s.goawayCh }
// PeerGoaway returns the GOAWAY most recently received from the peer, or nil
// if none has arrived.
func (s *Session) PeerGoaway() *message.Goaway {
s.mu.Lock()
defer s.mu.Unlock()
return s.goawayReceived
}
// OnGoaway registers a callback invoked exactly once when the first GOAWAY
// arrives from the peer, passing the parsed message (whose NewSessionURI and
// Timeout drive client-side session migration per §3.6/§10.4). The handler
// runs in its own goroutine so it must not assume any ordering with other
// session activity, and it may safely block (e.g. to dial a new session and
// re-issue subscriptions) without stalling the control-receive loop.
//
// OnGoaway is level-triggered: if a GOAWAY has already been received when
// OnGoaway is called, the handler fires immediately. Only the most recently
// registered handler is retained, and the at-most-once guarantee is per
// session — a handler registered after the GOAWAY has already fired the
// previously registered one will itself fire (once) on registration.
//
// Passing a nil handler clears any previously registered callback (provided
// it has not yet fired).
func (s *Session) OnGoaway(handler func(*message.Goaway)) {
s.mu.Lock()
// If a GOAWAY already arrived and no handler has fired yet, run this one
// now and mark it fired so handleGoaway won't double-invoke.
if s.goawayReceived != nil && !s.goawayFired {
g := s.goawayReceived
s.goawayFired = true
s.mu.Unlock()
if handler != nil {
go handler(g)
}
return
}
s.goawayHandler = handler
s.mu.Unlock()
}
// SendGoaway sends a GOAWAY on the control stream and transitions the session
// to the draining state. newURI may be empty; timeout is the grace period
// before the local side may forcibly close the session with GOAWAY_TIMEOUT.
// Returns an error if GOAWAY has already been sent, or if the local role is
// client and newURI is non-empty (§10.4: "A client MUST NOT include a New
// Session URI").
func (s *Session) SendGoaway(timeout time.Duration, newURI string) error {
if s.role == roleClient && newURI != "" {
return errors.New("moqt/session: client MUST NOT send GOAWAY with New Session URI")
}
s.mu.Lock()
if s.goawaySent {
s.mu.Unlock()
return errors.New("moqt/session: GOAWAY already sent")
}
s.goawaySent = true
s.mu.Unlock()
msg := &message.Goaway{
NewSessionURI: []byte(newURI),
//nolint:gosec // G115: timeout is non-negative; whole ms fits a varint.
Timeout: uint64(timeout / time.Millisecond),
}
return s.sendControl(msg)
}
// 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.
func (s *Session) handleGoaway(m *message.Goaway) error {
s.mu.Lock()
if s.goawayReceived != nil {
s.mu.Unlock()
return errors.New("duplicate GOAWAY on control stream")
}
// §10.4: a client cannot direct a server to migrate, so a non-empty URI
// from a client is a PROTOCOL_VIOLATION. From our perspective, that means
// if we are the server we must reject a GOAWAY with a URI.
if s.role == roleServer && len(m.NewSessionURI) > 0 {
s.mu.Unlock()
return errors.New("GOAWAY from client carries non-empty URI")
}
s.goawayReceived = m
// Snapshot the registered handler under the lock and mark it fired so a
// later OnGoaway call won't re-invoke it. Run it in its own goroutine
// (outside the lock) so a blocking migration handler can't stall the
// control-receive loop.
var handler func(*message.Goaway)
if s.goawayHandler != nil && !s.goawayFired {
handler = s.goawayHandler
s.goawayFired = true
}
s.mu.Unlock()
close(s.goawayCh)
if handler != nil {
go handler(m)
}
return nil
}
package session
import (
"context"
"fmt"
"golang.org/x/sync/errgroup"
"github.com/floatdrop/moq-go/pkg/moqt"
"github.com/floatdrop/moq-go/pkg/moqt/message"
"github.com/floatdrop/moq-go/pkg/moqt/wire"
)
// handshake performs the SETUP exchange (§3.3). Each side opens a
// unidirectional control stream and writes SETUP, then accepts the peer's
// stream and reads theirs. The two directions run in parallel under an
// errgroup whose derived context cancels the sibling when either side fails,
// and BOTH the SETUP write and the SETUP read are bridged to that context
// with context.AfterFunc → CancelWrite/CancelRead (the readResponse pattern):
// stream I/O is context-free, so without the bridge a peer that opens the
// control stream but stalls mid-SETUP (or stops granting flow-control
// credit) would block the handshake past ctx cancellation — wedging, for a
// relay, the per-conn handler goroutine that Stop must join.
//
// Per §3.3, until SETUP is exchanged a peer may also open uni-streams for
// objects or bidi-streams for requests, we assume the peer is
// well-behaved and the first unidirectional stream it opens is the control
// stream beginning with SETUP. Out-of-order stream handling is not yet implemented.
func (s *Session) handshake(ctx context.Context, options []wire.KVPair) error {
g, gctx := errgroup.WithContext(ctx)
var (
sendStream SendStream
recvStream ReceiveStream
peerOpts []wire.KVPair
)
g.Go(func() error {
stream, err := s.conn.OpenUniStream()
if err != nil {
return fmt.Errorf("open send control: %w", err)
}
stop := context.AfterFunc(gctx, func() {
stream.CancelWrite(uint64(moqt.StreamResetCancelled))
})
defer stop()
if err := message.Marshal(stream, &message.Setup{Options: options}); err != nil {
stream.CancelWrite(uint64(moqt.StreamResetInternalError))
if gctx.Err() != nil {
return gctx.Err()
}
return fmt.Errorf("write SETUP: %w", err)
}
sendStream = stream
return nil
})
g.Go(func() error {
stream, err := s.conn.AcceptUniStream(gctx)
if err != nil {
return fmt.Errorf("accept control: %w", err)
}
stop := context.AfterFunc(gctx, func() {
stream.CancelRead(uint64(moqt.StreamResetCancelled))
})
defer stop()
msg, err := message.Parse(stream)
if err != nil {
stream.CancelRead(uint64(moqt.StreamResetInternalError))
if gctx.Err() != nil {
return gctx.Err()
}
return fmt.Errorf("read SETUP: %w", err)
}
setup, ok := msg.(*message.Setup)
if !ok {
stream.CancelRead(uint64(moqt.StreamResetInternalError))
return fmt.Errorf("expected SETUP, got %s", msg.Type())
}
recvStream = stream
peerOpts = setup.Options
return nil
})
if err := g.Wait(); err != nil {
return err
}
// A cancellation racing a fully successful exchange can fire a stale
// AfterFunc AFTER Marshal/Parse returned but BEFORE the deferred stop()
// detached it — resetting a stream we are about to adopt as the
// session's control stream while g.Wait still returns nil. Any stale
// fire implies ctx is cancelled by now, so failing here closes the
// window (the caller tears the conn down as on any handshake error).
if err := ctx.Err(); err != nil {
return err
}
s.sendCtrl = sendStream
s.recvCtrl = recvStream
s.peerOptions = peerOpts
return nil
}
// Package conntest holds transport-test helpers shared between the
// quicconn and wtconn adapter test packages. Keeping the self-signed
// certificate boilerplate here avoids duplicating it across both.
package conntest
import (
"crypto/ed25519"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"encoding/pem"
"math/big"
"net"
"testing"
"time"
)
// TLSConfig builds a one-shot ed25519 self-signed certificate valid for
// localhost / 127.0.0.1 and returns a *tls.Config advertising the given
// ALPN protocols. ed25519 key generation is orders of magnitude faster
// than RSA, which matters when the test runs under -race -count=N.
func TLSConfig(t *testing.T, nextProtos ...string) *tls.Config {
t.Helper()
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("ed25519.GenerateKey: %v", err)
}
template := x509.Certificate{
SerialNumber: big.NewInt(1),
DNSNames: []string{"localhost"},
IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1)},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
}
certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, pub, priv)
if err != nil {
t.Fatalf("x509.CreateCertificate: %v", err)
}
keyDER, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
t.Fatalf("MarshalPKCS8PrivateKey: %v", err)
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
cert, err := tls.X509KeyPair(certPEM, keyPEM)
if err != nil {
t.Fatalf("X509KeyPair: %v", err)
}
return &tls.Config{
Certificates: []tls.Certificate{cert},
NextProtos: nextProtos,
}
}
// SendDatagramUntilReceived sends payload repeatedly until recv yields a
// datagram or the deadline passes, and returns what arrived.
//
// Datagrams are unreliable by definition (§11.3), so a test that sent once and
// asserted receipt would be asserting something the transport never promised —
// and would fail occasionally against a correct implementation, which is the
// worst kind of test. Retrying removes the loss lottery without weakening
// anything: an adapter that never delivers still fails, just at the deadline
// rather than on the first drop.
//
// It lives here because both transport adapters need it and neither can import
// the other's test package.
func SendDatagramUntilReceived(
t *testing.T,
send func([]byte) error,
recv <-chan []byte,
payload []byte,
) []byte {
t.Helper()
deadline := time.Now().Add(10 * time.Second)
for {
if err := send(payload); err != nil {
t.Fatalf("SendDatagram: %v", err)
}
select {
case got := <-recv:
return got
case <-time.After(50 * time.Millisecond):
}
if time.Now().After(deadline) {
t.Fatal("no datagram arrived; the adapter never delivered one")
}
}
}
package conntest
import (
"context"
"errors"
"fmt"
"io"
"testing"
"time"
"github.com/floatdrop/moq-go/pkg/moqt"
"github.com/floatdrop/moq-go/pkg/moqt/session"
)
// Suite describes one [session.Conn] implementation for [RunSuite].
type Suite struct {
// NewPair returns two connected endpoints, cleaned up via t.Cleanup.
//
// bidiLimit, when > 0, caps how many bidirectional streams the CLIENT may
// open before the transport reports the peer's limit exhausted. How a
// transport expresses that is its own business — quic-go takes
// MaxIncomingStreams on the server, sessiontest caps the opener's credit
// directly — but the observable contract is the same. Implementations
// with SupportsBidiLimit false may ignore the argument.
NewPair func(t *testing.T, bidiLimit int64) (client, server session.Conn)
// SupportsBidiLimit reports whether NewPair can honour bidiLimit. When
// false, the ErrNoStreamCredit subtest skips with a reason rather than
// silently passing — see the note on that subtest.
SupportsBidiLimit bool
}
// RunSuite drives the behaviour every [session.Conn] adapter must implement
// identically, and is the reason it exists as a shared suite rather than as
// per-adapter tests.
//
// The session layer never imports a QUIC library: it is written against Conn
// and Stream, so every guarantee it relies on is a guarantee some adapter has
// to make good on. Three do (quicconn, wtconn, sessiontest), and only one of
// them — the in-process one — is exercised by `go test`. The other two carry
// the semantics that matter in production and are otherwise covered solely by
// the interop jobs. That is how a stale flag in entrypoint-relay.sh once broke
// the WebTransport path with the whole unit suite green.
//
// So the rule in CLAUDE.md — transport behaviour added to the interface must
// land in all three adapters — is enforced here mechanically instead of by
// review: add a subtest, and every adapter is held to it at once.
//
// What is pinned, and who depends on it:
//
// - Conn.Context ends when the connection does. The relay's per-session
// handler goroutines hang off it.
// - A send stream's Context ends once its data is delivered and it is
// closed. §8 SUBGROUP_DELIVERY_TIMEOUT enforcement arms a timer against
// exactly this signal, so an adapter that never fires it would leak the
// timer and one that fires early would reset healthy streams.
// - CancelWrite unblocks the peer's Read rather than leaving it parked.
// - OpenStream reports an exhausted peer limit as ErrNoStreamCredit. This
// one is a documented MUST on the interface, and PUBLISH_SKIPPED (§10.21)
// is built on it: the relay reacts to the sentinel instead of blocking.
// An adapter returning the raw transport error would make the relay hang
// where the spec says to send PUBLISH_SKIPPED.
//
// One constraint the subtests are written to: nothing here may assume the
// transport buffers a write the peer has not accepted yet. Real QUIC does, so
// a write-then-accept sequence passes on both real adapters — and deadlocks on
// sessiontest, whose streams are synchronous io.Pipes. Draining concurrently
// keeps the suite testing the Conn contract rather than each transport's
// buffering, which the contract says nothing about.
func RunSuite(t *testing.T, s Suite) {
t.Helper()
t.Run("ConnContextEndsOnClose", func(t *testing.T) {
client, _ := s.NewPair(t, 0)
select {
case <-client.Context().Done():
t.Fatal("Conn.Context was already done on a live connection")
default:
}
if err := client.CloseWithError(uint64(moqt.SessionNoError), "bye"); err != nil {
t.Fatalf("CloseWithError: %v", err)
}
awaitDone(client.Context(), t, "Conn.Context after CloseWithError")
})
t.Run("BidiStreamContextEndsAfterClose", func(t *testing.T) {
client, server := s.NewPair(t, 0)
stream, err := client.OpenStream()
if err != nil {
t.Fatalf("OpenStream: %v", err)
}
// The peer must drain for the send side to count as delivered: on a
// real transport the context tracks acknowledgement, not the local
// Close. Drain concurrently — see the note on buffering above.
drained := drainAsync(t, func() (io.Reader, error) { return server.AcceptStream(t.Context()) })
if _, err := stream.Write([]byte("hello")); err != nil {
t.Fatalf("Write: %v", err)
}
if err := stream.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
awaitDrain(t, drained, "hello")
awaitDone(stream.Context(), t, "bidi SendStream.Context after Close")
})
t.Run("UniStreamContextEndsAfterClose", func(t *testing.T) {
client, server := s.NewPair(t, 0)
stream, err := client.OpenUniStream()
if err != nil {
t.Fatalf("OpenUniStream: %v", err)
}
drained := drainAsync(t, func() (io.Reader, error) {
return server.AcceptUniStream(t.Context())
})
if _, err := stream.Write([]byte("hello")); err != nil {
t.Fatalf("Write: %v", err)
}
if err := stream.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
awaitDrain(t, drained, "hello")
awaitDone(stream.Context(), t, "uni SendStream.Context after Close")
})
t.Run("CancelWriteUnblocksPeerRead", func(t *testing.T) {
client, server := s.NewPair(t, 0)
stream, err := client.OpenStream()
if err != nil {
t.Fatalf("OpenStream: %v", err)
}
// Accept and read the first byte on another goroutine: a transport
// that does not surface a stream before it carries data needs the
// write to happen, and one with synchronous streams needs the read to
// happen for the write to return. Then park that goroutine in a second
// Read, which is what CancelWrite has to wake.
readErr := make(chan error, 1)
accepted := make(chan struct{})
go func() {
peer, err := server.AcceptStream(t.Context())
if err != nil {
readErr <- fmt.Errorf("accept: %w", err)
return
}
if _, err := io.ReadFull(peer, make([]byte, 1)); err != nil {
readErr <- fmt.Errorf("first read: %w", err)
return
}
close(accepted)
_, err = peer.Read(make([]byte, 1))
readErr <- err
}()
if _, err := stream.Write([]byte("x")); err != nil {
t.Fatalf("Write: %v", err)
}
select {
case <-accepted:
case err := <-readErr:
t.Fatalf("peer never parked in Read: %v", err)
case <-time.After(5 * time.Second):
t.Fatal("peer never received the first byte")
}
// The peer is now parked in Read. A reset must wake it with an error
// rather than an EOF: EOF would read as a clean end of data.
stream.CancelWrite(uint64(moqt.StreamResetInternalError))
select {
case err := <-readErr:
if err == nil {
t.Fatal("peer Read returned success after the writer reset the stream")
}
if errors.Is(err, io.EOF) {
t.Errorf("peer Read saw io.EOF after a reset, want a stream error: "+
"a reset must not be indistinguishable from a clean FIN (got %v)", err)
}
case <-time.After(5 * time.Second):
t.Fatal("CancelWrite did not unblock the peer's Read")
}
})
t.Run("OpenStreamReportsNoStreamCredit", func(t *testing.T) {
if !s.SupportsBidiLimit {
// Not a silent pass: this transport cannot be made to exhaust its
// own limit from a test. webtransport-go negotiates WebTransport
// stream limits over capsules and exposes no knob to lower them,
// so its ErrNoStreamCredit mapping is covered only by inspection
// and by the interop jobs.
t.Skip("transport cannot impose a bidi-stream limit in-test")
}
const limit = 2
client, _ := s.NewPair(t, limit)
for i := range limit {
if _, err := client.OpenStream(); err != nil {
t.Fatalf("OpenStream #%d within the limit: %v", i+1, err)
}
}
_, err := client.OpenStream()
if err == nil {
t.Fatal("OpenStream past the peer's limit succeeded; the limit was not applied")
}
if !errors.Is(err, session.ErrNoStreamCredit) {
t.Errorf("OpenStream past the peer's limit = %v, want session.ErrNoStreamCredit — "+
"the adapter must map its transport's stream-limit error onto the sentinel, "+
"or PUBLISH_SKIPPED (§10.21) cannot detect the condition", err)
}
})
}
// awaitDone fails the test unless ctx is cancelled promptly.
func awaitDone(ctx context.Context, t *testing.T, what string) {
t.Helper()
select {
case <-ctx.Done():
case <-time.After(5 * time.Second):
t.Fatalf("%s was never cancelled", what)
}
}
// drainAsync accepts a stream via accept and reads it to EOF on another
// goroutine, reporting the bytes (or the failure) on the returned channel.
// Concurrency is required, not stylistic: a synchronous-pipe transport blocks
// the writer until someone reads.
//
// It reports through a channel rather than calling t.Fatalf because Fatalf
// from a non-test goroutine does not stop the test.
func drainAsync(t *testing.T, accept func() (io.Reader, error)) <-chan drainResult {
t.Helper()
ch := make(chan drainResult, 1)
go func() {
r, err := accept()
if err != nil {
ch <- drainResult{err: fmt.Errorf("accept: %w", err)}
return
}
b, err := io.ReadAll(r)
ch <- drainResult{data: b, err: err}
}()
return ch
}
type drainResult struct {
data []byte
err error
}
// awaitDrain fails the test unless the peer read exactly want.
func awaitDrain(t *testing.T, ch <-chan drainResult, want string) {
t.Helper()
select {
case r := <-ch:
if r.err != nil {
t.Fatalf("draining the peer stream: %v", r.err)
}
if string(r.data) != want {
t.Fatalf("peer read %q, want %q", r.data, want)
}
case <-time.After(5 * time.Second):
t.Fatal("the peer never received the stream's data")
}
}
package session
import (
"context"
"fmt"
"github.com/floatdrop/moq-go/pkg/moqt/message"
)
// NamespacePublication is an established PUBLISH_NAMESPACE request (§10.16). It
// embeds the still-open request stream (so Close / writes / message.Marshal work
// directly on it) and carries the peer's REQUEST_OK. The caller announces tracks
// by writing NAMESPACE / NAMESPACE_DONE follow-ups to the embedded stream.
type NamespacePublication struct {
// Stream is the PUBLISH_NAMESPACE request stream, still open for
// NAMESPACE / NAMESPACE_DONE follow-ups. Close it to end the publication.
Stream
// OK is the REQUEST_OK the peer replied with.
OK *message.RequestOK
}
// NamespaceSubscription is an established SUBSCRIBE_NAMESPACE request (§10.19).
// It embeds the still-open request stream and carries the peer's REQUEST_OK;
// NAMESPACE / NAMESPACE_DONE notifications arrive by reading the embedded stream
// (e.g. via message.Parse).
type NamespaceSubscription struct {
// Stream is the SUBSCRIBE_NAMESPACE request stream, still open to receive
// NAMESPACE / NAMESPACE_DONE notifications. Close it to end the subscription.
Stream
// OK is the REQUEST_OK the peer replied with.
OK *message.RequestOK
}
// TrackSubscription is an established SUBSCRIBE_TRACKS request (§10.20). It
// embeds the still-open request stream and carries the peer's REQUEST_OK.
// Follow-up PUBLISH_SKIPPED notifications are read via
// [TrackSubscription.ReadPublishSkipped].
type TrackSubscription struct {
// Stream is the SUBSCRIBE_TRACKS request stream, still open to receive
// PUBLISH_SKIPPED follow-ups. Close it to end the subscription.
Stream
// OK is the REQUEST_OK the peer replied with.
OK *message.RequestOK
}
// PublishNamespace opens a PUBLISH_NAMESPACE request stream (§10.16) and
// awaits REQUEST_OK or REQUEST_ERROR. The session assigns m.RequestID; the
// caller supplies Namespace and optional Parameters.
//
// On success a [NamespacePublication] is returned whose embedded stream stays
// open (the caller may send NAMESPACE / NAMESPACE_DONE messages on it). On
// REQUEST_ERROR the stream is closed and a *RequestRejectedError is returned.
func (s *Session) PublishNamespace(
ctx context.Context,
m *message.PublishNamespace,
) (*NamespacePublication, error) {
return awaitRequestResponse(ctx, s, m,
func(stream Stream, ok *message.RequestOK) (*NamespacePublication, error) {
return &NamespacePublication{Stream: stream, OK: ok}, nil
})
}
// SubscribeNamespace opens a SUBSCRIBE_NAMESPACE request stream (§10.19) and
// awaits REQUEST_OK or REQUEST_ERROR. The session assigns m.RequestID; the
// caller supplies TrackNamespacePrefix and optional Parameters.
//
// On success a [NamespaceSubscription] is returned whose embedded stream stays
// open (the caller will receive NAMESPACE / NAMESPACE_DONE messages on it). On
// REQUEST_ERROR the stream is closed and a *RequestRejectedError is returned.
func (s *Session) SubscribeNamespace(
ctx context.Context,
m *message.SubscribeNamespace,
) (*NamespaceSubscription, error) {
return awaitRequestResponse(ctx, s, m,
func(stream Stream, ok *message.RequestOK) (*NamespaceSubscription, error) {
return &NamespaceSubscription{Stream: stream, OK: ok}, nil
})
}
// SubscribeTracks opens a SUBSCRIBE_TRACKS request stream (§10.20) and awaits
// REQUEST_OK or REQUEST_ERROR. The session assigns m.RequestID; the caller
// supplies TrackNamespacePrefix and optional Parameters.
//
// On success a [TrackSubscription] is returned whose embedded stream stays open
// for PUBLISH_SKIPPED follow-ups (read via [TrackSubscription.ReadPublishSkipped]).
// On REQUEST_ERROR the stream is closed and a *RequestRejectedError is returned.
func (s *Session) SubscribeTracks(ctx context.Context, m *message.SubscribeTracks) (*TrackSubscription, error) {
return awaitRequestResponse(ctx, s, m,
func(stream Stream, ok *message.RequestOK) (*TrackSubscription, error) {
return &TrackSubscription{Stream: stream, OK: ok}, nil
})
}
// IncomingNamespacePublication is an accepted inbound PUBLISH_NAMESPACE (§10.16)
// — the receiving side of [Session.PublishNamespace]'s [NamespacePublication],
// returned by [Request.AcceptPublishNamespace]. REQUEST_OK has been sent; the
// announcer's NAMESPACE / NAMESPACE_DONE follow-ups arrive by reading the
// embedded stream (e.g. via message.Parse). Close it to end the publication.
type IncomingNamespacePublication struct {
// Stream is the PUBLISH_NAMESPACE request stream, still open to receive
// NAMESPACE / NAMESPACE_DONE notifications. Close it to end the publication.
Stream
}
// IncomingNamespaceSubscription is an accepted inbound SUBSCRIBE_NAMESPACE
// (§10.19) — the announcing side of [Session.SubscribeNamespace]'s
// [NamespaceSubscription], returned by [Request.AcceptSubscribeNamespace].
// REQUEST_OK has been sent; the caller announces matching namespaces by writing
// NAMESPACE / NAMESPACE_DONE to the embedded stream (e.g. via message.Marshal).
// Close it to end the subscription.
type IncomingNamespaceSubscription struct {
// Stream is the SUBSCRIBE_NAMESPACE request stream, still open for
// NAMESPACE / NAMESPACE_DONE follow-ups. Close it to end the subscription.
Stream
}
// IncomingTrackSubscription is an accepted inbound SUBSCRIBE_TRACKS (§10.20) —
// the publishing side of [Session.SubscribeTracks]'s [TrackSubscription],
// returned by [Request.AcceptSubscribeTracks]. REQUEST_OK has been sent; the
// publisher forwards matching tracks as PUBLISH requests on new streams (see
// [Session.OpenPublish]) and signals stream exhaustion with
// [IncomingTrackSubscription.WritePublishSkipped] (§6.1 / §10.21). Close it to
// end the subscription.
type IncomingTrackSubscription struct {
// Stream is the SUBSCRIBE_TRACKS request stream, still open for
// PUBLISH_SKIPPED follow-ups. Close it to end the subscription.
Stream
}
// WritePublishSkipped sends a PUBLISH_SKIPPED (§6.1 / §10.21) on the
// SUBSCRIBE_TRACKS stream, telling the subscriber the publisher could not open a
// PUBLISH stream for the named track because it has no available bidirectional
// streams. It is the publisher-side counterpart of
// [TrackSubscription.ReadPublishSkipped].
func (t *IncomingTrackSubscription) WritePublishSkipped(pb *message.PublishSkipped) error {
return message.Marshal(t.Stream, pb)
}
// acceptNamespaceRequest is the shared accept path of the three namespace
// requests (§10.16 / §10.19 / §10.20): assert the request's first message is
// of type M, reply the all-default REQUEST_OK, and hand the still-open
// stream to wrap. op names the caller for error messages.
func acceptNamespaceRequest[M message.Message, T any](r *Request, op string, wrap func(Stream) T) (T, error) {
var zero T
if _, ok := r.First.(M); !ok {
return zero, fmt.Errorf("moqt/session: %s on a %s request", op, r.First.Type())
}
if err := message.Marshal(r.Stream, &message.RequestOK{}); err != nil {
return zero, fmt.Errorf("moqt/session: %s: write REQUEST_OK: %w", op, err)
}
return wrap(r.Stream), nil
}
// AcceptPublishNamespace accepts an inbound PUBLISH_NAMESPACE (§10.16), replies
// REQUEST_OK, and returns an [IncomingNamespacePublication] for receiving the
// announcer's NAMESPACE / NAMESPACE_DONE follow-ups — the accept-side
// counterpart of [Session.PublishNamespace]. r.First MUST be a
// *message.PublishNamespace.
func (r *Request) AcceptPublishNamespace() (*IncomingNamespacePublication, error) {
return acceptNamespaceRequest[*message.PublishNamespace](r, "AcceptPublishNamespace",
func(s Stream) *IncomingNamespacePublication { return &IncomingNamespacePublication{Stream: s} })
}
// AcceptSubscribeNamespace accepts an inbound SUBSCRIBE_NAMESPACE (§10.19),
// replies REQUEST_OK, and returns an [IncomingNamespaceSubscription] for
// announcing matching namespaces via NAMESPACE / NAMESPACE_DONE — the
// accept-side counterpart of [Session.SubscribeNamespace]. r.First MUST be a
// *message.SubscribeNamespace.
func (r *Request) AcceptSubscribeNamespace() (*IncomingNamespaceSubscription, error) {
return acceptNamespaceRequest[*message.SubscribeNamespace](r, "AcceptSubscribeNamespace",
func(s Stream) *IncomingNamespaceSubscription { return &IncomingNamespaceSubscription{Stream: s} })
}
// AcceptSubscribeTracks accepts an inbound SUBSCRIBE_TRACKS (§10.20), replies
// REQUEST_OK, and returns an [IncomingTrackSubscription] for forwarding matching
// PUBLISHes and sending PUBLISH_SKIPPED follow-ups — the accept-side counterpart
// of [Session.SubscribeTracks]. r.First MUST be a *message.SubscribeTracks.
func (r *Request) AcceptSubscribeTracks() (*IncomingTrackSubscription, error) {
return acceptNamespaceRequest[*message.SubscribeTracks](r, "AcceptSubscribeTracks",
func(s Stream) *IncomingTrackSubscription { return &IncomingTrackSubscription{Stream: s} })
}
// ReadPublishSkipped reads the next follow-up message on this SUBSCRIBE_TRACKS
// response stream and returns it as a PUBLISH_SKIPPED.
//
// This is the subscriber side of §6.1 / §10.21. After the initial REQUEST_OK,
// the publisher sends PUBLISH_SKIPPED on this stream when it cannot open a
// PUBLISH stream for a matching track because it has no available
// bidirectional streams. (Forwarded PUBLISHes themselves arrive on their own
// new bidi streams via [Session.AcceptRequest], not here.) The returned
// message names the track the publisher couldn't push; the caller's sanctioned
// recovery is to issue an explicit SUBSCRIBE for it.
//
// It blocks until a message arrives or the stream ends. A non-PUBLISH_SKIPPED
// message is reported as an error, as is the underlying read error (e.g.
// io.EOF when the publisher FINs the SUBSCRIBE_TRACKS stream).
func (t *TrackSubscription) ReadPublishSkipped() (*message.PublishSkipped, error) {
m, err := message.Parse(t.Stream)
if err != nil {
return nil, fmt.Errorf("moqt/session: read SUBSCRIBE_TRACKS follow-up: %w", err)
}
pb, ok := m.(*message.PublishSkipped)
if !ok {
return nil, fmt.Errorf("moqt/session: unexpected %s on SUBSCRIBE_TRACKS stream, want PUBLISH_SKIPPED", m.Type())
}
return pb, nil
}
package session
import (
"github.com/floatdrop/moq-go/pkg/moqt/message"
"github.com/floatdrop/moq-go/pkg/moqt/wire"
)
// Option configures a session opened via Client or Server. See WithPath,
// WithAuthority, WithImplementation, WithMaxAuthTokenCacheSize,
// WithTokenVerifier, and WithGrease for the available knobs.
type Option func(*config)
// config carries the resolved set of options applied to a single
// Client/Server call. Unexported so callers can only construct it via
// Option helpers.
type config struct {
setupOptions []wire.KVPair
knownMandatoryTrackProperties map[message.PropertyType]struct{}
// maxAuthTokenCacheSize is the byte budget for the inbound
// authorization-token alias cache (§10.2.2). It mirrors the value
// advertised to the peer via MAX_AUTH_TOKEN_CACHE_SIZE and is captured
// here by WithMaxAuthTokenCacheSize so open() can size the cache. The
// default (0) prohibits alias registration per §10.3.1.3.
maxAuthTokenCacheSize uint64
// maxRequestUpdates is the per-request-stream limit on unacknowledged
// inbound REQUEST_UPDATEs (§10.3.1.7). It mirrors the value advertised to
// the peer via MAX_REQUEST_UPDATES and is captured here by
// WithMaxRequestUpdates so the session can enforce it on the receive side.
// The default (0) means REQUEST_UPDATE concurrency is not limited.
maxRequestUpdates uint64
// maxFilterRanges is the per-subscription/fetch limit on the total number
// of Range Filter ranges (§10.3.1.6). It mirrors the value advertised via
// MAX_FILTER_RANGES and is captured by WithMaxFilterRanges so the receive
// side can reject over-limit filters with INVALID_FILTER. The default (0)
// prohibits Range Filters entirely.
maxFilterRanges uint64
// tokenVerifier is the optional application policy that turns a resolved
// (Type, Value) authorization token into an allow/deny decision. nil
// disables verification (all tokens are accepted by the transport; the
// application is responsible for any out-of-band checks).
tokenVerifier TokenVerifier
}
// WithImplementation sets the MOQT_IMPLEMENTATION SETUP option — a
// free-form identifier of this peer's implementation. Recommended for
// every peer; advisory in spec terms.
func WithImplementation(nameAndVersion string) Option {
return func(c *config) {
c.setupOptions = append(c.setupOptions, message.MOQTImplementationOption(nameAndVersion))
}
}
// WithPath sets the PATH SETUP option (§10.3.1.2). Client-only — using
// this on Server is a protocol violation per the spec.
func WithPath(pathAndQuery string) Option {
return func(c *config) {
c.setupOptions = append(c.setupOptions, message.PathOption(pathAndQuery))
}
}
// WithAuthority sets the AUTHORITY SETUP option (§10.3.1.1). Client-only.
func WithAuthority(authority string) Option {
return func(c *config) {
c.setupOptions = append(c.setupOptions, message.AuthorityOption(authority))
}
}
// WithMaxAuthTokenCacheSize sets MAX_AUTH_TOKEN_CACHE_SIZE — the maximum
// byte size of the per-session authorization-token alias cache (§10.2.2).
//
// The same budget sizes the inbound TokenCache the session uses to process
// AUTHORIZATION_TOKEN aliases on request streams: maxBytes is both advertised
// to the peer in SETUP and used to bound how many REGISTER tokens the peer may
// install. The default (option absent) is 0, which prohibits alias
// registration entirely per §10.3.1.3.
func WithMaxAuthTokenCacheSize(maxBytes uint64) Option {
return func(c *config) {
c.maxAuthTokenCacheSize = maxBytes
c.setupOptions = append(c.setupOptions, message.MaxAuthTokenCacheSizeOption(maxBytes))
}
}
// WithMaxRequestUpdates sets MAX_REQUEST_UPDATES (§10.3.1.7) — the maximum
// number of unacknowledged REQUEST_UPDATE messages this endpoint is willing to
// receive on any single request stream. The value is both advertised to the
// peer in SETUP and enforced on inbound follow-ups: a REQUEST_UPDATE that
// arrives while the stream already holds max outstanding updates closes the
// session with TOO_MANY_REQUEST_UPDATES.
//
// A REQUEST_UPDATE is outstanding from receipt until this endpoint writes the
// mandated REQUEST_OK/REQUEST_ERROR. The default (option absent, or 0) does not
// limit REQUEST_UPDATE concurrency.
func WithMaxRequestUpdates(maxUpdates uint64) Option {
return func(c *config) {
c.maxRequestUpdates = maxUpdates
c.setupOptions = append(c.setupOptions, message.MaxRequestUpdatesOption(maxUpdates))
}
}
// WithMaxFilterRanges sets MAX_FILTER_RANGES (§10.3.1.6) — the maximum total
// number of Range Filter ranges this endpoint will accept across all Range
// Filter parameters on a single subscription or fetch (§5.1.4). The value is
// advertised to the peer in SETUP and used to reject over-limit or (when 0)
// any Range Filters with INVALID_FILTER. The default (option absent, or 0)
// prohibits Range Filters entirely.
func WithMaxFilterRanges(maxRanges uint64) Option {
return func(c *config) {
c.maxFilterRanges = maxRanges
c.setupOptions = append(c.setupOptions, message.MaxFilterRangesOption(maxRanges))
}
}
// WithTokenVerifier installs an application policy that authorizes resolved
// AUTHORIZATION_TOKEN tokens (§10.2.2). After the session resolves a request's
// tokens (handling REGISTER / USE_ALIAS / USE_VALUE / DELETE against the
// inbound cache), it invokes v.VerifyToken for each resolved token so the
// application can validate signatures, expiry, audience, and scope — concerns
// the transport deliberately leaves out (§13.3).
//
// Passing nil (or never calling this option) disables verification: tokens are
// still parsed and aliases are still maintained, but no allow/deny decision is
// made at the transport layer.
func WithTokenVerifier(v TokenVerifier) Option {
return func(c *config) {
c.tokenVerifier = v
}
}
// WithGrease enables GREASE (§14): a random unknown SETUP option is injected
// into the outbound SETUP message to exercise the peer's tolerance of unknown
// values. GREASE values follow the pattern 0x7F * N + 0x9D and are always
// larger than all currently defined SETUP option types, so appending preserves
// the ascending-Type ordering required by §1.4.3.
func WithGrease() Option {
return func(c *config) {
c.setupOptions = append(c.setupOptions, message.GreaseSetupOption())
}
}
// WithKnownMandatoryTrackProperties configures the set of Mandatory Track
// Property types (range 0x4000–0x7FFF per §2.5.1) that this endpoint
// understands. When the session receives Track Properties (in SUBSCRIBE_OK,
// FETCH_OK, or TRACK_STATUS_OK) containing a mandatory property not in this
// set, it returns *ErrUnsupportedMandatoryTrackProperty.
//
// If this option is never called, mandatory track property enforcement is
// disabled — all properties are forwarded without inspection. This is the
// correct default for relays and other forwarding endpoints.
//
// End subscribers that interpret track data should call this option to opt
// in to enforcement. Pass an empty (non-nil) map to reject all mandatory
// properties, or populate the map with the types you support.
func WithKnownMandatoryTrackProperties(types map[message.PropertyType]struct{}) Option {
return func(c *config) {
c.knownMandatoryTrackProperties = types
}
}
package session
import (
"context"
"fmt"
"sync/atomic"
"github.com/floatdrop/moq-go/pkg/moqt"
"github.com/floatdrop/moq-go/pkg/moqt/message"
)
// Publication is a live track this side publishes objects on. It owns the
// request stream (embedded, so Close / writes / message.Marshal work directly on
// it) and the Track Alias the session assigned, and it opens subgroup
// uni-streams for the track via [Publication.OpenSubgroup] without the caller
// having to thread the alias around. It is returned both by [Session.Publish]
// (publisher-initiated, the PUBLISH side) and by [Request.AcceptSubscribe]
// (answering an inbound SUBSCRIBE) — in both cases this endpoint is the one
// sending objects.
type Publication struct {
// requestHandle carries the request stream — still open for follow-up
// traffic: PUBLISH_DONE, REQUEST_UPDATE, etc.; Close it to FIN the
// publication — and provides Update and Broker. Serving subscriber
// REQUEST_UPDATEs on a long-lived publication is what
// [requestHandle.Broker] + [RequestBroker.Serve] are for.
//
// §10.9 permits REQUEST_UPDATE only from the request's sender, plus
// the subscriber of a PUBLISH-established subscription — so Update is
// valid on a Publication from [Session.Publish] (this side sent the
// PUBLISH) but NOT on one from [Request.AcceptSubscribe], where this
// side is the publisher answering the peer's SUBSCRIBE.
requestHandle
alias uint64
// subgroupCount counts subgroup streams opened via OpenSubgroup, used as
// the §10.12 Stream Count when Done sends PUBLISH_DONE.
subgroupCount atomic.Uint64
}
// TrackAlias reports the §11.1 Track Alias bound to this publication — the
// integer inbound subgroup streams carry to identify the track. It is the
// value the caller supplied in message.Publish.TrackAlias, or, when that was
// the zero value, the one [Session.Publish] allocated via
// [Session.AllocOutboundTrackAlias].
func (p *Publication) TrackAlias() uint64 { return p.alias }
// OpenSubgroup opens an outbound SUBGROUP_HEADER uni-stream (§11.4.2) for this
// publication's track, filling in the Track Alias automatically — h.TrackAlias
// is ignored and overwritten. It is otherwise identical to
// [Session.OpenSubgroup]: the caller MUST Close the returned stream to FIN it
// once all objects are written, or Cancel to reset.
func (p *Publication) OpenSubgroup(h message.SubgroupHeader) (*OutgoingSubgroupStream, error) {
h.TrackAlias = p.alias
sg, err := p.s.OpenSubgroup(h)
if err != nil {
return nil, err
}
p.subgroupCount.Add(1)
return sg, nil
}
// Done ends the publication (§10.12): it writes a PUBLISH_DONE with the given
// status code and reason, then FINs the request stream. The §10.12 Stream Count
// is set to the number of subgroup streams opened via [Publication.OpenSubgroup]
// so a subscriber knows how many data streams to expect; this is exact only when
// every subgroup was opened through this handle (subgroups opened via
// [Session.OpenSubgroup] directly are not counted — send PUBLISH_DONE yourself
// via message.Marshal if you need a different count).
func (p *Publication) Done(code moqt.PublishDoneCode, reason string) error {
if err := p.writeThenClose(&message.PublishDone{
StatusCode: code,
StreamCount: p.subgroupCount.Load(),
ErrorReason: reason,
}); err != nil {
return fmt.Errorf("moqt/session: write PUBLISH_DONE: %w", err)
}
return nil
}
// IncomingPublication is the receiving side of a publisher-initiated PUBLISH
// (§10.11) this endpoint accepted via [Request.AcceptPublish] — the accept-side
// counterpart of [Session.Subscribe]'s [Subscription]. The objects arrive on
// subgroup uni-streams (or datagrams) keyed by [IncomingPublication.TrackAlias]
// and are consumed via [Session.AcceptDataStream]; the embedded request stream
// stays open for follow-ups — PUBLISH_DONE from the publisher, or a
// REQUEST_UPDATE this side sends via [IncomingPublication.Update] to adjust
// forwarding (§10.9). Close it to end the reception.
type IncomingPublication struct {
// requestHandle carries the PUBLISH request stream — still open for
// follow-up traffic (inbound PUBLISH_DONE, outbound REQUEST_UPDATE;
// Close it to end the reception) — and provides Update.
requestHandle
alias uint64
}
// TrackAlias reports the §11.1 Track Alias the publisher assigned — the integer
// inbound subgroup and datagram streams carry to identify this track (resolve it
// via [Session.LookupInboundTrackAlias]).
func (p *IncomingPublication) TrackAlias() uint64 { return p.alias }
// Publish opens a PUBLISH request stream (§10.11) and awaits the peer's
// initial response. It is [Session.OpenPublish] plus the response wait: the
// session assigns m.RequestID (after the stream opens, so a blocked open
// consumes no ID) and, when m.TrackAlias is the zero value, a Track Alias via
// [Session.AllocOutboundTrackAlias]; the caller supplies Namespace / Name /
// Parameters / TrackProperties. On success a [Publication] is returned whose
// embedded stream stays open for PUBLISH_DONE / REQUEST_UPDATE follow-ups and
// whose [Publication.OpenSubgroup] opens subgroup uni-streams for the track.
// On REQUEST_ERROR the stream is closed and a *RequestRejectedError is
// returned.
//
// To assign the Track Alias yourself (e.g. to mirror an upstream alias), set
// m.TrackAlias before calling — any non-zero value is used as-is — or drop to
// [Session.OpenPublish] for full control over the stream lifecycle.
func (s *Session) Publish(ctx context.Context, m *message.Publish) (*Publication, error) {
if m.TrackAlias == 0 {
m.TrackAlias = s.AllocOutboundTrackAlias()
}
return awaitRequestResponse(ctx, s, m,
func(stream Stream, _ *message.RequestOK) (*Publication, error) {
return &Publication{
Stream: stream,
s: s,
requestID: m.RequestID,
alias: m.TrackAlias,
}, nil
})
}
// OpenPublish opens a PUBLISH request stream (§10.11) without blocking on
// stream-flow-control credit and without awaiting the peer's response. It is
// the relay-side counterpart of [Publish]: relay fan-out is fire-and-continue,
// so the caller owns the stream's read side.
//
// If the peer's stream limit is currently exhausted it returns
// [ErrNoStreamCredit] and consumes NO Request ID — the ID is allocated only
// after the stream is successfully opened (see [Session.openAllocRequest]), so
// a blocked attempt leaves the session's Request ID sequence untouched. This
// lets the caller react to an exhausted limit by sending PUBLISH_SKIPPED (§6.1,
// §10.21) instead. On success it assigns m.RequestID, writes the PUBLISH as the
// stream's first message, and returns the still-open bidi stream so the caller
// can read the peer's REQUEST_OK / REQUEST_ERROR and send follow-ups (subgroup
// streams, PUBLISH_DONE, REQUEST_UPDATE).
func (s *Session) OpenPublish(m *message.Publish) (Stream, error) {
return s.openAllocRequest(m)
}
package quicconn
import (
"context"
"crypto/tls"
"fmt"
"net"
"net/netip"
"github.com/quic-go/quic-go"
"github.com/floatdrop/moq-go/pkg/moqt/session"
)
// Dial opens a raw-QUIC connection to addr ("host:port") and returns it as a
// [session.Conn], ready for a client-side MOQT SETUP. It is the client-side
// counterpart of [NewListener], and the one dial path every MOQT client in this
// repo shares — the relay's cross-relay dialer and the demo/interop CLIs alike.
//
// The name in addr is resolved here rather than left to [quic.DialAddr], which
// resolves via [net.ResolveUDPAddr]. That helper returns the first *IPv4*
// address for a bare "host:port" and reaches for IPv6 only when the string
// carries a bracketed literal — it picks the family by looking for a '[' in the
// string (net.addrList.forResolve), not by what the resolver ranked first. So a
// dual-stack peer named by hostname is always dialed over IPv4, even where only
// the IPv6 path carries traffic: the Initials leave, nothing answers, and the
// dial fails with "timeout: no recent network activity" while the host is
// plainly reachable over IPv6.
//
// [net.Resolver.LookupIPAddr] instead returns every address in RFC 6724 order —
// the same ranking getaddrinfo / `getent ahosts` report — and each is tried in
// turn, so a host whose first address is unreachable still connects on the next.
// Candidates go back to [quic.DialAddr] as literals via [net.JoinHostPort],
// which brackets IPv6: that pins the family chosen here and costs no second
// lookup. ctx bounds the whole sequence, so a caller's dial timeout applies
// across all candidates rather than per candidate.
func Dial(ctx context.Context, addr string, tlsCfg *tls.Config, quicCfg *quic.Config) (session.Conn, error) {
candidates, err := resolveDialCandidates(ctx, addr)
if err != nil {
return nil, err
}
var lastErr error
for _, candidate := range candidates {
qc, err := quic.DialAddr(ctx, candidate, tlsCfg, quicCfg)
if err == nil {
return New(qc), nil
}
lastErr = fmt.Errorf("dial %s: %w", candidate, err)
// A cancelled/expired ctx fails every remaining candidate the same way;
// report the first real failure instead of the derived ones.
if ctx.Err() != nil {
break
}
}
return nil, lastErr
}
// resolveDialCandidates expands a "host:port" into the addresses to dial, in the
// resolver's preferred order. A host that is already an IP literal resolves to
// itself: no DNS, and no chance of the family flipping under a caller that
// deliberately pinned one. [netip.ParseAddr] rather than [net.ParseIP] so a
// zone-scoped literal ("fe80::1%eth0") is recognized as one too.
func resolveDialCandidates(ctx context.Context, addr string) ([]string, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
if _, err := netip.ParseAddr(host); err == nil {
return []string{addr}, nil
}
// LookupIPAddr reports an error rather than an empty slice when a name has
// no addresses, so the dial loop above always has at least one candidate.
ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil {
return nil, err
}
candidates := make([]string, len(ips))
for i, ip := range ips {
// IPAddr.String carries the zone; JoinHostPort adds the brackets.
candidates[i] = net.JoinHostPort(ip.String(), port)
}
return candidates, nil
}
// Package quicconn adapts github.com/quic-go/quic-go's *quic.Conn to the
// transport-neutral session.Conn interface.
//
// This is the sole boundary in the moqt tree where quic-go's concrete types
// meet the session abstraction. Putting it in a dedicated subpackage lets the
// rest of pkg/moqt (and its tests) stay independent of quic-go's surface.
//
// quic-go uses typed-uint64 aliases (quic.StreamErrorCode,
// quic.ApplicationErrorCode) for error codes; session.Conn / SendStream /
// ReceiveStream use plain uint64. The wrappers below do the lossless
// conversion at each call site.
package quicconn
import (
"context"
"errors"
"net"
"github.com/quic-go/quic-go"
"github.com/floatdrop/moq-go/pkg/moqt/session"
)
// New wraps c so it satisfies session.Conn.
func New(c *quic.Conn) session.Conn { return &conn{q: c} }
// Compile-time satisfaction check.
var _ session.Conn = (*conn)(nil)
// conn holds a *quic.Conn by named field rather than embedding. Embedding
// would promote quic-go's CloseWithError(quic.ApplicationErrorCode, string)
// onto the wrapper; the session.Conn interface demands
// CloseWithError(uint64, string). Two methods of the same name with different
// signatures aren't allowed on a single Go type, so we delegate explicitly.
type conn struct{ q *quic.Conn }
func (c *conn) OpenUniStream() (session.SendStream, error) {
s, err := c.q.OpenUniStream()
if err != nil {
if _, ok := errors.AsType[*quic.StreamLimitReachedError](err); ok {
return nil, session.ErrNoStreamCredit
}
return nil, err
}
return &sendStream{s: s}, nil
}
func (c *conn) AcceptUniStream(ctx context.Context) (session.ReceiveStream, error) {
s, err := c.q.AcceptUniStream(ctx)
if err != nil {
return nil, err
}
return &recvStream{s: s}, nil
}
// OpenStream opens a bidirectional stream without blocking. quic-go returns a
// *quic.StreamLimitReachedError when the peer's stream limit is exhausted; we
// map that onto session.ErrNoStreamCredit so callers can detect it
// transport-neutrally with errors.Is.
func (c *conn) OpenStream() (session.Stream, error) {
s, err := c.q.OpenStream()
if err != nil {
if _, ok := errors.AsType[*quic.StreamLimitReachedError](err); ok {
return nil, session.ErrNoStreamCredit
}
return nil, err
}
return &bidiStream{s: s}, nil
}
func (c *conn) AcceptStream(ctx context.Context) (session.Stream, error) {
s, err := c.q.AcceptStream(ctx)
if err != nil {
return nil, err
}
return &bidiStream{s: s}, nil
}
func (c *conn) CloseWithError(code uint64, reason string) error {
return c.q.CloseWithError(quic.ApplicationErrorCode(code), reason)
}
func (c *conn) Context() context.Context { return c.q.Context() }
func (c *conn) SendDatagram(payload []byte) error {
return c.q.SendDatagram(payload)
}
func (c *conn) ReceiveDatagram(ctx context.Context) ([]byte, error) {
return c.q.ReceiveDatagram(ctx)
}
// sendStream wraps *quic.SendStream. Named field for the same reason as conn.
type sendStream struct{ s *quic.SendStream }
func (s *sendStream) Write(p []byte) (int, error) { return s.s.Write(p) }
func (s *sendStream) Close() error { return s.s.Close() }
func (s *sendStream) CancelWrite(code uint64) {
s.s.CancelWrite(quic.StreamErrorCode(code))
}
// SetReliableBoundary satisfies [session.ReliableResetStream] by forwarding to
// quic-go's RESET_STREAM_AT support. It is a no-op unless the peer enabled the
// extension (quic.Config.EnableStreamResetPartialDelivery).
func (s *sendStream) SetReliableBoundary() { s.s.SetReliableBoundary() }
// Context is cancelled when all data has been acknowledged by the peer or
// the stream is reset. quic-go's SendStream.Context() provides this directly.
func (s *sendStream) Context() context.Context { return s.s.Context() }
// recvStream wraps *quic.ReceiveStream.
type recvStream struct{ s *quic.ReceiveStream }
func (s *recvStream) Read(p []byte) (int, error) { return s.s.Read(p) }
func (s *recvStream) CancelRead(code uint64) {
s.s.CancelRead(quic.StreamErrorCode(code))
}
// bidiStream wraps *quic.Stream.
type bidiStream struct{ s *quic.Stream }
func (s *bidiStream) Read(p []byte) (int, error) { return s.s.Read(p) }
func (s *bidiStream) Write(p []byte) (int, error) { return s.s.Write(p) }
func (s *bidiStream) Close() error { return s.s.Close() }
func (s *bidiStream) CancelRead(code uint64) {
s.s.CancelRead(quic.StreamErrorCode(code))
}
func (s *bidiStream) CancelWrite(code uint64) {
s.s.CancelWrite(quic.StreamErrorCode(code))
}
// Context is cancelled when all data has been acknowledged or the stream is
// reset. quic-go's Stream embeds SendStream which has Context().
func (s *bidiStream) Context() context.Context { return s.s.Context() }
// Listener adapts a *quic.Listener so it can be handed directly to the
// relay's accept loop. The relay's listener interface requires
// Accept(ctx) → session.Conn, Addr() → net.Addr, and Close() → error;
// this type satisfies it structurally without forcing this package to
// import pkg/relay.
//
// The caller owns the underlying *quic.Listener — its TLS config, ALPN
// selection ("moqt-20"), QUIC parameters, and listening socket. Close
// on the Listener forwards to the underlying *quic.Listener, which is
// also what the caller would call themselves on shutdown; both paths
// are equivalent.
type Listener struct{ ql *quic.Listener }
// NewListener wraps ql so it can be passed to relay.New.
//
// Typical wiring:
//
// ql, err := quic.ListenAddr(":4433", tlsCfg, quicCfg)
// if err != nil { … }
// r := relay.New(quicconn.NewListener(ql), relay.Config{ … })
// go r.Start(ctx)
func NewListener(ql *quic.Listener) *Listener { return &Listener{ql: ql} }
// Accept blocks until the next inbound *quic.Conn arrives, then wraps
// it via [New] into a session.Conn the relay can hand to session.Server.
// ctx cancellation propagates to the underlying Accept.
func (l *Listener) Accept(ctx context.Context) (session.Conn, error) {
c, err := l.ql.Accept(ctx)
if err != nil {
return nil, err
}
return New(c), nil
}
// Addr returns the address the underlying quic-go listener is bound to.
func (l *Listener) Addr() net.Addr { return l.ql.Addr() }
// Close closes the underlying *quic.Listener. Subsequent Accept calls
// unblock with the quic-go close error.
func (l *Listener) Close() error { return l.ql.Close() }
package session
import (
"context"
"errors"
"fmt"
"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/track"
)
// ErrRequestIDParityViolation is returned by AcceptRequest when the peer sends
// a Request ID whose parity does not match the expected value per §10.1.
// The caller MUST close the session with SessionInvalidRequestID.
type ErrRequestIDParityViolation struct {
RequestID uint64
ExpectedEven bool // true = expected even (peer is client), false = expected odd (peer is server)
}
func (e *ErrRequestIDParityViolation) Error() string {
want := "even"
if !e.ExpectedEven {
want = "odd"
}
return fmt.Sprintf(
"moqt/session: peer Request ID %d has wrong parity (want %s) — INVALID_REQUEST_ID",
e.RequestID,
want,
)
}
// ErrDuplicateRequestID is returned by [Session.CheckPeerRequestID] (and thus
// AcceptRequest) when the peer reuses a Request ID (§10.1: "a duplicate
// Request ID" MUST close the session with INVALID_REQUEST_ID). Cross-stream
// delivery reordering is tolerated — an ID below the high-water mark counts
// as a duplicate only once every unseen ID it could have been is accounted
// for. The caller MUST close the session with SessionInvalidRequestID.
type ErrDuplicateRequestID struct {
RequestID uint64
MaxSeen uint64
}
func (e *ErrDuplicateRequestID) Error() string {
return fmt.Sprintf(
"moqt/session: peer Request ID %d already consumed (high-water mark %d) — INVALID_REQUEST_ID",
e.RequestID,
e.MaxSeen,
)
}
// ErrUnexpectedRequestUpdate is returned by AcceptRequest when a peer opens a
// request stream whose first message is a REQUEST_UPDATE. §10.9 permits
// REQUEST_UPDATE only as a follow-up on an existing request stream (or against
// a PUBLISH-established subscription); a REQUEST_UPDATE in any other position
// is a PROTOCOL_VIOLATION. The caller MUST close the session with
// SessionProtocolViolation.
type ErrUnexpectedRequestUpdate struct {
RequestID uint64
}
// ErrUnexpectedPublishStateNotify is returned by AcceptRequest when a peer
// opens a request stream with PUBLISH_STATE_NOTIFY. §10.10 admits it only as a
// publisher's unilateral notification on a subscription's existing stream, so
// this is a PROTOCOL_VIOLATION and the caller MUST close the session.
var ErrUnexpectedPublishStateNotify = errors.New(
"moqt/session: PUBLISH_STATE_NOTIFY as the first message of a request stream — PROTOCOL_VIOLATION")
func (e *ErrUnexpectedRequestUpdate) Error() string {
return fmt.Sprintf(
"moqt/session: REQUEST_UPDATE (Request ID %d) as the first message of a request stream — PROTOCOL_VIOLATION",
e.RequestID,
)
}
// ErrTooManyRequestUpdates is returned by [RequestUpdateLimiter.Received] when
// a peer exceeds the per-request-stream MAX_REQUEST_UPDATES limit it was
// advertised (§10.3.1.7). The caller MUST close the session with
// SessionTooManyRequestUpdates.
type ErrTooManyRequestUpdates struct {
Limit uint64
}
func (e *ErrTooManyRequestUpdates) Error() string {
return fmt.Sprintf(
"moqt/session: peer exceeded MAX_REQUEST_UPDATES (%d) outstanding on a request stream — TOO_MANY_REQUEST_UPDATES",
e.Limit,
)
}
// RequestUpdateLimiter enforces the receive-side MAX_REQUEST_UPDATES limit
// (§10.3.1.7) for a single request stream. A REQUEST_UPDATE is "outstanding"
// from when it is received until this endpoint writes the mandated
// REQUEST_OK/REQUEST_ERROR; the sender may not have more than the advertised
// limit outstanding at once. Construct one per stream via
// [Session.NewRequestUpdateLimiter].
//
// A limiter is not safe for concurrent use, which matches the single-reader
// invariant of the follow-up loops ([RequestBroker.Serve] and the relay's
// per-stream readers). A limit of 0 (the default, meaning the option was not
// advertised) disables the check.
type RequestUpdateLimiter struct {
limit uint64
outstanding uint64
}
// NewRequestUpdateLimiter returns a limiter seeded with the MAX_REQUEST_UPDATES
// value this session advertised to the peer.
func (s *Session) NewRequestUpdateLimiter() *RequestUpdateLimiter {
return &RequestUpdateLimiter{limit: s.maxRequestUpdates}
}
// Received records an inbound REQUEST_UPDATE. It returns
// [*ErrTooManyRequestUpdates] when the stream already holds the advertised
// limit of outstanding updates (§10.3.1.7: the endpoint MUST then close the
// session with TOO_MANY_REQUEST_UPDATES); the caller owns that close, mirroring
// [Session.CheckPeerRequestID]. On success the update counts as outstanding
// until a matching [RequestUpdateLimiter.Responded].
func (l *RequestUpdateLimiter) Received() error {
if l.limit != 0 && l.outstanding >= l.limit {
return &ErrTooManyRequestUpdates{Limit: l.limit}
}
l.outstanding++
return nil
}
// Responded releases the credit a successful [RequestUpdateLimiter.Received]
// took, once this endpoint has written the mandated REQUEST_OK/REQUEST_ERROR.
// Callers pair it with exactly one Received that returned nil (a Received that
// errored closes the session and never reaches here), so outstanding is always
// at least 1 on entry.
func (l *RequestUpdateLimiter) Responded() {
l.outstanding--
}
// RequestRejectedError is returned by Publish / Subscribe when the peer
// answers a request with REQUEST_ERROR (§10.5). Callers can detect it via
// errors.As and inspect Code / Reason.
type RequestRejectedError struct {
Code moqt.RequestErrorCode
Reason string
}
func (e *RequestRejectedError) Error() string {
return fmt.Sprintf("moqt request rejected: %s (code %#x)", e.Reason, uint64(e.Code))
}
// Request is an inbound MoQT request stream (§3.3, §10.1) after its first
// message has been parsed.
//
// "Request" here matches MoQT's terminology, not the one-shot RPC sense the
// word usually implies in Go. A request is a long-lived request-response
// interaction identified by a Request ID: the bidi stream stays open for the
// lifetime of the operation, the responder writes an initial response
// (REQUEST_OK / REQUEST_ERROR / SUBSCRIBE_OK / PUBLISH_OK), and either side
// may send follow-up messages on the same stream — REQUEST_UPDATE from the
// requester, PUBLISH_DONE from a publisher, additional REQUEST_OKs in
// response to updates, and so on — until one side FINs or resets the stream.
//
// Handlers read First to decide what to do, write responses via Reply or
// RejectError, and use Stream directly for any further messages or to close
// the send side.
type Request struct {
Stream Stream
First message.Message
// Tokens holds the AUTHORIZATION_TOKEN values (§10.2.2) carried by
// First, fully resolved against the inbound token cache: REGISTER and
// USE_VALUE tokens contribute their (Type, Value) directly, USE_ALIAS
// tokens are resolved to the previously-registered value, and DELETE
// tokens are applied to the cache without producing an entry here. It is
// nil when the request carried no tokens. Handlers (and
// [Session.VerifyRequestTokens]) consult it to authorize the request;
// callers never see a bare alias.
Tokens []ResolvedToken
// s is the owning session, used by the AcceptSubscribe / AcceptPublish
// helpers to allocate Track Aliases and register inbound aliases.
s *Session
}
// AcceptRequest blocks until a peer opens a bidirectional stream, reads and
// parses the first message, and returns the result. The session must be past
// SETUP (i.e. Open has returned successfully).
//
// Requests that target a reserved namespace the MOQT implementation owns
// (§3.2.1 "." and §3.2.2 ".session") are answered with REQUEST_ERROR
// DOES_NOT_EXIST and skipped transparently — the caller (and, for a relay, any
// other session) never observes them, satisfying "Relays MUST NOT forward
// requests for session-level tracks and namespaces". AcceptRequest loops until
// it has an application-visible request to return.
//
// If the first message fails to parse, the bidi stream is reset and the error
// is returned. §3.3 / §10 require the receiver to treat such conditions as
// session-level PROTOCOL_VIOLATIONs; the caller decides whether to escalate
// by calling Session.Close.
func (s *Session) AcceptRequest(ctx context.Context) (*Request, error) {
for {
stream, err := s.conn.AcceptStream(ctx)
if err != nil {
return nil, err
}
// readResponse bridges ctx to the otherwise context-free Parse, so a
// peer that opens the stream but stalls mid-message cannot wedge the
// accept loop past cancellation.
msg, err := s.readResponse(ctx, stream)
if err != nil {
resetStream(stream)
if ctx.Err() != nil {
return nil, ctx.Err()
}
return nil, fmt.Errorf("moqt/session: parse request first message: %w", err)
}
// §10.9: REQUEST_UPDATE is valid only as a follow-up on an existing
// request stream (or against a PUBLISH-established subscription), never
// as the message that opens a stream. Receiving one here is a
// PROTOCOL_VIOLATION; the caller MUST close the session
// (SessionProtocolViolation), as with the Request-ID violations below.
if upd, ok := msg.(*message.RequestUpdate); ok {
resetStream(stream)
return nil, &ErrUnexpectedRequestUpdate{RequestID: upd.RequestID}
}
// §10.10: PUBLISH_STATE_NOTIFY is a unilateral publisher-to-subscriber
// notification on an existing subscription's stream. "An endpoint that
// receives a PUBLISH_STATE_NOTIFY for any other request type, or from the
// subscriber, MUST close the session with a PROTOCOL_VIOLATION" — opening
// a stream with one is both. It carries no Request ID, so the §10.1
// accounting below would not catch it either.
if _, ok := msg.(*message.PublishStateNotify); ok {
resetStream(stream)
return nil, ErrUnexpectedPublishStateNotify
}
// §10.1 parity + duplicate enforcement, shared with the follow-up
// REQUEST_UPDATE path — see [Session.CheckPeerRequestID].
if m, ok := msg.(message.WithRequestID); ok {
if err := s.CheckPeerRequestID(m.GetRequestID()); err != nil {
resetStream(stream)
return nil, err
}
}
// §10.2.2: process any AUTHORIZATION_TOKEN parameters now, before the
// request is dispatched/validated/authorized. REGISTER tokens are
// committed to the inbound cache here so the alias persists even if the
// request is later rejected for an unrelated reason (a §10.2.2 MUST).
// A cache-layer failure is a session-level fault carried by
// *TokenCacheError; the caller MUST close the session with its Code.
tokens, err := s.processRequestTokens(msg)
if err != nil {
resetStream(stream)
return nil, err
}
// §3.2.1 / §3.2.2: a request for a reserved namespace the
// implementation owns is rejected with DOES_NOT_EXIST here, after
// token processing (so REGISTER tokens still commit), without ever
// surfacing to the application. Other reserved ("."-prefixed)
// namespaces fall through to the application per §3.2.1.
if reason, reject := reservedNamespaceRejection(msg); reject {
rejectStreamWithError(stream, moqt.RequestDoesNotExist, reason)
continue
}
return &Request{Stream: stream, First: msg, Tokens: tokens, s: s}, nil
}
}
// maxTrackedRequestIDGaps bounds [Session.CheckPeerRequestID]'s memory for
// below-the-mark Request IDs that may still legitimately arrive late. A
// conforming peer creates gaps only through delivery reordering of in-flight
// requests (it allocates in +2 increments), so the bound is far above any
// realistic reorder window; when it overflows, the lowest (oldest) gaps are
// evicted first — they are the least plausible late arrivals — and a later
// arrival for an evicted one reads as a duplicate.
const maxTrackedRequestIDGaps = 1024
// evictLowestGapsLocked removes the n smallest Request IDs from gaps, keeping
// the newest entries claimable when the cap forces a choice. O(cap log cap),
// and only runs on a jump that overflows the cap. Caller holds s.mu.
func evictLowestGapsLocked(gaps map[uint64]struct{}, n int) {
ids := make([]uint64, 0, len(gaps))
for id := range gaps {
ids = append(ids, id)
}
slices.Sort(ids)
for _, id := range ids[:min(n, len(ids))] {
delete(gaps, id)
}
}
// CheckPeerRequestID validates one inbound Request ID per §10.1 and records
// it. It applies to every peer message that consumes a Request ID — the
// first message of a request stream (AcceptRequest calls this) and follow-up
// REQUEST_UPDATEs ([RequestBroker.Serve] and relay follow-up readers call it
// for those).
//
// Two violations are session-fatal per §10.1, and the caller MUST close the
// session with [moqt.SessionInvalidRequestID] (AcceptRequest instead returns
// the error to its caller, which owns that decision):
//
// - wrong parity for the sender (*ErrRequestIDParityViolation);
// - a duplicate ID (*ErrDuplicateRequestID).
//
// An ID below the high-water mark is NOT automatically a duplicate: the peer
// allocates in +2 increments, but requests ride separate QUIC streams and
// can be delivered out of order, so each unseen ID below the mark stays
// claimable exactly once.
func (s *Session) CheckPeerRequestID(rid uint64) error {
// §10.1: the client generates even Request IDs (starting at 0), the
// server odd ones (starting at 1); peerMustBeEven is true when we are
// the server.
peerMustBeEven := s.role == roleServer
if peerMustBeEven && rid%2 != 0 {
return &ErrRequestIDParityViolation{RequestID: rid, ExpectedEven: true}
}
if !peerMustBeEven && rid%2 != 1 {
return &ErrRequestIDParityViolation{RequestID: rid, ExpectedEven: false}
}
s.mu.Lock()
defer s.mu.Unlock()
if !s.peerRequestIDSeen || rid > s.peerRequestIDMax {
s.recordRequestIDGapsLocked(rid, peerMustBeEven)
s.peerRequestIDSeen = true
s.peerRequestIDMax = rid
return nil
}
if _, open := s.peerRequestIDGaps[rid]; open {
delete(s.peerRequestIDGaps, rid)
return nil
}
return &ErrDuplicateRequestID{RequestID: rid, MaxSeen: s.peerRequestIDMax}
}
// recordRequestIDGapsLocked records the peer Request IDs an advance of the
// high-water mark to rid skips over, as claimable reorder gaps. The peer's
// sequence starts at its parity base (§10.1: client 0, server 1), so on the
// very first observation everything below rid is potentially in flight. All
// new gaps are newer than every existing entry (which lie below the previous
// mark), so keeping the newest cap-many claimable means inserting at most
// cap new gaps (newest first) and evicting the lowest old entries to make
// room. Caller holds s.mu.
func (s *Session) recordRequestIDGapsLocked(rid uint64, peerMustBeEven bool) {
lo := uint64(0)
if !peerMustBeEven {
lo = 1
}
if s.peerRequestIDSeen {
lo = s.peerRequestIDMax + 2
}
if rid <= lo {
return
}
newGaps := maxTrackedRequestIDGaps
if d := (rid - lo) / 2; d < maxTrackedRequestIDGaps {
newGaps = int(d)
}
if excess := len(s.peerRequestIDGaps) + newGaps - maxTrackedRequestIDGaps; excess > 0 {
evictLowestGapsLocked(s.peerRequestIDGaps, excess)
}
if s.peerRequestIDGaps == nil {
s.peerRequestIDGaps = make(map[uint64]struct{})
}
for id, n := rid, 0; n < newGaps; n++ {
id -= 2
s.peerRequestIDGaps[id] = struct{}{}
}
}
// resetStream cancels both directions of a bidi request stream (§3.3.3) with
// StreamResetInternalError (§3.3.4) — the common teardown when a request stream
// is abandoned mid-parse or fails §10.1 validation.
func resetStream(s Stream) {
s.CancelRead(uint64(moqt.StreamResetInternalError))
s.CancelWrite(uint64(moqt.StreamResetInternalError))
}
// rejectStreamWithError applies [Request.RejectError]'s teardown on the
// pre-Request path in AcceptRequest, where no *Request value exists yet. Its
// error is dropped because there is nothing left to do with it: RejectError
// has already reset the stream if the REQUEST_ERROR could not be sent.
func rejectStreamWithError(stream Stream, code moqt.RequestErrorCode, reason string) {
_ = (&Request{Stream: stream}).RejectError(code, reason)
}
// requestHandle is the state every requester-side typed handle embeds: the
// still-open bidi request stream (close it to end the request), the owning
// session, and the §10.1 Request ID of the request the stream carries (used
// where a follow-on message must reference the original request, e.g. the
// FETCH_HEADER a FetchResponder opens). Embedding it provides the shared
// Update and Broker methods.
type requestHandle struct {
// Stream is the request stream, still open for follow-up traffic.
// Close it to end the request.
Stream
s *Session
requestID uint64
brokerOnce sync.Once
broker atomic.Pointer[RequestBroker]
}
// Broker returns this request's [RequestBroker], creating it on first call.
// Use it when the request outlives its initial response and follow-up
// traffic must coexist with updates: run [RequestBroker.Serve] to own the
// stream's reads, and route writes through the broker. Once created, the
// handle's own Update (and terminal writes like [Publication.Done]) go
// through the broker automatically, so they stay safe alongside Serve.
func (h *requestHandle) Broker() *RequestBroker {
h.brokerOnce.Do(func() {
h.broker.Store(h.s.NewRequestBroker(h.Stream))
})
return h.broker.Load()
}
// Update sends a REQUEST_UPDATE (§10.9) on the request stream and awaits the
// single REQUEST_OK / REQUEST_ERROR the spec mandates. params carries only
// the fields to change; any parameter omitted keeps its prior value on the
// peer.
//
// With no [requestHandle.Broker] attached this is [Session.UpdateRequest] —
// it reads the response directly, so it must be the stream's only reader.
// With a broker attached it delegates to [RequestBroker.Update], whose
// response arrives via the broker's Serve loop.
func (h *requestHandle) Update(ctx context.Context, params message.Parameters) (*message.RequestOK, error) {
if b := h.broker.Load(); b != nil {
return b.Update(ctx, params)
}
return h.s.UpdateRequest(ctx, h.Stream, params)
}
// writeThenClose writes msg and FINs the send side, routing through the
// attached broker's write lock when one exists — the shared backend of
// terminal handle methods like [Publication.Done].
func (h *requestHandle) writeThenClose(msg message.Message) error {
if b := h.broker.Load(); b != nil {
return b.writeThenClose(msg)
}
if err := message.Marshal(h.Stream, msg); err != nil {
return err
}
return h.Stream.Close()
}
// openRequest opens a new outbound bidirectional stream and writes first as
// its initial message. The returned Stream can be used to read responses
// (typically a single REQUEST_OK / REQUEST_ERROR / SUBSCRIBE_OK first, then
// optionally more) and to send follow-up messages such as REQUEST_UPDATE.
//
// On any error before the stream is established and the first message
// written, the stream (if any) is reset and the error is returned.
func (s *Session) openRequest(first message.Message) (Stream, error) {
stream, err := s.conn.OpenStream()
if err != nil {
return nil, err
}
return writeFirst(stream, first)
}
// openAllocRequest opens a request stream for m and assigns m a freshly
// allocated Request ID (§10.1) only after the open succeeds — so a failed open
// (e.g. ErrNoStreamCredit) consumes no ID and the §10.1 sequence stays
// untouched — then writes it as the stream's first message. It does NOT await
// the peer's response — the caller owns the read side. It is the single
// primitive beneath every typed request opener (Publish, Subscribe, Fetch,
// TrackStatus, the namespace requests) and the non-blocking
// [Session.OpenPublish] used for relay fan-out.
func (s *Session) openAllocRequest(m message.WithRequestID) (Stream, error) {
stream, err := s.conn.OpenStream()
if err != nil {
return nil, err
}
m.SetRequestID(s.AllocRequestID())
return writeFirst(stream, m)
}
// writeFirst marshals first as the initial message of a freshly opened request
// stream. On a write failure the stream is reset and the error is returned.
func writeFirst(stream Stream, first message.Message) (Stream, error) {
if err := message.Marshal(stream, first); err != nil {
resetStream(stream)
return nil, fmt.Errorf("moqt/session: write request first message: %w", err)
}
return stream, nil
}
// readResponse parses one message from stream, honoring ctx. message.Parse
// reads from a context-free io.Reader, so cancellation is bridged by resetting
// the stream's read side with StreamResetCancelled (§3.3.4), which unblocks the
// in-flight Parse.
//
// The bridge is a context.AfterFunc hook rather than a watcher goroutine: it
// fires (in its own goroutine) only if ctx is actually cancelled, so the common
// case — the response arrives first — runs no extra goroutine at all, and the
// deferred stop() removes the hook. When ctx fired, ctx.Err() is returned in
// place of the resulting wire error so the caller sees context.Canceled /
// context.DeadlineExceeded.
//
// Known teardown-only race: a cancellation landing after a successful Parse
// but before stop() detaches the hook fires a stale CancelRead — the caller
// receives (msg, nil) on a stream whose read side was just reset. Every
// caller's ctx is a session/relay-lifetime context, so the poisoned handle
// only occurs mid-shutdown, where the very next read surfacing a reset is
// acceptable.
func (s *Session) readResponse(ctx context.Context, stream Stream) (message.Message, error) {
stop := context.AfterFunc(ctx, func() {
stream.CancelRead(uint64(moqt.StreamResetCancelled))
})
defer stop()
msg, err := message.Parse(stream)
if err != nil && ctx.Err() != nil {
return nil, ctx.Err()
}
return msg, err
}
// awaitRequestResponse opens a request stream for m (allocating its Request ID
// only after the open succeeds — see [Session.openAllocRequest]), awaits the
// peer's initial response, and dispatches it:
//
// - the expected success type OK is handed to onOK, which owns the still-open
// stream from that point: it wraps the stream in the typed handle, or closes
// it and returns an error (e.g. on Track Property validation failure);
// - REQUEST_ERROR (§10.5) is surfaced as a *RequestRejectedError and the
// stream is closed;
// - any other message is an unexpected-response error and the stream is closed.
//
// Error messages name the operation via m.Type() (e.g. "SUBSCRIBE"). It is the
// single primitive beneath [Session.Publish], [Session.Subscribe],
// [Session.Fetch], [Session.TrackStatus], and the three namespace request
// openers, which share this §10.1 open / await-OK skeleton and differ only in
// OK type and success handling (Publish additionally pre-allocates its Track
// Alias before the open).
func awaitRequestResponse[OK message.Message, R any](
ctx context.Context,
s *Session,
m message.WithRequestID,
onOK func(stream Stream, ok OK) (R, error),
) (R, error) {
var zero R
stream, err := s.openAllocRequest(m)
if err != nil {
return zero, err
}
resp, err := s.readResponse(ctx, stream)
if err != nil {
_ = stream.Close()
return zero, fmt.Errorf("moqt/session: read %s response: %w", m.Type(), err)
}
if ok, isOK := resp.(OK); isOK {
return onOK(stream, ok)
}
_ = stream.Close()
if rerr, isErr := resp.(*message.RequestError); isErr {
return zero, &RequestRejectedError{Code: rerr.ErrorCode, Reason: rerr.ErrorReason}
}
return zero, fmt.Errorf("moqt/session: unexpected %s in %s response", resp.Type(), m.Type())
}
// UpdateRequest sends a REQUEST_UPDATE (§10.9) on an already-established
// request stream and awaits the single REQUEST_OK / REQUEST_ERROR the spec
// mandates in response. The update rides the original bidi stream — the
// stream, not the ID, names the request being modified — but per §10.1 the
// REQUEST_UPDATE itself consumes a fresh Request ID from this endpoint's
// space, which the session allocates here (a reused ID is a duplicate the
// peer must treat as session-fatal). params carries only the fields the
// caller wants to change; any parameter omitted keeps its prior value on the
// peer (§10.9).
//
// On REQUEST_OK the parsed message is returned and the stream is left open
// for further traffic. REQUEST_ERROR is surfaced as a *RequestRejectedError;
// the stream is left open so the caller can decide how to tear down (a failed
// subscription update is followed by PUBLISH_DONE from the publisher, §10.9).
//
// UpdateRequest reads the response directly off the stream, so it MUST NOT
// run concurrently with any other reader of the same stream ([DrainAndWait],
// a PUBLISH_DONE-draining loop, another UpdateRequest) — a concurrent reader
// races it for the response and can swallow it, blocking this call until ctx
// expires. When the stream needs a standing reader, use [RequestBroker.Serve]
// and [RequestBroker.Update] instead.
func (s *Session) UpdateRequest(
ctx context.Context,
stream Stream,
params message.Parameters,
) (*message.RequestOK, error) {
if err := message.Marshal(stream, &message.RequestUpdate{
RequestID: s.AllocRequestID(),
Parameters: params,
}); err != nil {
return nil, fmt.Errorf("moqt/session: write REQUEST_UPDATE: %w", err)
}
resp, err := s.readResponse(ctx, stream)
if err != nil {
return nil, fmt.Errorf("moqt/session: read REQUEST_UPDATE response: %w", err)
}
return mapUpdateResponse(resp)
}
// Reply marshals a response message onto the request's bidi stream. The
// stream is left open so further messages can be written. Use RejectError or
// Stream.Close to terminate the send direction.
func (r *Request) Reply(msg message.Message) error {
return message.Marshal(r.Stream, msg)
}
// RejectError writes a REQUEST_ERROR with the given code and reason, then
// cancels the read side and FINs the send direction of the bidi stream
// (§3.3.3: "When an endpoint rejects a request without performing any
// application processing, it SHOULD send a REQUEST_ERROR and FIN the stream.").
// CancelRead ensures that any further data the peer sends after the rejection
// does not queue in the transport buffer indefinitely.
//
// When the REQUEST_ERROR itself cannot be written, the stream is reset instead
// and the write error returned. §3.3.3 gives a responder both exits —
// REQUEST_ERROR plus FIN, or "Receivers cancel requests if they are unable to
// or choose not to respond" — and a failed write has taken neither until the
// reset lands. Returning early without it leaves the requester waiting on a
// response that can never arrive, for as long as the session lives.
func (r *Request) RejectError(code moqt.RequestErrorCode, reason string) error {
if err := message.Marshal(r.Stream, &message.RequestError{
ErrorCode: code,
ErrorReason: reason,
}); err != nil {
resetStream(r.Stream)
return err
}
r.Stream.CancelRead(uint64(moqt.StreamResetInternalError))
return r.Stream.Close()
}
// AcceptSubscribe accepts an inbound SUBSCRIBE (§10.7) and returns a
// [Publication] for pushing objects back to the subscriber — the accept-side
// counterpart of [Session.Publish]. r.First MUST be a *message.Subscribe.
//
// ok carries the SUBSCRIBE_OK fields the caller wants to set (negotiated
// Parameters, TrackProperties); its TrackAlias is filled in automatically when
// zero, via [Session.AllocOutboundTrackAlias] — set it non-zero to assign a
// specific alias (e.g. to mirror an upstream). ok may be nil for the all-default
// reply. AcceptSubscribe writes SUBSCRIBE_OK and returns a Publication whose
// [Publication.OpenSubgroup] is pre-bound to the alias and whose
// [Publication.Done] ends the subscription with PUBLISH_DONE.
func (r *Request) AcceptSubscribe(ok *message.SubscribeOK) (*Publication, error) {
if _, isSub := r.First.(*message.Subscribe); !isSub {
return nil, fmt.Errorf("moqt/session: AcceptSubscribe on a %s request", r.First.Type())
}
if ok == nil {
ok = &message.SubscribeOK{}
}
if ok.TrackAlias == 0 {
ok.TrackAlias = r.s.AllocOutboundTrackAlias()
}
if err := message.Marshal(r.Stream, ok); err != nil {
return nil, fmt.Errorf("moqt/session: write SUBSCRIBE_OK: %w", err)
}
sub, _ := r.First.(*message.Subscribe) // checked above
return &Publication{
Stream: r.Stream,
s: r.s,
requestID: sub.RequestID,
alias: ok.TrackAlias,
}, nil
}
// AcceptPublish accepts an inbound PUBLISH (§10.11): it registers the
// publisher-assigned Track Alias (§11.1, so inbound subgroup/datagram streams
// resolve to this track and a reused alias is caught as DUPLICATE_TRACK_ALIAS),
// replies REQUEST_OK, and returns an [IncomingPublication] for the receiving
// side — the accept-side counterpart of [Session.Publish]. r.First MUST be a
// *message.Publish. The objects arrive on subgroup uni-streams via
// [Session.AcceptDataStream].
//
// If the alias collides with a different already-registered track,
// *ErrDuplicateTrackAlias is returned WITHOUT replying OK; the caller MUST close
// the session with [moqt.SessionDuplicateTrackAlias] (§11.1).
func (r *Request) AcceptPublish() (*IncomingPublication, error) {
pub, isPub := r.First.(*message.Publish)
if !isPub {
return nil, fmt.Errorf("moqt/session: AcceptPublish on a %s request", r.First.Type())
}
if err := r.s.RegisterInboundTrackAlias(pub.TrackAlias, track.NewKey(pub.Namespace, pub.Name)); err != nil {
return nil, err
}
if err := message.Marshal(r.Stream, &message.RequestOK{}); err != nil {
return nil, fmt.Errorf("moqt/session: write PUBLISH REQUEST_OK: %w", err)
}
return &IncomingPublication{
Stream: r.Stream,
s: r.s,
requestID: pub.RequestID,
alias: pub.TrackAlias,
}, nil
}
package session
import (
"context"
"sync"
"github.com/floatdrop/moq-go/pkg/moqt"
"github.com/floatdrop/moq-go/pkg/moqt/message"
)
// RequestHandler handles one inbound request that a [RequestMux] routed to it by
// the [message.Type] of its first message. It is invoked synchronously by
// [RequestMux.Run]; spawn a goroutine inside it when a request must be serviced
// concurrently with accepting the next one (see [RequestMux.Run]).
type RequestHandler func(*Request)
// RequestMux routes the requests accepted from a [Session] to per-type handlers,
// replacing the hand-rolled "AcceptRequest loop + type-switch + dispatch" a
// server otherwise writes. It is the request-stream counterpart of [Demux],
// which does the same for inbound data streams.
//
// Requests are dispatched by the [message.Type] of their first message — e.g.
// [message.TypeSubscribe] for an inbound SUBSCRIBE. A request whose type has no
// registered handler is passed to the OnUnknown callback.
//
// Handlers may be registered or replaced at any time, including while
// [RequestMux.Run] is executing. Registration is safe for concurrent use.
//
// The zero value is not ready for use — construct with [NewRequestMux].
type RequestMux struct {
mu sync.RWMutex
handlers map[message.Type]RequestHandler
onUnknown func(*Request)
}
// NewRequestMux returns an empty RequestMux ready for handler registration.
func NewRequestMux() *RequestMux {
return &RequestMux{handlers: make(map[message.Type]RequestHandler)}
}
// Handle registers h for inbound requests whose first message is of type t
// (e.g. [message.TypeSubscribe]). A nil h unregisters t; registering a type
// that already has a handler replaces it.
func (m *RequestMux) Handle(t message.Type, h RequestHandler) {
m.mu.Lock()
defer m.mu.Unlock()
if h == nil {
delete(m.handlers, t)
return
}
m.handlers[t] = h
}
// HandleType registers h for inbound requests whose first message is the
// concrete type T (e.g. *message.Subscribe), handing h the already-asserted
// typed message alongside the [*Request]. It is the generic form of
// [RequestMux.Handle]: the [message.Type] key is derived from T, and the type
// assertion a Handle callback would otherwise repeat (req.First.(*message.X)) is
// done once, here.
//
// A nil h unregisters T's type; registering a type that already has a handler
// replaces it.
func (m *RequestMux) HandleType[T message.WithRequestID](h func(*Request, T)) {
var zero T // nil pointer; message Type() methods are constant returns
if h == nil {
m.Handle(zero.Type(), nil)
return
}
m.Handle(zero.Type(), func(req *Request) {
msg, _ := req.First.(T)
h(req, msg)
})
}
// OnUnknown sets the callback invoked for an accepted request whose type has no
// registered handler. With no callback set (the default, or a nil f), an
// unmatched request is rejected with REQUEST_ERROR NOT_SUPPORTED and its stream
// FIN'd so it does not leak.
func (m *RequestMux) OnUnknown(f func(*Request)) {
m.mu.Lock()
defer m.mu.Unlock()
m.onUnknown = f
}
// Run accepts requests from sess and dispatches each to its registered handler
// until ctx is cancelled or [Session.AcceptRequest] returns an error, which Run
// returns.
//
// Some AcceptRequest errors are session-fatal protocol violations — a §10.1
// Request-ID parity/monotonicity violation (*ErrRequestIDParityViolation /
// *ErrDuplicateRequestID) or a token-cache fault (*TokenCacheError) — that the
// caller MUST escalate by closing the session with the mapped code (see
// [Session.AcceptRequest]). Run surfaces the error unchanged so the caller can
// inspect it with errors.As and Close accordingly.
//
// Dispatch is synchronous: a handler runs to completion before Run accepts the
// next request, mirroring a hand-written accept loop and [Demux.Run]. A handler
// that keeps a request stream open for the lifetime of a subscription therefore
// blocks the loop, so spawn a goroutine inside the handler when requests must be
// serviced concurrently.
func (m *RequestMux) Run(ctx context.Context, sess *Session) error {
for {
req, err := sess.AcceptRequest(ctx)
if err != nil {
return err
}
m.dispatch(req)
}
}
// dispatch routes one accepted request to its registered handler, or to the
// unknown path when none matches.
func (m *RequestMux) dispatch(req *Request) {
m.mu.RLock()
h := m.handlers[req.First.Type()]
f := m.onUnknown
m.mu.RUnlock()
if h != nil {
h(req)
return
}
if f != nil {
f(req)
return
}
_ = req.RejectError(moqt.RequestNotSupported, "moqt/session: no handler for request type")
}
package session
import (
"bytes"
"github.com/floatdrop/moq-go/pkg/moqt/message"
"github.com/floatdrop/moq-go/pkg/moqt/wire"
)
// reservedDot is a Track Namespace first field of exactly "." (0x2e), which
// §3.2.1 reserves for no purpose.
var reservedDot = []byte{0x2e}
// sessionNamespace is the ".session" first field (§3.2.2) MOQT reserves for
// session-level tracks and namespaces managed by the implementation.
var sessionNamespace = []byte(".session")
// requestNamespace returns the Track Namespace carried by a request's first
// message, or ok=false for first messages that carry none — notably a Joining
// FETCH, which references a Request ID instead of a namespace.
func requestNamespace(msg message.Message) (ns wire.TrackNamespace, ok bool) {
switch m := msg.(type) {
case *message.Subscribe:
return m.Namespace, true
case *message.Publish:
return m.Namespace, true
case *message.TrackStatus:
return m.Namespace, true
case *message.PublishNamespace:
return m.Namespace, true
case *message.SubscribeNamespace:
return m.TrackNamespacePrefix, true
case *message.SubscribeTracks:
return m.TrackNamespacePrefix, true
case *message.Fetch:
return m.Namespace, true
default:
return nil, false
}
}
// reservedNamespaceRejection classifies a request's Track Namespace against the
// §3.2.1 / §3.2.2 reserved-namespace rules and reports whether the request MUST
// be rejected with DOES_NOT_EXIST before the application ever sees it. The
// decision keys on the first namespace tuple field:
//
// - exactly "." (§3.2.1): reserved for no purpose — reject.
// - ".session" (§3.2.2): the session-level namespace, owned by the MOQT
// implementation rather than the application. This library implements no
// session-level tracks, so every such request is "unrecognized" and MUST be
// rejected with DOES_NOT_EXIST — which also subsumes the §3.2.2 rule that a
// ".session" namespace with an empty Track Name does not exist. A future
// session-level extension would dispatch its recognized tracks here instead
// of rejecting.
// - any other "."-prefixed value (§3.2.1): an unrecognized reserved namespace
// that MUST be passed to the application so future extensions don't break
// older implementations — so it is NOT rejected here.
// - anything else: an ordinary namespace — not rejected.
func reservedNamespaceRejection(msg message.Message) (reason string, reject bool) {
ns, ok := requestNamespace(msg)
if !ok || len(ns) == 0 {
return "", false
}
switch first := ns[0]; {
case bytes.Equal(first, reservedDot):
return `reserved namespace "." (§3.2.1)`, true
case bytes.Equal(first, sessionNamespace):
return "unrecognized session-level namespace (§3.2.2)", true
default:
return "", false
}
}
// Package session implements the MoQT session layer: SETUP handshake, control
// stream multiplexing, request-ID allocation, and graceful termination via
// GOAWAY (§3.3, §3.5, §10.3, §10.4 of draft-ietf-moq-transport-20).
//
// The package does not depend on a specific transport. It operates against the
// Conn interface, which any QUIC-like transport can satisfy.
package session
import (
"context"
"fmt"
"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/track"
"github.com/floatdrop/moq-go/pkg/moqt/wire"
)
// role identifies whether this endpoint initiated (client) or accepted
// (server) the underlying QUIC connection. role determines Request ID parity
// per §10.1: client IDs are even, server IDs are odd. The type is unexported
// because callers select a role by calling Client or Server rather than
// passing a value.
type role uint8
const (
roleClient role = iota
roleServer
)
// Session represents one MoQT session over a Conn after the SETUP handshake
// has completed. The Session owns the control-stream goroutines until Close
// is called.
type Session struct {
conn Conn
role role
sendCtrl SendStream
recvCtrl ReceiveStream
peerOptions []wire.KVPair
// Outgoing Request ID allocator: client starts at 0 (even), server at 1
// (odd); each AllocRequestID advances by 2 (§10.1).
nextRequestID atomic.Uint64
// Outgoing Track Alias allocator. §11.1: aliases are scoped to the
// publisher → subscriber direction of one session, so each end keeps an
// independent counter for tracks it advertises to the peer. The first
// allocation is 1, not 0: AllocOutboundTrackAlias reserves 0 as the
// "unset, auto-allocate" sentinel used by Publish/OpenPublish/Reply (see
// AllocOutboundTrackAlias). The spec does not constrain parity the way it
// does for Request IDs.
nextOutboundTrackAlias atomic.Uint64
// Serialized writes onto the control stream. Producers send through
// sendControl; the controlSendLoop drains and writes.
controlOut chan message.Message
mu sync.Mutex
goawayReceived *message.Goaway
goawaySent bool
// goawayCh is closed when goawayReceived transitions from nil to set.
goawayCh chan struct{}
// goawayHandler is the optional callback registered via OnGoaway. It is
// invoked exactly once, in its own goroutine, when the first GOAWAY
// arrives from the peer. goawayFired guards the at-most-once invocation
// across the handleGoaway and OnGoaway paths. Both are protected by mu.
goawayHandler func(*message.Goaway)
goawayFired bool
// Inbound Request ID tracking (§10.1). Protected by mu.
// peerRequestIDSeen is false until the first inbound Request ID arrives;
// peerRequestIDMax is the high-water mark. The peer allocates IDs in +2
// increments, but requests ride separate QUIC streams and can be
// DELIVERED out of order, so an ID below the mark is not automatically a
// duplicate: peerRequestIDGaps holds the not-yet-seen IDs below the mark
// (bounded by maxTrackedRequestIDGaps) that a late-delivered request may
// still legitimately claim. See [Session.CheckPeerRequestID].
peerRequestIDSeen bool
peerRequestIDMax uint64
peerRequestIDGaps map[uint64]struct{}
// Inbound Track Alias → track.Key mapping (§11.1). Protected by mu.
// Populated via RegisterInboundTrackAlias when the peer assigns an alias
// (SUBSCRIBE_OK or PUBLISH). A duplicate alias for a different track is
// a DUPLICATE_TRACK_ALIAS session error.
inboundAliases map[uint64]track.Key
// knownMandatoryTrackProperties is the set of Mandatory Track Property
// types (range 0x4000–0x7FFF) this endpoint supports. Configured via
// WithKnownMandatoryTrackProperties. nil means none are known.
knownMandatoryTrackProperties map[message.PropertyType]struct{}
// tokenCache is the inbound authorization-token alias cache (§10.2.2).
// AcceptRequest drives Register / Delete / Resolve on it from the
// AUTHORIZATION_TOKEN parameters of each inbound request. Always non-nil
// (sized 0 when MAX_AUTH_TOKEN_CACHE_SIZE was not negotiated, which
// prohibits aliasing per §10.3.1.3). The cache has its own internal
// mutex; it is not protected by mu.
tokenCache *TokenCache
// tokenVerifier is the optional application policy consulted for each
// resolved token. nil disables verification. Set once at construction
// via WithTokenVerifier; never mutated, so no lock is required.
tokenVerifier TokenVerifier
// maxRequestUpdates is the per-request-stream limit on unacknowledged
// inbound REQUEST_UPDATEs we advertised via MAX_REQUEST_UPDATES
// (§10.3.1.7). 0 means unlimited. Set once at construction via
// WithMaxRequestUpdates; read by NewRequestUpdateLimiter, so no lock.
maxRequestUpdates uint64
// maxFilterRanges is the total Range Filter range budget we advertised via
// MAX_FILTER_RANGES (§10.3.1.6). 0 prohibits Range Filters. Set once at
// construction via WithMaxFilterRanges; read via MaxFilterRanges(), no lock.
maxFilterRanges uint64
closeOnce sync.Once
// closeErr holds the *ClosedError cause; atomic because Err may
// be called at any time, not only after Done fires.
closeErr atomic.Pointer[ClosedError]
// done is closed when the session terminates for any reason.
done chan struct{}
}
// Client performs the SETUP handshake from the client side (the initiator of
// the underlying QUIC connection) and returns a ready Session. Request IDs on
// this side are even (§10.1). If the handshake fails, conn is closed and the
// error is returned. On success the caller owns the Session and must Close it.
func Client(ctx context.Context, conn Conn, opts ...Option) (*Session, error) {
return open(ctx, conn, opts, roleClient)
}
// Server performs the SETUP handshake from the server side (the acceptor of
// the underlying QUIC connection) and returns a ready Session. Request IDs on
// this side are odd (§10.1). Errors and ownership match Client.
func Server(ctx context.Context, conn Conn, opts ...Option) (*Session, error) {
return open(ctx, conn, opts, roleServer)
}
func open(ctx context.Context, conn Conn, opts []Option, r role) (*Session, error) {
var cfg config
for _, o := range opts {
o(&cfg)
}
s := &Session{
conn: conn,
role: r,
// Control-stream traffic is sparse (only GOAWAY after SETUP per §10
// table 5, and at most one outbound GOAWAY per session). A buffer of
// 1 lets a sender hand off while the previous frame is being written
// to the transport; anything larger just delays backpressure.
controlOut: make(chan message.Message, 1),
goawayCh: make(chan struct{}),
done: make(chan struct{}),
inboundAliases: make(map[uint64]track.Key),
knownMandatoryTrackProperties: cfg.knownMandatoryTrackProperties,
tokenCache: NewTokenCache(cfg.maxAuthTokenCacheSize),
tokenVerifier: cfg.tokenVerifier,
maxRequestUpdates: cfg.maxRequestUpdates,
maxFilterRanges: cfg.maxFilterRanges,
}
var first uint64
if r == roleServer {
first = 1
}
s.nextRequestID.Store(first)
// Before the handshake, so an option we must not send is never sent. The
// conn is left untouched and un-closed: nothing has been written to it, so
// disposing of it stays the caller's choice.
if err := checkOutboundSetupOptions(r, conn, cfg.setupOptions); err != nil {
return nil, err
}
if err := s.handshake(ctx, cfg.setupOptions); err != nil {
_ = conn.CloseWithError(uint64(moqt.SessionProtocolViolation), err.Error())
return nil, err
}
if code, err := s.checkPeerSetupOptions(); err != nil {
_ = conn.CloseWithError(uint64(code), err.Error())
return nil, err
}
go s.controlSendLoop()
go s.controlRecvLoop()
return s, nil
}
// PeerOptions returns the SETUP options the peer advertised. The returned
// slice aliases internal state and must not be mutated.
func (s *Session) PeerOptions() []wire.KVPair { return s.peerOptions }
// webTransportConn is an optional capability a Conn adapter may implement to
// report that it runs over WebTransport. An adapter that does not implement it
// is treated as native QUIC, so the checks below are best-effort — they cover
// the wtconn adapter in this repo, not a third-party WebTransport adapter that
// stays silent.
//
// Deliberately not a Conn method: only the §10.3.1 PATH/AUTHORITY rules need
// it, and per CLAUDE.md anything added to that interface must land in all three
// adapters plus any external one.
type webTransportConn interface {
IsWebTransport() bool
}
func overWebTransport(conn Conn) bool {
wt, ok := conn.(webTransportConn)
return ok && wt.IsWebTransport()
}
// checkOutboundSetupOptions enforces the send side of PATH (§10.3.1.2) and
// AUTHORITY (§10.3.1.1). Each says the option "MUST NOT be used by the server,
// or when WebTransport is used" — so unlike checkPeerSetupOptions, which
// handles what a peer did to us, this catches what we are about to do to a
// peer, before the SETUP goes out.
//
// It fails the open rather than dropping the offending option, because silently
// discarding it would leave a client believing it had requested an authority
// the server never saw.
func checkOutboundSetupOptions(r role, conn Conn, opts []wire.KVPair) error {
for _, opt := range opts {
var name string
switch message.SetupOption(opt.Type) {
case message.SetupOptionPath:
name = "PATH"
case message.SetupOptionAuthority:
name = "AUTHORITY"
case message.SetupOptionAuthorizationToken,
message.SetupOptionMaxAuthTokenCache,
message.SetupOptionMaxFilterRanges,
message.SetupOptionMOQTImplementation,
message.SetupOptionMaxRequestUpdates:
continue
default:
continue
}
if r == roleServer {
return fmt.Errorf("moqt/session: %s setup option is client-only (§10.3.1)", name)
}
if overWebTransport(conn) {
return fmt.Errorf(
"moqt/session: %s setup option must not be used on a WebTransport session (§10.3.1); "+
"HTTP/3 carries the path and authority in the CONNECT request", name)
}
}
return nil
}
// checkPeerSetupOptions enforces the receive side of the PATH (§10.3.1.2) and
// AUTHORITY (§10.3.1.1) setup options. Each names three conditions under which
// a received option MUST close the session — it came from a server, it arrived
// on a WebTransport session, or the server does not support the value — with
// INVALID_PATH and INVALID_AUTHORITY respectively, whose numeric values come
// from the §3.5 registry. Returns that close code and the reason, or a nil
// error when the peer's options are acceptable.
//
// The first two are enforced. The third is not: it needs the server to be told
// which paths and authorities it serves, and that configuration does not exist
// — a server that never learns its own names cannot check them.
//
// Note the asymmetry with the role gate. A server ignores these options over
// native QUIC because receiving them there is precisely what they are for; it
// must still reject them over WebTransport, where §10.3.1 forbids them
// outright and HTTP/3 carries the same information in the CONNECT request.
func (s *Session) checkPeerSetupOptions() (moqt.SessionErrorCode, error) {
overWT := overWebTransport(s.conn)
if s.role != roleClient && !overWT {
return moqt.SessionNoError, nil
}
violation := func(name string) error {
if overWT {
return fmt.Errorf(
"peer sent a %s setup option on a WebTransport session (§10.3.1)", name)
}
return fmt.Errorf("server sent a %s setup option, which is client-only (§10.3.1)", name)
}
for _, opt := range s.peerOptions {
switch message.SetupOption(opt.Type) {
case message.SetupOptionPath:
return moqt.SessionInvalidPath, violation("PATH")
case message.SetupOptionAuthority:
return moqt.SessionInvalidAuthority, violation("AUTHORITY")
case message.SetupOptionAuthorizationToken,
message.SetupOptionMaxAuthTokenCache,
message.SetupOptionMaxFilterRanges,
message.SetupOptionMOQTImplementation,
message.SetupOptionMaxRequestUpdates:
// Legal in both directions. Listed rather than folded into the
// default so that adding a §10.3.1 option fails the exhaustive
// linter until someone decides whether a server may send it.
default:
// §10.3 requires a receiver ignore options it does not recognize.
}
}
return moqt.SessionNoError, nil
}
// MaxFilterRanges returns the MAX_FILTER_RANGES value this session advertised
// (§10.3.1.6) — the total Range Filter range budget it will accept on any one
// subscription or fetch. 0 prohibits Range Filters. The relay enforces this
// when validating a request's Range Filters against [message.RangeFilterSet.Validate].
func (s *Session) MaxFilterRanges() uint64 { return s.maxFilterRanges }
// AllocRequestID returns the next outbound Request ID per §10.1.
func (s *Session) AllocRequestID() uint64 {
return s.nextRequestID.Add(2) - 2
}
// Done returns a channel that is closed when the session has terminated.
func (s *Session) Done() <-chan struct{} { return s.done }
// Err returns the close cause — a *ClosedError carrying the §3.5
// error code and reason of the first Close call — or nil when the session
// was closed cleanly (SessionNoError) or is still open. The value is
// published before Done is closed, so the natural pattern
// <-sess.Done(); sess.Err() is race-free.
func (s *Session) Err() error {
// Explicit nil check: returning a nil *ClosedError directly
// would produce a non-nil error interface.
if e := s.closeErr.Load(); e != nil {
return e
}
return nil
}
// ClosedError is the close cause stored by [Session.Close] and
// returned by [Session.Err] for a non-clean close.
type ClosedError struct {
Code moqt.SessionErrorCode
Reason string
}
func (e *ClosedError) Error() string {
return fmt.Sprintf("moqt/session: closed with code %#x: %s", uint64(e.Code), e.Reason)
}
// Close terminates the session, cancelling the control streams and closing
// the underlying connection with the given code (§3.5). Calling Close more
// than once is safe; only the first call's code takes effect. The returned
// error is the transport's close error (nil in the common case), NOT the
// close cause — that is what [Session.Err] reports.
func (s *Session) Close(code moqt.SessionErrorCode, reason string) error {
var transportErr error
s.closeOnce.Do(func() {
// Publish the cause BEFORE closing done: Err must be safe to call
// the moment Done fires.
if code != moqt.SessionNoError {
s.closeErr.Store(&ClosedError{Code: code, Reason: reason})
}
close(s.done)
// CancelRead first so the recv loop unblocks. The control stream
// must not be FIN'd cleanly during session lifetime (§3.3); we
// reset both directions instead.
if s.recvCtrl != nil {
s.recvCtrl.CancelRead(uint64(moqt.StreamResetSessionClosed))
}
if s.sendCtrl != nil {
s.sendCtrl.CancelWrite(uint64(moqt.StreamResetSessionClosed))
}
transportErr = s.conn.CloseWithError(uint64(code), reason)
})
return transportErr
}
package sessiontest
import (
"io"
"net"
"sync"
)
// pipeReadCloser / pipeWriteCloser are the two halves of an in-process pipe.
// Both *io.PipeReader/*io.PipeWriter (synchronous) and *bufPipe's reader/writer
// (buffered) satisfy them, so [uniStream] / [bidiStream] can be backed by
// either without caring which.
type pipeReadCloser interface {
io.Reader
CloseWithError(error) error
}
type pipeWriteCloser interface {
io.Writer
Close() error
CloseWithError(error) error
}
// newPipe returns a connected reader/writer pair. With bufSize <= 0 it returns
// a synchronous io.Pipe (the historical default — every Write blocks until a
// Read drains it). With bufSize > 0 it returns a [bufPipe] of that capacity,
// which lets the writer run ahead and is what the throughput benchmarks use to
// avoid measuring per-object goroutine scheduling instead of forwarding work.
func newPipe(bufSize int) (pipeReadCloser, pipeWriteCloser) {
if bufSize > 0 {
return newBufPipe(bufSize)
}
return io.Pipe()
}
// bufPipe is a bounded, buffered, in-memory byte pipe — a drop-in alternative
// to io.Pipe for the sessiontest transport. io.Pipe is fully synchronous: every
// Write blocks until a Read consumes it, forcing a goroutine handoff per write,
// so a relay/session benchmark over it spends ~85% of its CPU in the scheduler
// (usleep / cond_signal / cond_wait) rather than in forwarding code. bufPipe
// lets the writer run ahead by up to `cap` buffered bytes before blocking, so
// producer and consumer wake in bursts instead of lock-stepping per object.
//
// Semantics otherwise match io.Pipe:
// - a clean writer Close surfaces as io.EOF to the reader, but only after the
// buffered bytes have been drained;
// - CloseWithError on either half unblocks the other half with that error.
type bufPipe struct {
mu sync.Mutex
notEmpty sync.Cond
notFull sync.Cond
buf []byte // ring buffer
r, n int // read index, number of bytes currently buffered
rerr error // set when the reader closes; returned to the writer
werr error // set when the writer closes; returned to the reader once drained
}
func newBufPipe(capacity int) (pipeReadCloser, pipeWriteCloser) {
bp := &bufPipe{buf: make([]byte, capacity)}
bp.notEmpty.L = &bp.mu
bp.notFull.L = &bp.mu
return bufPipeReader{bp}, bufPipeWriter{bp}
}
// write copies all of p into the ring, blocking while the buffer is full. It
// returns early with rerr if the reader has gone away (mirrors io.Pipe writing
// to a closed reader), and with werr when the WRITE side itself was already
// closed — real QUIC rejects writes after FIN, and silently buffering them
// here would let tests pass flows production fails on.
func (bp *bufPipe) write(p []byte) (int, error) {
bp.mu.Lock()
defer bp.mu.Unlock()
total := 0
for len(p) > 0 {
if bp.werr != nil {
return total, net.ErrClosed
}
for bp.n == len(bp.buf) && bp.rerr == nil && bp.werr == nil {
bp.notFull.Wait()
}
if bp.rerr != nil {
return total, bp.rerr
}
if bp.werr != nil {
return total, net.ErrClosed
}
// Copy into the contiguous free region starting at the write index,
// stopping at the buffer end (the next iteration handles the wrap).
w := (bp.r + bp.n) % len(bp.buf)
chunk := min(len(p), len(bp.buf)-bp.n, len(bp.buf)-w)
copy(bp.buf[w:w+chunk], p[:chunk])
bp.n += chunk
total += chunk
p = p[chunk:]
bp.notEmpty.Signal()
}
return total, nil
}
// read drains up to len(p) bytes, blocking while the buffer is empty. Once the
// writer has closed and the buffer is empty it returns werr (io.EOF on a clean
// close).
//
// The two half-closes are deliberately asymmetric. A writer close is reported
// only after the buffer drains — that is what makes this a buffered pipe, and
// it matches io.Pipe for a clean Close. A reader close takes effect at once,
// buffered bytes or not: it models QUIC STOP_SENDING, and a stream that kept
// handing back objects after the session reset it would let a test observe
// delivery production never performs. Checking rerr before the drain loop is
// also what gives the reader's own error precedence over a writer close that
// lands afterwards, so a reset is not reported as a clean io.EOF.
func (bp *bufPipe) read(p []byte) (int, error) {
if len(p) == 0 {
return 0, nil
}
bp.mu.Lock()
defer bp.mu.Unlock()
if bp.rerr != nil {
return 0, bp.rerr
}
for bp.n == 0 {
if bp.werr != nil {
return 0, bp.werr
}
if bp.rerr != nil {
return 0, bp.rerr
}
bp.notEmpty.Wait()
}
chunk := min(len(p), bp.n, len(bp.buf)-bp.r)
copy(p, bp.buf[bp.r:bp.r+chunk])
bp.r = (bp.r + chunk) % len(bp.buf)
bp.n -= chunk
bp.notFull.Signal()
return chunk, nil
}
func (bp *bufPipe) closeWrite(err error) error {
if err == nil {
err = io.EOF
}
bp.mu.Lock()
if bp.werr == nil {
bp.werr = err
}
bp.notEmpty.Broadcast()
bp.notFull.Broadcast() // wake any writer blocked on a full buffer
bp.mu.Unlock()
return nil
}
func (bp *bufPipe) closeRead(err error) error {
if err == nil {
err = io.ErrClosedPipe
}
bp.mu.Lock()
if bp.rerr == nil {
bp.rerr = err
}
bp.notFull.Broadcast()
bp.notEmpty.Broadcast()
bp.mu.Unlock()
return nil
}
type bufPipeReader struct{ bp *bufPipe }
func (r bufPipeReader) Read(p []byte) (int, error) { return r.bp.read(p) }
func (r bufPipeReader) CloseWithError(err error) error { return r.bp.closeRead(err) }
type bufPipeWriter struct{ bp *bufPipe }
func (w bufPipeWriter) Write(p []byte) (int, error) { return w.bp.write(p) }
func (w bufPipeWriter) Close() error { return w.bp.closeWrite(io.EOF) }
func (w bufPipeWriter) CloseWithError(err error) error { return w.bp.closeWrite(err) }
package sessiontest
import (
"context"
"fmt"
"sync/atomic"
"github.com/floatdrop/moq-go/pkg/moqt/session"
)
// Op identifies the [session.Conn] or [session.Stream] operation a [FaultFunc]
// is being consulted about.
type Op int
const (
OpOpenStream Op = iota
OpOpenUniStream
OpAcceptStream
OpAcceptUniStream
OpSendDatagram
OpReceiveDatagram
OpStreamWrite
OpStreamRead
OpStreamClose
)
// numOps sizes the per-Op counter arrays. It is deliberately an untyped
// constant rather than a trailing iota member: as an Op it would be a phantom
// enum value every exhaustive switch had to handle.
const numOps = int(OpStreamClose) + 1
func (o Op) String() string {
switch o {
case OpOpenStream:
return "OpenStream"
case OpOpenUniStream:
return "OpenUniStream"
case OpAcceptStream:
return "AcceptStream"
case OpAcceptUniStream:
return "AcceptUniStream"
case OpSendDatagram:
return "SendDatagram"
case OpReceiveDatagram:
return "ReceiveDatagram"
case OpStreamWrite:
return "StreamWrite"
case OpStreamRead:
return "StreamRead"
case OpStreamClose:
return "StreamClose"
}
return fmt.Sprintf("Op(%d)", int(o))
}
// FaultOp describes the operation a [FaultFunc] is being consulted about.
type FaultOp struct {
// Op is the operation about to be performed.
Op Op
// Stream is the ordinal of the stream the operation is on, numbered from
// 1 in the order the conn handed streams out. Unidirectional and
// bidirectional streams share the one sequence, and both opening and
// accepting allocate from it. Stream is 0 for connection-level operations
// (the opens and accepts themselves, and the datagram calls).
//
// Ordinals are only stable when the test controls the order streams are
// created in. Code under test that opens streams from several goroutines
// — a relay fanning out to subscribers, for one — does not give that;
// match on Buf there instead.
Stream int
// N counts occurrences of this Op, from 1: per stream for the stream
// operations, per conn for the connection-level ones.
N int
// Buf is the caller's buffer for OpStreamWrite and OpStreamRead, and the
// payload for OpSendDatagram; nil for every other Op. On OpStreamRead it
// is the destination buffer, which the read has not filled yet — its
// length is the size of the read, not data. Do not retain or modify it.
Buf []byte
}
// FaultFunc is consulted before each wrapped operation. Returning a non-nil
// error makes the operation fail with that error instead of being performed;
// returning nil lets it through untouched.
//
// It runs on whichever goroutine drives the operation — for a relay under
// test, several at once — so it must be safe for concurrent use.
type FaultFunc func(FaultOp) error
// Faulty wraps c so fault is consulted before every operation on the conn and
// on every stream the conn hands out, letting a test make a chosen write, open
// or read fail. It exists to reach the error branches a healthy in-process pipe
// never takes: the "reply failed" and "write failed" paths that in production
// run when a peer's transport goes bad.
//
// Faults are injected in front of the wrapped operation, so a failed one never
// reaches the underlying conn: a failed Write puts no bytes on the stream, and
// a failed OpenStream consumes no stream credit.
//
// Two limitations, both deliberate:
//
// - A failed Write reports (0, err). Real QUIC can fail part-way through and
// report a short write; nothing in this tree distinguishes the two, so the
// wrapper does not model it.
// - Wrapped streams do not forward the optional [session.PrioritizedSendStream]
// and [session.ReliableResetStream] interfaces. No sessiontest stream
// implements either, and silently dropping §7.2 priority or RESET_STREAM_AT
// would be a confusing way to find that out, so Faulty panics rather than
// wrap a stream that does.
func Faulty(c session.Conn, fault FaultFunc) session.Conn {
fc := &faultyConn{Conn: c}
fc.fault = fault
return fc
}
// FailNth returns a [FaultFunc] failing the nth occurrence of op with err,
// counted from 1 across the whole conn. Every other operation succeeds.
func FailNth(op Op, n int, err error) FaultFunc {
var seen atomic.Int64
return func(f FaultOp) error {
if f.Op != op {
return nil
}
if seen.Add(1) == int64(n) {
return err
}
return nil
}
}
// FailAll returns a [FaultFunc] failing every occurrence of op with err.
func FailAll(op Op, err error) FaultFunc {
return func(f FaultOp) error {
if f.Op != op {
return nil
}
return err
}
}
// faultyCounter holds the fault state shared by the conn and its streams: the
// hook, which stream this is (0 for the conn itself), and how many times each
// Op has been seen here. It contains atomics, so it is embedded and filled in
// place — never copied.
type faultyCounter struct {
fault FaultFunc
stream int
counts [numOps]atomic.Int64
}
func (f *faultyCounter) check(op Op, buf []byte) error {
return f.fault(FaultOp{
Op: op,
Stream: f.stream,
N: int(f.counts[op].Add(1)),
Buf: buf,
})
}
type faultyConn struct {
session.Conn
faultyCounter
streams atomic.Int64 // stream ordinal allocator
}
func (c *faultyConn) OpenStream() (session.Stream, error) {
if err := c.check(OpOpenStream, nil); err != nil {
return nil, err
}
s, err := c.Conn.OpenStream()
if err != nil {
return nil, err
}
return c.newStream(s), nil
}
func (c *faultyConn) AcceptStream(ctx context.Context) (session.Stream, error) {
if err := c.check(OpAcceptStream, nil); err != nil {
return nil, err
}
s, err := c.Conn.AcceptStream(ctx)
if err != nil {
return nil, err
}
return c.newStream(s), nil
}
func (c *faultyConn) OpenUniStream() (session.SendStream, error) {
if err := c.check(OpOpenUniStream, nil); err != nil {
return nil, err
}
s, err := c.Conn.OpenUniStream()
if err != nil {
return nil, err
}
assertPlainSend(s)
fs := &faultySendStream{SendStream: s}
c.initCounter(&fs.faultyCounter)
return fs, nil
}
func (c *faultyConn) AcceptUniStream(ctx context.Context) (session.ReceiveStream, error) {
if err := c.check(OpAcceptUniStream, nil); err != nil {
return nil, err
}
s, err := c.Conn.AcceptUniStream(ctx)
if err != nil {
return nil, err
}
fs := &faultyRecvStream{ReceiveStream: s}
c.initCounter(&fs.faultyCounter)
return fs, nil
}
func (c *faultyConn) SendDatagram(payload []byte) error {
if err := c.check(OpSendDatagram, payload); err != nil {
return err
}
return c.Conn.SendDatagram(payload)
}
func (c *faultyConn) ReceiveDatagram(ctx context.Context) ([]byte, error) {
if err := c.check(OpReceiveDatagram, nil); err != nil {
return nil, err
}
return c.Conn.ReceiveDatagram(ctx)
}
func (c *faultyConn) newStream(s session.Stream) *faultyStream {
assertPlainSend(s)
fs := &faultyStream{Stream: s}
c.initCounter(&fs.faultyCounter)
return fs
}
// initCounter fills a stream's fault state in place and allocates its ordinal.
func (c *faultyConn) initCounter(fc *faultyCounter) {
fc.fault = c.fault
fc.stream = int(c.streams.Add(1))
}
// assertPlainSend panics if s implements one of the optional SendStream
// interfaces the wrappers cannot forward. See [Faulty] for why this is loud
// rather than silent.
func assertPlainSend(s any) {
switch s.(type) {
case session.PrioritizedSendStream:
panic("sessiontest.Faulty: refusing to wrap a session.PrioritizedSendStream — " +
"the wrapper cannot forward SetSendPriority")
case session.ReliableResetStream:
panic("sessiontest.Faulty: refusing to wrap a session.ReliableResetStream — " +
"the wrapper cannot forward SetReliableBoundary")
}
}
type faultyStream struct {
session.Stream
faultyCounter
}
func (s *faultyStream) Write(p []byte) (int, error) {
if err := s.check(OpStreamWrite, p); err != nil {
return 0, err
}
return s.Stream.Write(p)
}
func (s *faultyStream) Read(p []byte) (int, error) {
if err := s.check(OpStreamRead, p); err != nil {
return 0, err
}
return s.Stream.Read(p)
}
func (s *faultyStream) Close() error {
if err := s.check(OpStreamClose, nil); err != nil {
return err
}
return s.Stream.Close()
}
type faultySendStream struct {
session.SendStream
faultyCounter
}
func (s *faultySendStream) Write(p []byte) (int, error) {
if err := s.check(OpStreamWrite, p); err != nil {
return 0, err
}
return s.SendStream.Write(p)
}
func (s *faultySendStream) Close() error {
if err := s.check(OpStreamClose, nil); err != nil {
return err
}
return s.SendStream.Close()
}
type faultyRecvStream struct {
session.ReceiveStream
faultyCounter
}
func (s *faultyRecvStream) Read(p []byte) (int, error) {
if err := s.check(OpStreamRead, p); err != nil {
return 0, err
}
return s.ReceiveStream.Read(p)
}
// Package sessiontest provides in-process helpers for testing MoQT session
// code without a real QUIC transport. NewConnPair returns two session.Conn
// endpoints backed by io.Pipes; streams opened on one end are accepted on
// the other. NewSessionPair goes one step further and performs the full SETUP
// handshake, returning two ready *session.Session values.
//
// The implementation deliberately mirrors a real QUIC stream's semantics
// where it matters for tests:
//
// - Opening a stream never blocks (per the Conn contract): the stream is
// offered to the peer's accept queue immediately, and a full queue —
// the peer isn't accepting — surfaces as ErrNoStreamCredit rather than
// a deadlocked opener. (Unlike quic-go, the peer can therefore see a
// uni stream before its first byte is written.)
// - CancelRead / CancelWrite unblock any in-flight Read / Write with an
// error.
// - CloseWithError cancels the shared connection context, which unblocks
// any pending Accept on either end.
package sessiontest
import (
"context"
"errors"
"sync"
"testing"
"github.com/floatdrop/moq-go/pkg/moqt/session"
)
// NewSessionPair performs the MoQT SETUP handshake over an in-process conn
// pair and returns two ready Sessions — client (even Request IDs) and server
// (odd Request IDs). Both sessions are closed via tb.Cleanup when the test
// or benchmark ends.
//
// The parameter is testing.TB rather than *testing.T so the helper serves
// both tests and benchmarks. Because testing.TB does not expose Context()
// (that method lives only on *testing.T / *testing.B), the handshake context
// is managed internally and cancelled via tb.Cleanup.
func NewSessionPair(tb testing.TB) (client, server *session.Session) {
tb.Helper()
connA, connB := NewConnPair()
ctx, cancel := context.WithCancel(context.Background())
tb.Cleanup(cancel)
var (
wg sync.WaitGroup
aSess, bSess *session.Session
aErr, bErr error
)
wg.Go(func() {
aSess, aErr = session.Client(ctx, connA)
})
wg.Go(func() {
bSess, bErr = session.Server(ctx, connB)
})
wg.Wait()
if aErr != nil {
tb.Fatalf("sessiontest.NewSessionPair client: %v", aErr)
}
if bErr != nil {
tb.Fatalf("sessiontest.NewSessionPair server: %v", bErr)
}
tb.Cleanup(func() {
_ = aSess.Close(0, "")
_ = bSess.Close(0, "")
})
return aSess, bSess
}
// NewConnPair returns two session.Conn endpoints wired together in-process.
// Both endpoints have unlimited outbound bidirectional-stream credit; use
// [NewConnPairWithLimits] to cap one or both sides for PUBLISH_SKIPPED-style
// stream-exhaustion testing.
func NewConnPair() (a, b session.Conn) {
return NewConnPairWithLimits(-1, -1)
}
// NewConnPairWithLimits is [NewConnPair] with an explicit cap on each
// endpoint's outbound bidirectional-stream credit, modelling the peer's QUIC
// MAX_STREAMS limit. aBidiLimit caps how many bidi streams endpoint a may
// open; bBidiLimit does the same for endpoint b. A negative limit means
// unlimited. Once an endpoint's credit is exhausted, [pipeConn.OpenStream]
// returns [session.ErrNoStreamCredit] immediately — mirroring real QUIC, where
// a new stream cannot be opened until the peer raises the MAX_STREAMS limit.
func NewConnPairWithLimits(aBidiLimit, bBidiLimit int) (a, b session.Conn) {
return newConnPair(aBidiLimit, bBidiLimit, 0)
}
// NewConnPairBuffered is [NewConnPair] but with each stream backed by a
// buffered [bufPipe] of bufSize bytes instead of a synchronous io.Pipe. The
// writer can run ahead by up to bufSize bytes before blocking, which decouples
// the producer and consumer goroutines so a relay/session throughput benchmark
// measures forwarding work rather than per-object goroutine scheduling. Both
// endpoints have unlimited outbound bidi-stream credit.
func NewConnPairBuffered(bufSize int) (a, b session.Conn) {
return newConnPair(-1, -1, bufSize)
}
func newConnPair(aBidiLimit, bBidiLimit, bufSize int) (a, b session.Conn) {
aUniToB := make(chan *uniStream, 4)
bUniToA := make(chan *uniStream, 4)
aBidiToB := make(chan *bidiStream, 4)
bBidiToA := make(chan *bidiStream, 4)
// Datagram channels: what A sends, B receives, and vice-versa.
aDatagramToB := make(chan []byte, 16)
bDatagramToA := make(chan []byte, 16)
aCtx, aCancel := context.WithCancel(context.Background())
bCtx, bCancel := context.WithCancel(context.Background())
return &pipeConn{
uniOut: aUniToB, uniIn: bUniToA,
bidiOut: aBidiToB, bidiIn: bBidiToA,
datagramOut: aDatagramToB, datagramIn: bDatagramToA,
ctx: aCtx, ctxCancel: aCancel,
bidiCredit: aBidiLimit,
bufSize: bufSize,
},
&pipeConn{
uniOut: bUniToA, uniIn: aUniToB,
bidiOut: bBidiToA, bidiIn: aBidiToB,
datagramOut: bDatagramToA, datagramIn: aDatagramToB,
ctx: bCtx, ctxCancel: bCancel,
bidiCredit: bBidiLimit,
bufSize: bufSize,
}
}
var errCancelled = errors.New("sessiontest: stream cancelled")
var errConnClosed = errors.New("sessiontest: connection closed")
// uniStream is a unidirectional pipe. The opener writes via w; the acceptor
// reads via r. The same struct satisfies both SendStream and ReceiveStream;
// each side gets back the appropriate interface, which constrains which
// methods they can call.
//
// ctx / ctxCancel implement Context(): the context is cancelled when Close()
// or CancelWrite() is called, signalling "all data committed" (or reset).
// For in-process pipes, data is synchronously delivered, so Close() is
// equivalent to "all data acknowledged".
type uniStream struct {
r pipeReadCloser
w pipeWriteCloser
ctx context.Context
ctxCancel context.CancelFunc
}
func newUniStream(bufSize int) *uniStream {
r, w := newPipe(bufSize)
ctx, cancel := context.WithCancel(context.Background())
return &uniStream{r: r, w: w, ctx: ctx, ctxCancel: cancel}
}
func (s *uniStream) Write(p []byte) (int, error) { return s.w.Write(p) }
func (s *uniStream) Close() error {
err := s.w.Close()
s.ctxCancel() // signal "all data committed"
return err
}
func (s *uniStream) CancelWrite(uint64) {
_ = s.w.CloseWithError(errCancelled)
s.ctxCancel() // signal reset
}
func (s *uniStream) Read(p []byte) (int, error) { return s.r.Read(p) }
func (s *uniStream) CancelRead(uint64) { _ = s.r.CloseWithError(errCancelled) }
// Context is cancelled when Close() or CancelWrite() has been called,
// indicating the send side is done (either cleanly or via reset).
func (s *uniStream) Context() context.Context { return s.ctx }
// bidiStream is two io.Pipes wired so each end reads what the other writes.
// ctx / ctxCancel implement Context() on the send side.
type bidiStream struct {
r pipeReadCloser
w pipeWriteCloser
ctx context.Context
ctxCancel context.CancelFunc
}
func newBidiStreamPair(bufSize int) (a, b *bidiStream) {
aR, aW := newPipe(bufSize) // a writes, b reads
bR, bW := newPipe(bufSize) // b writes, a reads
aCtx, aCancel := context.WithCancel(context.Background())
bCtx, bCancel := context.WithCancel(context.Background())
return &bidiStream{r: bR, w: aW, ctx: aCtx, ctxCancel: aCancel},
&bidiStream{r: aR, w: bW, ctx: bCtx, ctxCancel: bCancel}
}
func (s *bidiStream) Read(p []byte) (int, error) { return s.r.Read(p) }
func (s *bidiStream) Write(p []byte) (int, error) { return s.w.Write(p) }
func (s *bidiStream) Close() error {
err := s.w.Close()
s.ctxCancel() // signal "all data committed"
return err
}
func (s *bidiStream) CancelRead(uint64) { _ = s.r.CloseWithError(errCancelled) }
func (s *bidiStream) CancelWrite(uint64) {
_ = s.w.CloseWithError(errCancelled)
s.ctxCancel() // signal reset
}
// Context is cancelled when Close() or CancelWrite() has been called.
func (s *bidiStream) Context() context.Context { return s.ctx }
// cancellable is satisfied by both uniStream and bidiStream — anything the
// pipeConn hands out and needs to forcibly tear down on connection close.
type cancellable interface {
CancelRead(uint64)
CancelWrite(uint64)
}
type pipeConn struct {
uniOut, uniIn chan *uniStream
bidiOut, bidiIn chan *bidiStream
datagramOut, datagramIn chan []byte
ctx context.Context
ctxCancel context.CancelFunc
mu sync.Mutex
closed bool
tracked []cancellable
// bidiCredit caps how many outbound bidirectional streams this endpoint
// may open, modelling the peer's QUIC MAX_STREAMS limit. A negative value
// means unlimited. bidiUsed counts streams already opened; both are
// guarded by mu.
bidiCredit int
bidiUsed int
// bufSize selects the per-stream pipe backing: 0 = synchronous io.Pipe,
// >0 = a buffered bufPipe of that capacity (see [newPipe]).
bufSize int
}
// reserveBidiCredit accounts for one outbound bidi stream against the cap.
// Returns false when the cap is set (non-negative) and already exhausted.
func (c *pipeConn) reserveBidiCredit() bool {
c.mu.Lock()
defer c.mu.Unlock()
if c.bidiCredit >= 0 && c.bidiUsed >= c.bidiCredit {
return false
}
c.bidiUsed++
return true
}
// track records s so CloseWithError can cancel it. Returns false if the conn
// has already been closed, in which case the caller should cancel s itself.
func (c *pipeConn) track(s cancellable) bool {
c.mu.Lock()
defer c.mu.Unlock()
if c.closed {
return false
}
c.tracked = append(c.tracked, s)
return true
}
func (c *pipeConn) OpenUniStream() (session.SendStream, error) {
select {
case <-c.ctx.Done():
return nil, errConnClosed
default:
}
s := newUniStream(c.bufSize)
// Non-blocking per the Conn contract: a full accept queue means the
// peer isn't draining opens — surface ErrNoStreamCredit instead of
// deadlocking the opener (the transport equivalent of an exhausted
// stream limit).
select {
case c.uniOut <- s:
if !c.track(s) {
s.CancelRead(0)
s.CancelWrite(0)
return nil, errConnClosed
}
return s, nil
case <-c.ctx.Done():
return nil, errConnClosed
default:
return nil, session.ErrNoStreamCredit
}
}
func (c *pipeConn) AcceptUniStream(ctx context.Context) (session.ReceiveStream, error) {
select {
case s := <-c.uniIn:
if !c.track(s) {
s.CancelRead(0)
s.CancelWrite(0)
return nil, errConnClosed
}
return s, nil
case <-ctx.Done():
return nil, ctx.Err()
case <-c.ctx.Done():
return nil, errConnClosed
}
}
// OpenStream is the non-blocking bidi open. When the credit cap is exhausted
// it returns session.ErrNoStreamCredit instead of blocking, mirroring
// quic-go's Conn.OpenStream / StreamLimitReachedError.
func (c *pipeConn) OpenStream() (session.Stream, error) {
if !c.reserveBidiCredit() {
return nil, session.ErrNoStreamCredit
}
mine, peers := newBidiStreamPair(c.bufSize)
// Non-blocking per the Conn contract — see OpenUniStream.
select {
case c.bidiOut <- peers:
if !c.track(mine) {
mine.CancelRead(0)
mine.CancelWrite(0)
return nil, errConnClosed
}
return mine, nil
case <-c.ctx.Done():
return nil, errConnClosed
default:
return nil, session.ErrNoStreamCredit
}
}
func (c *pipeConn) AcceptStream(ctx context.Context) (session.Stream, error) {
select {
case s := <-c.bidiIn:
if !c.track(s) {
s.CancelRead(0)
s.CancelWrite(0)
return nil, errConnClosed
}
return s, nil
case <-ctx.Done():
return nil, ctx.Err()
case <-c.ctx.Done():
return nil, errConnClosed
}
}
// SendDatagram delivers payload to the peer's datagramIn channel. The payload
// is copied so the caller may reuse the slice immediately.
func (c *pipeConn) SendDatagram(payload []byte) error {
cp := make([]byte, len(payload))
copy(cp, payload)
select {
case c.datagramOut <- cp:
return nil
case <-c.ctx.Done():
return errConnClosed
}
}
// ReceiveDatagram blocks until a datagram arrives from the peer or ctx / the
// connection is cancelled.
func (c *pipeConn) ReceiveDatagram(ctx context.Context) ([]byte, error) {
select {
case p := <-c.datagramIn:
return p, nil
case <-ctx.Done():
return nil, ctx.Err()
case <-c.ctx.Done():
return nil, errConnClosed
}
}
// CloseWithError mirrors quic-go: cancelling the connection also forcibly
// tears down every stream the conn has handed out, so any in-flight Read or
// Write on those streams unblocks with an error.
func (c *pipeConn) CloseWithError(uint64, string) error {
c.mu.Lock()
if c.closed {
c.mu.Unlock()
return nil
}
c.closed = true
tracked := c.tracked
c.tracked = nil
c.mu.Unlock()
c.ctxCancel()
for _, s := range tracked {
s.CancelRead(0)
s.CancelWrite(0)
}
return nil
}
func (c *pipeConn) Context() context.Context { return c.ctx }
package session
import (
"context"
"io"
"github.com/floatdrop/moq-go/pkg/moqt"
)
// DrainAndWait keeps a request stream alive until the peer closes its send
// side (FIN or RESET_STREAM) or ctx is cancelled, whichever comes first.
// It does not expect any meaningful data on the stream — incoming bytes are
// read and discarded.
//
// This is the canonical "hold a long-lived request stream open" primitive
// for MoQT consumers. §6.1, §6.2, and §10.7 all model the request stream as
// a subscription-lifetime keepalive: post-OK there are no further wire
// messages, but the stream must stay open as long as the requester still
// wants the subscription. Both the relay's session handlers and end
// subscribers / publishers benefit from a shared implementation rather than
// re-inventing the ctx-cancel + CancelRead dance at every call site.
//
// On ctx cancellation DrainAndWait calls [ReceiveStream.CancelRead] with
// [moqt.StreamResetSessionClosed] so the underlying read unblocks promptly.
// The function does not return until the read goroutine has exited; this is
// what makes it safe to invoke from a [sync.WaitGroup.Go] without leaking.
//
// DrainAndWait is concurrency-safe in the trivial sense that the inner
// goroutine is owned by this call; do not invoke it concurrently with other
// readers of the same Stream. In particular it discards §10.9 responses, so
// it cannot be combined with Update on the same stream — when a request
// needs both a lifetime keepalive AND updates or follow-up handling, run
// [RequestBroker.Serve] (via the handle's Broker method) instead.
func DrainAndWait(ctx context.Context, s Stream) {
done := make(chan struct{})
go func() {
defer close(done)
_, _ = io.Copy(io.Discard, s)
}()
select {
case <-done:
case <-ctx.Done():
s.CancelRead(uint64(moqt.StreamResetSessionClosed))
<-done
}
}
package session
import (
"context"
"github.com/floatdrop/moq-go/pkg/moqt/message"
"github.com/floatdrop/moq-go/pkg/moqt/track"
)
// Subscription is a live subscriber-initiated track subscription. It owns the
// request stream (embedded, so Close / reads / message.Marshal work directly
// on it) plus the identifiers follow-up traffic needs — the Request ID and the
// publisher-assigned Track Alias — so the caller can send REQUEST_UPDATE via
// [Subscription.Update] without holding them separately. It is returned by
// [Session.Subscribe].
type Subscription struct {
// requestHandle carries the SUBSCRIBE request stream — still open for
// follow-up traffic (REQUEST_UPDATE and inbound PUBLISH_DONE; Close it
// to end the subscription) — and provides Update.
requestHandle
// OK is the parsed SUBSCRIBE_OK response — the publisher-assigned Track
// Alias, negotiated Parameters, and TrackProperties.
OK *message.SubscribeOK
}
// TrackAlias reports the §11.1 Track Alias the publisher assigned to this
// subscription — the integer inbound subgroup and datagram streams carry to
// identify the track (see [Session.AcceptDataStream]). It is shorthand for
// sub.OK.TrackAlias.
func (sub *Subscription) TrackAlias() uint64 { return sub.OK.TrackAlias }
// Subscribe opens a SUBSCRIBE request stream (§10.7) and awaits SUBSCRIBE_OK.
// The session assigns m.RequestID; the caller supplies the rest. On success a
// [Subscription] is returned whose embedded stream stays open for follow-up
// traffic (REQUEST_UPDATE via [Subscription.Update], inbound PUBLISH_DONE) and
// whose [Subscription.TrackAlias] matches the alias on inbound subgroup
// streams. REQUEST_ERROR is surfaced as a *RequestRejectedError and the stream
// is closed.
func (s *Session) Subscribe(ctx context.Context, m *message.Subscribe) (*Subscription, error) {
return awaitRequestResponse(ctx, s, m,
func(stream Stream, ok *message.SubscribeOK) (*Subscription, error) {
// §2.5.1: reject tracks with unknown mandatory track properties.
if err := s.validateTrackProperties(ok.TrackProperties, "SUBSCRIBE_OK"); err != nil {
_ = stream.Close()
return nil, err
}
// §11.1: register the alias the publisher assigned so we can detect
// DUPLICATE_TRACK_ALIAS if the same alias is reused for a different track.
key := track.NewKey(m.Namespace, m.Name)
if err := s.RegisterInboundTrackAlias(ok.TrackAlias, key); err != nil {
_ = stream.Close()
return nil, err
}
return &Subscription{
Stream: stream,
s: s,
requestID: m.RequestID,
OK: ok,
}, nil
})
}
package session
import (
"fmt"
"sync"
"github.com/floatdrop/moq-go/pkg/moqt"
)
// tokenCacheEntry holds the resolved (type, value) for a registered alias.
type tokenCacheEntry struct {
tokenType uint64
value []byte
size uint64 // 16 + len(value), per §10.3.1.3
}
// TokenCache is a per-session, per-direction alias cache per §10.2.2.
//
// Client and server each maintain independent caches (separate alias spaces).
// The cache is thread-safe.
//
// Cache size accounting per §10.3.1.3:
// - Token size = 16 bytes + len(TokenValue)
// - Total = Σ(registered token sizes) − Σ(deregistered token sizes)
// - maxSize = 0 prohibits alias registration (default when MAX_AUTH_TOKEN_CACHE_SIZE
// is not negotiated in SETUP)
type TokenCache struct {
mu sync.Mutex
maxSize uint64 // 0 = aliases prohibited
used uint64 // current total size
entries map[uint64]*tokenCacheEntry
}
// NewTokenCache creates a cache with the given maximum byte size.
// maxSize=0 prohibits alias registration (the default per §10.3.1.3).
func NewTokenCache(maxSize uint64) *TokenCache {
return &TokenCache{
maxSize: maxSize,
entries: make(map[uint64]*tokenCacheEntry),
}
}
// Register adds alias → (tokenType, value) to the cache per §10.2.2 REGISTER.
//
// Returns:
// - moqt.SessionDuplicateAuthTokenAlias if alias is already registered.
// - moqt.SessionAuthTokenCacheOverflow if adding would exceed maxSize.
//
// Per §10.2.2: even if the message fails for other reasons, a REGISTER that
// does not cause a session error MUST be stored. The caller is responsible for
// applying that rule (i.e. call Register before validating the message).
func (c *TokenCache) Register(alias, tokenType uint64, value []byte) error {
size := uint64(16) + uint64(len(value))
c.mu.Lock()
defer c.mu.Unlock()
if _, exists := c.entries[alias]; exists {
return fmt.Errorf("moqt/session: token alias %d already registered (%w)",
alias, sessionErr(moqt.SessionDuplicateAuthTokenAlias))
}
if c.maxSize == 0 {
// Aliases are prohibited when MAX_AUTH_TOKEN_CACHE_SIZE was not negotiated.
return fmt.Errorf("moqt/session: token alias registration prohibited (maxSize=0) (%w)",
sessionErr(moqt.SessionAuthTokenCacheOverflow))
}
if c.used+size > c.maxSize {
return fmt.Errorf("moqt/session: token cache overflow (used=%d size=%d max=%d) (%w)",
c.used, size, c.maxSize, sessionErr(moqt.SessionAuthTokenCacheOverflow))
}
valueCopy := make([]byte, len(value))
copy(valueCopy, value)
c.entries[alias] = &tokenCacheEntry{
tokenType: tokenType,
value: valueCopy,
size: size,
}
c.used += size
return nil
}
// Delete removes alias from the cache per §10.2.2 DELETE.
// Returns moqt.SessionUnknownAuthTokenAlias if alias is not registered.
func (c *TokenCache) Delete(alias uint64) error {
c.mu.Lock()
defer c.mu.Unlock()
entry, exists := c.entries[alias]
if !exists {
return fmt.Errorf("moqt/session: token alias %d not registered (%w)",
alias, sessionErr(moqt.SessionUnknownAuthTokenAlias))
}
c.used -= entry.size
delete(c.entries, alias)
return nil
}
// Resolve returns the (tokenType, value) for alias per §10.2.2 USE_ALIAS.
// Returns moqt.SessionUnknownAuthTokenAlias if alias is not registered.
// The returned value slice is a copy owned by the caller.
func (c *TokenCache) Resolve(alias uint64) (tokenType uint64, value []byte, err error) {
c.mu.Lock()
defer c.mu.Unlock()
entry, exists := c.entries[alias]
if !exists {
return 0, nil, fmt.Errorf("moqt/session: token alias %d not registered (%w)",
alias, sessionErr(moqt.SessionUnknownAuthTokenAlias))
}
out := make([]byte, len(entry.value))
copy(out, entry.value)
return entry.tokenType, out, nil
}
// Size returns the current total cache size in bytes.
func (c *TokenCache) Size() uint64 {
c.mu.Lock()
defer c.mu.Unlock()
return c.used
}
// MaxSize returns the configured maximum cache size.
func (c *TokenCache) MaxSize() uint64 {
return c.maxSize // immutable after construction; no lock needed
}
// sessionErr wraps a SessionErrorCode as an error so callers can use errors.Is
// to identify the specific session-level error to signal.
type sessionErrCode moqt.SessionErrorCode
func (e sessionErrCode) Error() string {
return fmt.Sprintf("session error code 0x%X", uint64(e))
}
func (e sessionErrCode) Is(target error) bool {
t, ok := target.(sessionErrCode)
return ok && t == e
}
func sessionErr(code moqt.SessionErrorCode) error {
return sessionErrCode(code)
}
package session
import (
"context"
"errors"
"fmt"
"github.com/floatdrop/moq-go/pkg/moqt"
"github.com/floatdrop/moq-go/pkg/moqt/message"
)
// ResolvedToken is a fully-resolved AUTHORIZATION_TOKEN (§10.2.2): the
// (Token Type, Token Value) pair an application policy needs to make an
// authorization decision, with all alias indirection already removed.
//
// The session produces a ResolvedToken for every REGISTER, USE_ALIAS, and
// USE_VALUE token on an inbound request (DELETE tokens carry no value and so
// produce none). USE_ALIAS tokens are resolved against the inbound TokenCache
// before the value is exposed, so a verifier never sees a bare alias.
//
// Value is owned by the caller (a fresh copy per resolution); mutating it does
// not affect the cache.
type ResolvedToken struct {
// Type is the Token Type (§10.2.2) — an application-defined identifier
// of the token scheme (e.g. a registry entry for a CAT or JWT profile).
Type uint64
// Value is the raw, opaque Token Value. Its interpretation is entirely
// up to the TokenVerifier; the transport treats it as bytes (§13.3).
Value []byte
}
// TokenVerifier is the application policy that authorizes resolved
// authorization tokens. The session invokes VerifyToken once per
// ResolvedToken carried by an inbound request, after the token has been
// resolved against the inbound cache.
//
// The transport deliberately defines no token format (§13.3); a verifier is
// where signature checking, expiry, audience, and scope validation live.
//
// Returning nil authorizes the token. Returning a non-nil error denies the
// request the token accompanied: wrap the error with [*TokenDeniedError] (or
// use [DenyToken]) to choose the REQUEST_ERROR code the peer receives —
// notably [moqt.RequestExpiredAuthToken] for an expired token per §10.2.2. A
// plain error denies with [moqt.RequestUnauthorized].
//
// VerifyToken must be safe for concurrent use: requests on a session are
// dispatched concurrently, so multiple goroutines may call it at once.
type TokenVerifier interface {
VerifyToken(ctx context.Context, sess *Session, tok ResolvedToken) error
}
// TokenVerifierFunc adapts an ordinary function to the [TokenVerifier]
// interface, so a policy can be supplied inline without a named type.
type TokenVerifierFunc func(ctx context.Context, sess *Session, tok ResolvedToken) error
// VerifyToken calls f.
func (f TokenVerifierFunc) VerifyToken(ctx context.Context, sess *Session, tok ResolvedToken) error {
return f(ctx, sess, tok)
}
// TokenCacheError is returned by [Session.AcceptRequest] when processing an
// inbound request's AUTHORIZATION_TOKEN parameters fails at the cache layer
// (§10.2.2). These are session-level faults: a malformed token, a duplicate
// REGISTER alias, a cache overflow, or a USE_ALIAS / DELETE referencing an
// unknown alias. Code is the SESSION_ERROR the caller should close the
// session with.
type TokenCacheError struct {
// Code is the §10.2.2 SESSION_ERROR code to terminate the session with.
Code moqt.SessionErrorCode
// Err is the underlying cache or parse error, for diagnostics.
Err error
}
// Error implements the error interface.
func (e *TokenCacheError) Error() string {
return fmt.Sprintf("moqt/session: token cache error (session code 0x%X): %v", uint64(e.Code), e.Err)
}
// Unwrap exposes the underlying error for errors.Is / errors.As.
func (e *TokenCacheError) Unwrap() error { return e.Err }
// TokenDeniedError is returned by token verification to deny a single request
// with an explicit MoQT REQUEST_ERROR code. Unlike [TokenCacheError] it is a
// per-request rejection, not a session-level fault: the caller should reply
// REQUEST_ERROR and leave the session running.
//
// Code MUST be one of the §10.6 REQUEST_ERROR codes (see
// [moqt.RequestErrorCode]); the zero value collapses to
// [moqt.RequestUnauthorized].
type TokenDeniedError struct {
// Code is the REQUEST_ERROR code to send. Zero ⇒ RequestUnauthorized.
Code moqt.RequestErrorCode
// Reason is the human-readable reason forwarded to the peer.
Reason string
// Err is the underlying verifier error, for diagnostics. Optional.
Err error
}
// Error implements the error interface.
func (e *TokenDeniedError) Error() string {
if e.Reason != "" {
return fmt.Sprintf("moqt/session: token denied (request code 0x%X): %s", uint64(e.RequestErrorCode()), e.Reason)
}
return fmt.Sprintf("moqt/session: token denied (request code 0x%X)", uint64(e.RequestErrorCode()))
}
// Unwrap exposes the underlying verifier error for errors.Is / errors.As.
func (e *TokenDeniedError) Unwrap() error { return e.Err }
// RequestErrorCode returns the REQUEST_ERROR code to send, substituting
// [moqt.RequestUnauthorized] for the zero value.
func (e *TokenDeniedError) RequestErrorCode() moqt.RequestErrorCode {
if e.Code == 0 {
return moqt.RequestUnauthorized
}
return e.Code
}
// DenyToken constructs a [*TokenDeniedError]. Use it from a [TokenVerifier]
// to reject a request with a specific REQUEST_ERROR code:
//
// return session.DenyToken(moqt.RequestExpiredAuthToken, "token expired")
func DenyToken(code moqt.RequestErrorCode, reason string) error {
return &TokenDeniedError{Code: code, Reason: reason}
}
// TokenCache returns the session's inbound authorization-token alias cache
// (§10.2.2). It is primarily exposed for inspection and tests; the session
// drives Register / Resolve / Delete on it automatically from inbound request
// parameters in [Session.AcceptRequest]. Always non-nil.
func (s *Session) TokenCache() *TokenCache { return s.tokenCache }
// processRequestTokens parses the AUTHORIZATION_TOKEN parameters of msg and
// applies each token to the inbound cache per §10.2.2, returning the resolved
// (Type, Value) tokens for any REGISTER / USE_ALIAS / USE_VALUE entries.
//
// Processing order matters: a REGISTER is committed to the cache immediately,
// honouring the §10.2.2 MUST that "an Alias which is registered ... MUST be
// added to the cache even if the message fails for some other reason." Because
// the cache mutation happens here — before the request is validated or
// authorized — a later rejection of the request does not roll the alias back.
//
// A cache-layer failure (malformed token, duplicate alias, overflow, unknown
// alias) is returned as a [*TokenCacheError] carrying the session-level
// SESSION_ERROR code the caller must close the session with.
func (s *Session) processRequestTokens(msg message.Message) ([]ResolvedToken, error) {
ps, ok := messageParameters(msg)
if !ok {
return nil, nil
}
tokens, err := message.TokensFromParam(ps)
if err != nil {
return nil, &TokenCacheError{Code: moqt.SessionMalformedAuthToken, Err: err}
}
if len(tokens) == 0 {
return nil, nil
}
var resolved []ResolvedToken
for i := range tokens {
t := &tokens[i]
switch t.AliasType {
case message.AliasTypeRegister:
// §10.2.2: register before any further validation so the
// alias persists even if the request is later rejected.
if err := s.tokenCache.Register(t.TokenAlias, t.TokenType, t.TokenValue); err != nil {
return nil, &TokenCacheError{Code: sessionCodeForCacheErr(err), Err: err}
}
resolved = append(resolved, ResolvedToken{
Type: t.TokenType,
Value: append([]byte(nil), t.TokenValue...),
})
case message.AliasTypeUseAlias:
typ, val, err := s.tokenCache.Resolve(t.TokenAlias)
if err != nil {
return nil, &TokenCacheError{Code: sessionCodeForCacheErr(err), Err: err}
}
resolved = append(resolved, ResolvedToken{Type: typ, Value: val})
case message.AliasTypeUseValue:
resolved = append(resolved, ResolvedToken{
Type: t.TokenType,
Value: append([]byte(nil), t.TokenValue...),
})
case message.AliasTypeDelete:
if err := s.tokenCache.Delete(t.TokenAlias); err != nil {
return nil, &TokenCacheError{Code: sessionCodeForCacheErr(err), Err: err}
}
default:
return nil, &TokenCacheError{
Code: moqt.SessionMalformedAuthToken,
Err: fmt.Errorf("unknown token alias type 0x%X", uint64(t.AliasType)),
}
}
}
return resolved, nil
}
// VerifyRequestTokens runs the configured [TokenVerifier] over the tokens the
// session resolved for req (see [Request.Tokens]). It returns nil when no
// verifier is configured or every token is authorized, and a
// [*TokenDeniedError] (mappable to a REQUEST_ERROR) for the first denial.
//
// The relay calls this before dispatching a request; standalone session users
// can call it from their own request loop. It is safe to call with a req whose
// Tokens slice is empty.
func (s *Session) VerifyRequestTokens(ctx context.Context, req *Request) error {
if s.tokenVerifier == nil || len(req.Tokens) == 0 {
return nil
}
for _, tok := range req.Tokens {
if err := s.tokenVerifier.VerifyToken(ctx, s, tok); err != nil {
if denied, ok := errors.AsType[*TokenDeniedError](err); ok {
return denied
}
return &TokenDeniedError{Code: moqt.RequestUnauthorized, Reason: err.Error(), Err: err}
}
}
return nil
}
// sessionCodeForCacheErr maps a [TokenCache] error to its §10.2.2 SESSION_ERROR
// code. The cache wraps a sentinel via sessionErr, so errors.Is identifies
// which one. An unrecognised error defaults to MALFORMED_AUTH_TOKEN.
func sessionCodeForCacheErr(err error) moqt.SessionErrorCode {
for _, c := range []moqt.SessionErrorCode{
moqt.SessionDuplicateAuthTokenAlias,
moqt.SessionAuthTokenCacheOverflow,
moqt.SessionUnknownAuthTokenAlias,
} {
if errors.Is(err, sessionErr(c)) {
return c
}
}
return moqt.SessionMalformedAuthToken
}
// messageParameters returns the Parameters block of msg for the request
// message types that may carry AUTHORIZATION_TOKEN (§10.2.2). The second
// return is false for message types that have no Parameters block, so the
// caller can skip token processing entirely.
func messageParameters(msg message.Message) (message.Parameters, bool) {
switch m := msg.(type) {
case *message.Subscribe:
return m.Parameters, true
case *message.Publish:
return m.Parameters, true
case *message.Fetch:
return m.Parameters, true
case *message.TrackStatus:
return m.Parameters, true
case *message.PublishNamespace:
return m.Parameters, true
case *message.SubscribeNamespace:
return m.Parameters, true
case *message.SubscribeTracks:
return m.Parameters, true
case *message.RequestUpdate:
return m.Parameters, true
}
return nil, false
}
// ProcessFollowupTokens resolves the AUTHORIZATION_TOKEN parameters (§10.2.2)
// of a follow-up message read off an established request stream — §10.2.2
// explicitly allows tokens on REQUEST_UPDATE, and a REGISTER alias "MUST be
// added to the cache even if the message fails for some other reason".
// AcceptRequest performs the same processing for a stream's FIRST message;
// any code that reads follow-ups directly (message.Parse on the stream) MUST
// route messages carrying parameters through here, or the peer's view of the
// token cache silently diverges and its next USE_ALIAS kills the session
// with UNKNOWN_AUTH_TOKEN_ALIAS.
//
// The error contract matches AcceptRequest: a *TokenCacheError carries the
// SESSION_ERROR code the caller must close the session with. Messages
// without parameters (or without token parameters) return (nil, nil).
func (s *Session) ProcessFollowupTokens(msg message.Message) ([]ResolvedToken, error) {
return s.processRequestTokens(msg)
}
package session
import (
"fmt"
"github.com/floatdrop/moq-go/pkg/moqt/message"
"github.com/floatdrop/moq-go/pkg/moqt/wire"
)
// ErrUnsupportedMandatoryTrackProperty is returned when Track Properties
// (received in SUBSCRIBE_OK, FETCH_OK, TRACK_STATUS_OK, or an inbound
// PUBLISH) contain a Mandatory Track Property (range 0x4000–0x7FFF per
// §2.5.1) that this endpoint does not understand. The caller MUST NOT
// process or forward the track.
//
// For outbound requests (Subscribe, Fetch, TrackStatus) the session layer
// returns this error directly. For inbound PUBLISH the caller should use
// ValidateTrackProperties and, on error, reply with REQUEST_ERROR /
// UNSUPPORTED_EXTENSION via Request.RejectError.
type ErrUnsupportedMandatoryTrackProperty struct {
// PropertyType is the first unrecognised mandatory property type found.
PropertyType message.PropertyType
// Context describes where the property was encountered (e.g.
// "SUBSCRIBE_OK", "FETCH_OK", "PUBLISH").
Context string
}
func (e *ErrUnsupportedMandatoryTrackProperty) Error() string {
return fmt.Sprintf(
"moqt/session: unsupported mandatory track property 0x%X in %s (§2.5.1 — UNSUPPORTED_EXTENSION)",
e.PropertyType, e.Context,
)
}
// ValidateTrackProperties parses raw Track Properties bytes and checks for
// unknown Mandatory Track Properties (range 0x4000–0x7FFF per §2.5.1).
//
// knownMandatory is the set of Mandatory Track Property types this endpoint
// supports. Every mandatory property found in raw that is not in this set
// causes *ErrUnsupportedMandatoryTrackProperty to be returned. An empty
// (non-nil) map means "I support no mandatory extensions" — any mandatory
// property will be rejected.
//
// Returns the parsed pairs on success. context is used in the error message
// to identify the source message (e.g. "SUBSCRIBE_OK").
func ValidateTrackProperties(
raw []byte,
knownMandatory map[message.PropertyType]struct{},
context string,
) ([]wire.KVPair, error) {
pairs, err := message.ParseTrackProperties(raw)
if err != nil {
return nil, fmt.Errorf("moqt/session: parsing track properties in %s: %w", context, err)
}
if typ, unknown := message.FirstUnknownMandatoryTrackProperty(pairs, knownMandatory); unknown {
return nil, &ErrUnsupportedMandatoryTrackProperty{
PropertyType: typ,
Context: context,
}
}
return pairs, nil
}
// validateTrackProperties is a session-level convenience that uses the
// session's configured set of known mandatory track property types.
//
// If WithKnownMandatoryTrackProperties was never called (the map is nil),
// the check is skipped entirely — this is the default for relays and other
// forwarding endpoints that pass Track Properties through opaquely. End
// subscribers that need to interpret track data should call
// WithKnownMandatoryTrackProperties (even with an empty map) to opt in to
// enforcement.
func (s *Session) validateTrackProperties(raw []byte, context string) error {
if s.knownMandatoryTrackProperties == nil {
return nil // not configured — skip enforcement
}
_, err := ValidateTrackProperties(raw, s.knownMandatoryTrackProperties, context)
return err
}
package session
import (
"context"
"fmt"
"github.com/floatdrop/moq-go/pkg/moqt/message"
)
// TrackStatusRequest is an established TRACK_STATUS request (§10.15). It owns
// the request stream (embedded, so Close / writes / message.Marshal work
// directly on it) plus the Request ID follow-up traffic needs, so the caller can
// send REQUEST_UPDATE via [TrackStatusRequest.Update] without holding it
// separately. It carries the peer's TRACK_STATUS_OK and is returned by
// [Session.TrackStatus].
type TrackStatusRequest struct {
// requestHandle carries the TRACK_STATUS request stream — still open
// for REQUEST_UPDATE follow-ups (Close it to end the request) — and
// provides Update.
requestHandle
// OK is the TRACK_STATUS_OK the peer replied with.
OK *message.TrackStatusOK
}
// AcceptTrackStatus accepts an inbound TRACK_STATUS (§10.15) and replies
// TRACK_STATUS_OK with the given status fields — the accept-side counterpart of
// [Session.TrackStatus]. r.First MUST be a *message.TrackStatus.
//
// ok carries the TRACK_STATUS_OK fields (status, largest location, Track
// Properties — [message.TrackStatusOK] is an alias of [message.RequestOK]); it
// may be nil for the all-default reply. Unlike the other Accept* helpers,
// TRACK_STATUS is a one-shot status query with no object-push or follow-up
// stream, so no handle is returned; use [Request.Stream] directly to service a
// later REQUEST_UPDATE.
func (r *Request) AcceptTrackStatus(ok *message.TrackStatusOK) error {
if _, isTS := r.First.(*message.TrackStatus); !isTS {
return fmt.Errorf("moqt/session: AcceptTrackStatus on a %s request", r.First.Type())
}
if ok == nil {
ok = &message.TrackStatusOK{}
}
if err := message.Marshal(r.Stream, ok); err != nil {
return fmt.Errorf("moqt/session: write TRACK_STATUS_OK: %w", err)
}
return nil
}
// TrackStatus opens a TRACK_STATUS request stream (§10.15) and awaits
// REQUEST_OK (TRACK_STATUS_OK) or REQUEST_ERROR. The session assigns
// m.RequestID; the caller supplies Namespace, Name, and optional Parameters.
//
// On success a [TrackStatusRequest] is returned whose embedded stream stays
// open (the caller may send REQUEST_UPDATE via [TrackStatusRequest.Update]) and
// whose OK holds the parsed TRACK_STATUS_OK. On REQUEST_ERROR the stream is
// closed and a *RequestRejectedError is returned.
func (s *Session) TrackStatus(ctx context.Context, m *message.TrackStatus) (*TrackStatusRequest, error) {
return awaitRequestResponse(ctx, s, m,
func(stream Stream, ok *message.RequestOK) (*TrackStatusRequest, error) {
// §2.5.1: reject tracks with unknown mandatory track properties.
// TRACK_STATUS_OK carries the same Track Properties as SUBSCRIBE_OK.
if err := s.validateTrackProperties(ok.TrackProperties, "TRACK_STATUS_OK"); err != nil {
_ = stream.Close()
return nil, err
}
return &TrackStatusRequest{
Stream: stream,
s: s,
requestID: m.RequestID,
OK: ok,
}, nil
})
}
package session
import (
"fmt"
"github.com/floatdrop/moq-go/pkg/moqt/track"
)
// AllocOutboundTrackAlias returns the next Track Alias to use when this side
// advertises a new track to the peer (§11.1). Aliases are independent across
// sessions, so callers must remap when forwarding between two sessions.
//
// Allocation starts at 1, never 0: [Session.Publish], [Session.OpenPublish],
// and the SUBSCRIBE_OK reply path treat a zero TrackAlias as "unset, allocate
// one for me". If this allocator returned 0, a caller that did the natural
// "alias := AllocOutboundTrackAlias(); Publish(&Publish{TrackAlias: alias})"
// would have its 0 silently re-allocated to a different value — and any data
// stream the caller then opened under the original 0 would carry an alias the
// peer never bound to the track (the relay drops it as an unknown alias). So 0
// is reserved as the sentinel and never handed out.
func (s *Session) AllocOutboundTrackAlias() uint64 {
return s.nextOutboundTrackAlias.Add(1)
}
// ErrDuplicateTrackAlias is returned by RegisterInboundTrackAlias when the
// peer assigns a Track Alias that is already in use for a different track
// (§11.1). The caller MUST close the session with SessionDuplicateTrackAlias.
type ErrDuplicateTrackAlias struct {
Alias uint64
Existing track.Key
New track.Key
}
func (e *ErrDuplicateTrackAlias) Error() string {
return fmt.Sprintf(
"moqt/session: Track Alias %d already in use for a different track — DUPLICATE_TRACK_ALIAS",
e.Alias,
)
}
// RegisterInboundTrackAlias records that the peer has assigned alias to the
// track identified by key. This MUST be called by the subscriber when it
// receives a SUBSCRIBE_OK (whose TrackAlias field is the alias) and by the
// server when it receives a PUBLISH (whose TrackAlias field is the alias).
//
// If alias is already registered for the same track (idempotent re-registration),
// nil is returned. If alias is already registered for a different track,
// *ErrDuplicateTrackAlias is returned and the caller MUST close the session
// with SessionDuplicateTrackAlias (§11.1).
func (s *Session) RegisterInboundTrackAlias(alias uint64, key track.Key) error {
s.mu.Lock()
defer s.mu.Unlock()
if existing, ok := s.inboundAliases[alias]; ok {
if existing != key {
return &ErrDuplicateTrackAlias{Alias: alias, Existing: existing, New: key}
}
return nil // idempotent
}
s.inboundAliases[alias] = key
return nil
}
// UnregisterInboundTrackAlias removes a previously registered alias, freeing
// it for potential reuse. Callers should invoke this when the subscription or
// publication associated with alias has been fully torn down (e.g. after
// PUBLISH_DONE or subscription cancellation and a suitable grace period per
// §11.1: "Subscribers SHOULD retain sufficient state to quickly discard
// unwanted Objects").
//
// Unregistering an alias that was never registered is a no-op.
func (s *Session) UnregisterInboundTrackAlias(alias uint64) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.inboundAliases, alias)
}
// LookupInboundTrackAlias returns the track.Key bound to alias by an earlier
// [Session.RegisterInboundTrackAlias] call, or (zero, false) if the alias is
// not currently registered.
//
// This is the recipient-side companion of [Session.RegisterInboundTrackAlias].
// Inbound data streams (SUBGROUP_HEADER, ObjectDatagram, FETCH_HEADER objects)
// identify their track by the alias the publisher chose; consumers — most
// notably the relay's fanout and end-subscriber applications — use this
// method to recover the canonical track identity for routing or rendering.
func (s *Session) LookupInboundTrackAlias(alias uint64) (track.Key, bool) {
s.mu.Lock()
defer s.mu.Unlock()
key, ok := s.inboundAliases[alias]
return key, ok
}
// Package wtconn adapts github.com/quic-go/webtransport-go's *webtransport.Session
// to the transport-neutral session.Conn interface.
//
// This is the WebTransport counterpart of the quicconn package. It is the sole
// boundary in the moqt tree where webtransport-go's concrete types meet the
// session abstraction. Putting it in a dedicated subpackage lets the rest of
// pkg/moqt (and its tests) stay independent of webtransport-go's surface.
//
// webtransport-go uses webtransport.StreamErrorCode (uint32) for stream-level
// error codes and webtransport.SessionErrorCode (uint32) for session-level
// error codes; session.Conn / SendStream / ReceiveStream use plain uint64.
// The wrappers below do the narrowing conversion at each call site. MoQT
// error codes fit comfortably in 32 bits (the largest defined code is 0x34),
// so no information is lost in practice.
package wtconn
import (
"context"
"errors"
"log/slog"
"net"
"net/http"
"sync"
"github.com/quic-go/quic-go"
"github.com/quic-go/webtransport-go"
"github.com/floatdrop/moq-go/pkg/moqt/session"
)
// New wraps s so it satisfies session.Conn.
func New(s *webtransport.Session) session.Conn { return &conn{s: s} }
// Compile-time satisfaction check.
var _ session.Conn = (*conn)(nil)
// conn holds a *webtransport.Session by named field rather than embedding.
// Embedding would promote webtransport's CloseWithError(SessionErrorCode, string)
// onto the wrapper; the session.Conn interface demands CloseWithError(uint64, string).
// Two methods of the same name with different signatures aren't allowed on a
// single Go type, so we delegate explicitly.
type conn struct{ s *webtransport.Session }
func (c *conn) OpenUniStream() (session.SendStream, error) {
s, err := c.s.OpenUniStream()
if err != nil {
if _, ok := errors.AsType[*quic.StreamLimitReachedError](err); ok {
return nil, session.ErrNoStreamCredit
}
return nil, err
}
return &sendStream{s: s}, nil
}
func (c *conn) AcceptUniStream(ctx context.Context) (session.ReceiveStream, error) {
s, err := c.s.AcceptUniStream(ctx)
if err != nil {
return nil, err
}
return &recvStream{s: s}, nil
}
// OpenStream opens a bidirectional stream without blocking. webtransport-go's
// OpenStream delegates to the underlying *quic.Conn, so an exhausted stream
// limit surfaces as a *quic.StreamLimitReachedError; we map that onto
// session.ErrNoStreamCredit for transport-neutral detection.
func (c *conn) OpenStream() (session.Stream, error) {
s, err := c.s.OpenStream()
if err != nil {
if _, ok := errors.AsType[*quic.StreamLimitReachedError](err); ok {
return nil, session.ErrNoStreamCredit
}
return nil, err
}
return &bidiStream{s: s}, nil
}
func (c *conn) AcceptStream(ctx context.Context) (session.Stream, error) {
s, err := c.s.AcceptStream(ctx)
if err != nil {
return nil, err
}
return &bidiStream{s: s}, nil
}
func (c *conn) CloseWithError(code uint64, reason string) error {
//nolint:gosec // G115: MoQT session error codes fit uint32 (WebTransport's error-code width).
return c.s.CloseWithError(webtransport.SessionErrorCode(code), reason)
}
func (c *conn) Context() context.Context { return c.s.Context() }
// IsWebTransport reports that this Conn runs over WebTransport, which
// §10.3.1.1 and §10.3.1.2 make relevant: the PATH and AUTHORITY setup options
// MUST NOT be used on a WebTransport session, since HTTP/3 already carries that
// information in the CONNECT request. The session layer asserts for this
// method to refuse those options before sending them.
//
// Not part of [session.Conn] on purpose — one rule needs it, and putting it on
// the interface would tax every adapter, third-party ones included, for a check
// the QUIC adapters answer trivially.
func (c *conn) IsWebTransport() bool { return true }
func (c *conn) SendDatagram(payload []byte) error {
return c.s.SendDatagram(payload)
}
func (c *conn) ReceiveDatagram(ctx context.Context) ([]byte, error) {
return c.s.ReceiveDatagram(ctx)
}
// sendStream wraps *webtransport.SendStream. Named field for the same reason
// as conn.
type sendStream struct{ s *webtransport.SendStream }
func (s *sendStream) Write(p []byte) (int, error) { return s.s.Write(p) }
func (s *sendStream) Close() error { return s.s.Close() }
func (s *sendStream) CancelWrite(code uint64) {
//nolint:gosec // G115: MoQT stream error codes fit uint32 (WebTransport's error-code width).
s.s.CancelWrite(webtransport.StreamErrorCode(code))
}
// Context is cancelled when all data has been acknowledged by the peer or
// the stream is reset. webtransport-go's SendStream.Context() provides this
// directly.
func (s *sendStream) Context() context.Context { return s.s.Context() }
// recvStream wraps *webtransport.ReceiveStream.
type recvStream struct{ s *webtransport.ReceiveStream }
func (s *recvStream) Read(p []byte) (int, error) { return s.s.Read(p) }
func (s *recvStream) CancelRead(code uint64) {
//nolint:gosec // G115: MoQT stream error codes fit uint32 (WebTransport's error-code width).
s.s.CancelRead(webtransport.StreamErrorCode(code))
}
// bidiStream wraps *webtransport.Stream.
type bidiStream struct{ s *webtransport.Stream }
func (s *bidiStream) Read(p []byte) (int, error) { return s.s.Read(p) }
func (s *bidiStream) Write(p []byte) (int, error) { return s.s.Write(p) }
func (s *bidiStream) Close() error { return s.s.Close() }
func (s *bidiStream) CancelRead(code uint64) {
//nolint:gosec // G115: MoQT stream error codes fit uint32 (WebTransport's error-code width).
s.s.CancelRead(webtransport.StreamErrorCode(code))
}
func (s *bidiStream) CancelWrite(code uint64) {
//nolint:gosec // G115: MoQT stream error codes fit uint32 (WebTransport's error-code width).
s.s.CancelWrite(webtransport.StreamErrorCode(code))
}
// Context is cancelled when all data has been acknowledged or the stream is
// reset. webtransport-go's Stream embeds SendStream which has Context().
func (s *bidiStream) Context() context.Context { return s.s.Context() }
// defaultBacklog bounds the pending-session queue used by [Listener].
// A small queue absorbs handler-invocation bursts while the relay accept
// loop catches up.
const defaultBacklog = 16
// Listener adapts a *webtransport.Server so it can be handed directly
// to the relay's accept loop. WebTransport sessions arrive via HTTP/3
// handler invocations, not via a synchronous Accept on a socket, so
// the listener registers a path handler on the caller's *http.ServeMux
// and bridges accepted sessions through a buffered channel.
//
// The caller owns:
//
// - The *webtransport.Server (typically constructed with
// [webtransport.ConfigureHTTP3Server] on the underlying
// *http3.Server).
// - The HTTP/3 server's lifecycle: ListenAndServe / Serve on the
// desired socket, and Close on shutdown. [Listener.Close] only
// stops Accept from yielding new sessions so the relay's accept
// loop unwinds — it does NOT close the *webtransport.Server.
//
// The Listener type satisfies relay.Listener structurally without
// importing pkg/relay.
type Listener struct {
server *webtransport.Server
addr net.Addr
queue chan session.Conn
closeOnce sync.Once
done chan struct{}
}
// NewListener registers a WebTransport upgrade handler at path on mux
// and returns a Listener suitable for relay.New.
//
// - server: the configured *webtransport.Server. Upgrade is called
// on this server for every inbound request that hits path.
// - mux: the HTTP/3 server's request mux. The Listener does NOT
// mount its own mux so the caller can multiplex MOQT-over-
// WebTransport with other HTTP/3 endpoints on the same server.
// - path: the HTTP/3 path the WebTransport CONNECT must target
// (e.g. "/moq").
// - addr: the address the Listener reports via [Listener.Addr];
// pass nil if you don't need it (the relay only uses it for
// log lines).
// - queueSize: bounds the pending-session backlog before the
// upgrade handler starts dropping sessions on the floor. Pass
// 0 for the package default ([defaultBacklog]).
//
// Typical wiring:
//
// h3 := &http3.Server{TLSConfig: tlsCfg}
// webtransport.ConfigureHTTP3Server(h3)
// wts := &webtransport.Server{H3: h3, CheckOrigin: …}
// mux := http.NewServeMux()
// udpConn, _ := net.ListenPacket("udp", ":4433")
// wts.H3.Handler = mux
//
// listener := wtconn.NewListener(wts, mux, "/moq", udpConn.LocalAddr(), 0)
// r := relay.New(listener, relay.Config{ … })
//
// go wts.Serve(udpConn)
// go r.Start(ctx)
func NewListener(
server *webtransport.Server,
mux *http.ServeMux,
path string,
addr net.Addr,
queueSize int,
) *Listener {
if queueSize <= 0 {
queueSize = defaultBacklog
}
l := &Listener{
server: server,
addr: addr,
queue: make(chan session.Conn, queueSize),
done: make(chan struct{}),
}
mux.HandleFunc(path, l.upgrade)
return l
}
// upgrade is the HTTP/3 handler the Listener registers on the mux.
// It performs the WebTransport upgrade and hands the resulting
// *webtransport.Session to Accept via the bounded queue. If Accept is
// not draining (closed Listener or a burst exceeding queueSize), the
// freshly-accepted session is closed immediately so the client sees
// the failure rather than hanging.
func (l *Listener) upgrade(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
slog.DebugContext(ctx, "wtconn: upgrade request",
"remote", r.RemoteAddr, "method", r.Method, "path", r.URL.Path,
"proto", r.Proto, "origin", r.Header.Get("Origin"))
sess, err := l.server.Upgrade(w, r)
if err != nil {
slog.DebugContext(ctx, "wtconn: upgrade failed", "remote", r.RemoteAddr, "err", err)
return
}
slog.DebugContext(ctx, "wtconn: upgrade ok",
"remote", r.RemoteAddr, "wt_protocol", sess.SessionState().ApplicationProtocol)
select {
case l.queue <- New(sess):
case <-l.done:
_ = sess.CloseWithError(0, "listener closed")
default:
slog.WarnContext(ctx, "wtconn: dropping session: accept backlog full", "remote", r.RemoteAddr)
_ = sess.CloseWithError(0, "accept backlog full")
}
}
// Accept blocks until the next upgraded WebTransport session arrives,
// then returns it as a session.Conn. ctx cancellation and Close both
// unblock Accept.
func (l *Listener) Accept(ctx context.Context) (session.Conn, error) {
select {
case c := <-l.queue:
return c, nil
case <-ctx.Done():
return nil, ctx.Err()
case <-l.done:
return nil, net.ErrClosed
}
}
// Addr returns the address the caller passed to NewListener, or nil
// if none was provided.
func (l *Listener) Addr() net.Addr { return l.addr }
// Close signals the Listener to stop yielding new sessions. The
// underlying *webtransport.Server keeps running; closing it is the
// caller's responsibility.
//
// Close is idempotent. Subsequent Accept calls return [net.ErrClosed].
func (l *Listener) Close() error {
l.closeOnce.Do(func() { close(l.done) })
return nil
}
// Package track provides domain types for MoQT track identification per
// §2.4.1 of draft-ietf-moq-transport: Full Track Name and a comparable Key
// derived from it for use as a Go map key.
package track
import (
"encoding/binary"
"github.com/floatdrop/moq-go/pkg/moqt/wire"
)
// FullTrackName identifies a single track. The slice fields make the value
// type non-comparable; use Key for map indexing or exact-equality checks.
type FullTrackName struct {
Namespace wire.TrackNamespace
Name []byte
}
// Key is a canonical, comparable representation of a Full Track Name. The
// namespace is wire-encoded (length-prefixed tuples per §2.4.1) so distinct
// tuple lists never collide — e.g. namespace ("a","b") with name "c" and
// namespace ("a") with name "bc" map to different Keys even though a naive
// byte concatenation would tie them.
type Key struct {
namespace string // wire.TrackNamespace bytes, as string for comparability
name string
}
// Key returns the canonical map-friendly representation.
func (n FullTrackName) Key() Key {
w := wire.NewWriter(nil)
w.TrackNamespace(n.Namespace)
return Key{namespace: string(w.Bytes()), name: string(n.Name)}
}
// NewKey is a convenience for callers that already have the namespace + name
// as separate values (e.g. fields parsed off a SUBSCRIBE / PUBLISH message).
func NewKey(ns wire.TrackNamespace, name []byte) Key {
return FullTrackName{Namespace: ns, Name: name}.Key()
}
// Bytes returns the canonical binary encoding of the Key: an unsigned varint
// length prefix on the wire-encoded namespace, followed by the namespace bytes
// and then the track name. The length prefix keeps the (namespace, name)
// boundary unambiguous, so distinct splits never collide — the same guarantee
// the [Key] struct gives as a map key, made available as bytes.
//
// Distributed discovery backends need this: their FindTrack only receives a
// Key (never the originating [FullTrackName]), so they derive a stable storage
// key from it here. The encoding is deterministic but one-way — reconstruct a
// FullTrackName from stored metadata, not by parsing this.
func (k Key) Bytes() []byte {
b := make([]byte, 0, binary.MaxVarintLen64+len(k.namespace)+len(k.name))
b = binary.AppendUvarint(b, uint64(len(k.namespace)))
b = append(b, k.namespace...)
b = append(b, k.name...)
return b
}
// Package uri parses and validates "moqt" URIs and their fragment identifiers
// as defined by draft-ietf-moq-transport-20 §3.1.1 and §3.1.2.
//
// moqt-URI = "moqt" "://" authority path-abempty [ "?" query ]
//
// A parsed [URI] exposes everything the connection-setup paths need:
// [URI.HostPort] for dialing (applying the §3.1.1 default port of 443),
// [URI.Authority] and [URI.PathAndQuery] for the AUTHORITY / PATH Setup
// Options carried on a native-QUIC connection (§3.1.4 / §10.3.1), and
// [URI.HTTPSURL] for the https URL a WebTransport client connects to
// (§3.1.3). Fragments are parsed and validated but, per §3.1.2, are processed
// locally by the client and never transmitted to the server.
//
// The package depends only on the standard library so it can be used from any
// layer of the stack without pulling in the session machinery.
package uri
import (
"fmt"
"net"
"net/url"
"strings"
)
// Scheme is the URI scheme defined for MOQT servers (§3.1.1).
const Scheme = "moqt"
// DefaultPort is used when the authority omits an explicit port (§3.1.1:
// "If the port is omitted in the URI, a default port of 443 is used").
const DefaultPort = "443"
// URI is a parsed, validated "moqt" URI (§3.1.1).
type URI struct {
// Authority is the host[:port] exactly as supplied (no default port
// filled in). The host subcomponent is guaranteed non-empty.
Authority string
// Host is the host subcomponent of the authority, without any port.
Host string
// Port is the explicit port from the authority, or [DefaultPort] when the
// authority omitted one.
Port string
// Path is the path-abempty component: either empty or beginning with "/".
Path string
// RawQuery is the query component without its leading "?", empty when the
// URI carried no query.
RawQuery string
// Fragment is the parsed fragment identifier, or nil when the URI carried
// none. Per §3.1.2 the fragment is processed locally and never sent to
// the server.
Fragment *Fragment
}
// Fragment is a parsed moqt URI fragment identifier (§3.1.2):
//
// moqt://example.com/app#<type>:<value>
type Fragment struct {
// Type is the registered fragment type identifier: a non-empty string of
// ASCII lowercase letters, digits, and hyphens (a-z, 0-9, -).
Type string
// Value is the type-specific value following the first colon. Its
// semantics are defined by the specification that registers Type.
Value string
}
// Parse parses and validates a "moqt" URI per §3.1.1 / §3.1.2. It returns an
// error when the scheme is not "moqt", the URI is not hierarchical, the
// authority has an empty host, or a present fragment does not match the
// "type:value" grammar.
func Parse(raw string) (*URI, error) {
u, err := url.Parse(raw)
if err != nil {
return nil, fmt.Errorf("uri: parse %q: %w", raw, err)
}
if u.Scheme != Scheme {
return nil, fmt.Errorf("uri: scheme %q, want %q", u.Scheme, Scheme)
}
// A hierarchical URI ("scheme://...") leaves Opaque empty and fills Host.
// An opaque form like "moqt:foo" is rejected.
if u.Opaque != "" {
return nil, fmt.Errorf("uri: %q is not hierarchical (expected %s://)", raw, Scheme)
}
host := u.Hostname()
if host == "" {
// §3.1.1: "The authority portion MUST NOT contain an empty host
// portion."
return nil, fmt.Errorf("uri: %q has an empty host", raw)
}
port := u.Port()
if port == "" {
port = DefaultPort
}
out := &URI{
Authority: u.Host,
Host: host,
Port: port,
// EscapedPath, not Path: the struct carries the RAW path-abempty
// component. url.URL.Path is percent-DECODED — using it would turn
// "/a%3Fb" into "/a?b", making the §3.1.4 PATH Setup Option
// ambiguous and String()/HTTPSURL() emit invalid URIs.
Path: u.EscapedPath(),
RawQuery: u.RawQuery,
}
// A '#' in the raw URI introduces a fragment component (§3.1.2). When
// present it MUST match the "type:value" grammar, so a present-but-empty
// or colon-less fragment is an error rather than silently ignored.
if strings.IndexByte(raw, '#') >= 0 {
frag, err := parseFragment(u.Fragment)
if err != nil {
return nil, err
}
out.Fragment = frag
}
return out, nil
}
// parseFragment validates the "type:value" grammar of §3.1.2 against the
// (percent-decoded) fragment text.
func parseFragment(frag string) (*Fragment, error) {
typ, val, ok := strings.Cut(frag, ":")
if !ok {
return nil, fmt.Errorf("uri: fragment %q missing \"type:value\" colon (§3.1.2)", frag)
}
if !validFragmentType(typ) {
return nil, fmt.Errorf(
"uri: fragment type %q must be a non-empty run of ASCII [a-z0-9-] (§3.1.2)", typ)
}
return &Fragment{Type: typ, Value: val}, nil
}
// validFragmentType reports whether s is a non-empty run of ASCII lowercase
// letters, digits, and hyphens (§3.1.2).
func validFragmentType(s string) bool {
if s == "" {
return false
}
for i := range len(s) {
c := s[i]
switch {
case c >= 'a' && c <= 'z':
case c >= '0' && c <= '9':
case c == '-':
default:
return false
}
}
return true
}
// HostPort returns the "host:port" string for dialing, applying the §3.1.1
// default port of 443 when the URI omitted one.
func (u *URI) HostPort() string {
return net.JoinHostPort(u.Host, u.Port)
}
// PathAndQuery returns the path-abempty with the query appended, the value to
// carry in the PATH Setup Option (§3.1.4 / §10.3.1.2). It is empty when the
// URI has neither a path nor a query.
func (u *URI) PathAndQuery() string {
if u.RawQuery == "" {
return u.Path
}
return u.Path + "?" + u.RawQuery
}
// HTTPSURL converts the moqt URI to the https URL a WebTransport client
// connects to (§3.1.3): the scheme is replaced with https and the authority,
// path, and query are preserved. The fragment is omitted because it is
// processed locally and never sent to the server (§3.1.2).
func (u *URI) HTTPSURL() string {
var b strings.Builder
b.WriteString("https://")
b.WriteString(u.Authority)
b.WriteString(u.Path)
if u.RawQuery != "" {
b.WriteByte('?')
b.WriteString(u.RawQuery)
}
return b.String()
}
// String reconstructs the moqt URI, including any fragment. The authority is
// emitted exactly as parsed, so a URI that omitted its port round-trips
// without a default port appearing.
func (u *URI) String() string {
var b strings.Builder
b.WriteString(Scheme)
b.WriteString("://")
b.WriteString(u.Authority)
b.WriteString(u.Path)
if u.RawQuery != "" {
b.WriteByte('?')
b.WriteString(u.RawQuery)
}
if u.Fragment != nil {
b.WriteByte('#')
b.WriteString(u.Fragment.Type)
b.WriteByte(':')
b.WriteString(u.Fragment.Value)
}
return b.String()
}
package wire
import (
"encoding/binary"
"fmt"
"io"
)
// MaxControlMessagePayload is the largest payload that fits in a control
// message's 16-bit Length field (§10).
const MaxControlMessagePayload = 0xFFFF
// ReadFrame reads a single MoQT control-message frame (Type + Length + Payload)
// from r, returning the message type and the payload bytes. The returned
// payload is freshly allocated; the caller owns it.
//
// ReadFrame returns io.EOF only when r reports EOF before the type byte has
// been read; once any byte has been consumed, a truncated frame surfaces as
// io.ErrUnexpectedEOF.
func ReadFrame(r io.Reader) (uint64, []byte, error) {
msgType, err := ReadVarint(NewByteReader(r))
if err != nil {
return 0, nil, err
}
var lenBuf [2]byte
if _, err := io.ReadFull(r, lenBuf[:]); err != nil {
if err == io.EOF {
err = io.ErrUnexpectedEOF
}
return 0, nil, err
}
length := binary.BigEndian.Uint16(lenBuf[:])
if length == 0 {
return msgType, nil, nil
}
payload := make([]byte, length)
if _, err := io.ReadFull(r, payload); err != nil {
if err == io.EOF {
err = io.ErrUnexpectedEOF
}
return 0, nil, err
}
return msgType, payload, nil
}
// WriteFrame writes a MoQT control-message frame to w. It returns an error if
// the payload exceeds MaxControlMessagePayload.
func WriteFrame(w io.Writer, msgType uint64, payload []byte) error {
if len(payload) > MaxControlMessagePayload {
return fmt.Errorf("moqt/wire: control message payload %d exceeds %d", len(payload), MaxControlMessagePayload)
}
hdr := make([]byte, 0, VarintLen(msgType)+2)
hdr = AppendVarint(hdr, msgType)
//nolint:gosec // G115: len(payload) is checked <= MaxControlMessagePayload (0xFFFF) above, so it fits 16 bits.
hdr = append(hdr, byte(len(payload)>>8), byte(len(payload)))
if _, err := w.Write(hdr); err != nil {
return err
}
if len(payload) == 0 {
return nil
}
_, err := w.Write(payload)
return err
}
package wire
import (
"cmp"
"fmt"
"slices"
)
// KVPair is a MoQT Key-Value-Pair (§1.4.3). When Type is even, IntVal carries
// the value (encoded as a single varint). When Type is odd, ByteVal carries the
// value (length-prefixed bytes).
//
// KVPairs are used for SETUP Options (§10.3.1); they appear delta-encoded by
// Type within a list, with the running "previous type" starting at zero.
type KVPair struct {
Type uint64
IntVal uint64
ByteVal []byte
}
// IsBytes reports whether this KVPair carries length-prefixed bytes (Type odd)
// rather than a varint (Type even).
func (p KVPair) IsBytes() bool { return p.Type&1 == 1 }
// MaxKVPairValueBytes is the per-pair byte-value cap from §1.4.3.
const MaxKVPairValueBytes = 0xFFFF
// KVPair appends a single KVPair using prev as the running previous Type, and
// returns the new previous Type. The first pair in a list passes prev=0.
func (w *Writer) KVPair(p KVPair, prev uint64) uint64 {
w.Varint(p.Type - prev)
if p.IsBytes() {
w.VarintBytes(p.ByteVal)
} else {
w.Varint(p.IntVal)
}
return p.Type
}
// KVPairs appends a list of KVPairs with delta encoding starting from prev=0.
// Pairs are sorted by Type before encoding so callers do not need to order them.
func (w *Writer) KVPairs(pairs []KVPair) {
slices.SortFunc(pairs, func(a, b KVPair) int { return cmp.Compare(a.Type, b.Type) })
var prev uint64
for _, p := range pairs {
prev = w.KVPair(p, prev)
}
}
// KVPair reads a single KVPair using prev as the running previous Type, and
// returns the pair plus the new previous Type.
func (r *Reader) KVPair(prev uint64) (KVPair, uint64, error) {
delta, err := r.Varint()
if err != nil {
return KVPair{}, prev, err
}
if delta > ^uint64(0)-prev {
return KVPair{}, prev, fmt.Errorf("moqt/wire: kv pair type delta overflow (prev=%d delta=%d)", prev, delta)
}
t := prev + delta
p := KVPair{Type: t}
if p.IsBytes() {
b, err := r.VarintBytes()
if err != nil {
return KVPair{}, prev, err
}
if len(b) > MaxKVPairValueBytes {
return KVPair{}, prev, fmt.Errorf(
"moqt/wire: kv pair value length %d exceeds %d",
len(b),
MaxKVPairValueBytes,
)
}
p.ByteVal = b
} else {
v, err := r.Varint()
if err != nil {
return KVPair{}, prev, err
}
p.IntVal = v
}
return p, t, nil
}
// KVPairsRemaining reads KVPairs until the reader is empty. This is used for
// SETUP, where Setup Options span the entire control-message payload (§10.3).
func (r *Reader) KVPairsRemaining() ([]KVPair, error) {
var (
pairs []KVPair
prev uint64
)
for !r.Empty() {
p, next, err := r.KVPair(prev)
if err != nil {
return nil, err
}
pairs = append(pairs, p)
prev = next
}
return pairs, nil
}
package wire
import (
"bytes"
"fmt"
)
// MaxTrackNamespaceFields is the upper bound on tuple count per §2.4.1.
const MaxTrackNamespaceFields = 32
// MaxFullTrackNameBytes is the upper bound on the sum of all namespace field
// lengths plus the track name length per §2.4.1.
const MaxFullTrackNameBytes = 4096
// TrackNamespace is an ordered set of 0..32 binary fields (§2.4.1).
type TrackNamespace [][]byte
// Namespace builds a TrackNamespace from string fields — the ergonomic form of
// the TrackNamespace{[]byte("a"), []byte("b")} literal. Each argument becomes
// one §2.4.1 field, in order. Namespace fields MAY contain arbitrary bytes; for
// non-UTF-8 fields use the [][]byte literal directly.
func Namespace(parts ...string) TrackNamespace {
ns := make(TrackNamespace, len(parts))
for i, p := range parts {
ns[i] = []byte(p)
}
return ns
}
// TrackNamespace reads a TrackNamespace per §2.4.1. Each field must be at least
// one byte; the tuple count must not exceed MaxTrackNamespaceFields. The
// returned slices are owned by the caller (see Reader.FixedBytes).
func (r *Reader) TrackNamespace() (TrackNamespace, error) {
count, err := r.Varint()
if err != nil {
return nil, err
}
if count > MaxTrackNamespaceFields {
return nil, fmt.Errorf("moqt/wire: track namespace has %d fields, max %d", count, MaxTrackNamespaceFields)
}
ns := make(TrackNamespace, 0, count)
total := 0
for i := range count {
field, err := r.VarintBytes()
if err != nil {
return nil, err
}
if len(field) == 0 {
return nil, fmt.Errorf("moqt/wire: track namespace field %d has zero length", i)
}
// §2.4.1: "If an endpoint receives a Track Namespace or a Full
// Track Name exceeding 4,096 bytes, it MUST close the session with
// a PROTOCOL_VIOLATION." The namespace-only half is enforced here,
// at the single parse point every message shares; messages that
// also carry a Track Name check the combined length in Validate.
total += len(field)
if total > MaxFullTrackNameBytes {
return nil, fmt.Errorf(
"moqt/wire: track namespace exceeds %d bytes (§2.4.1)", MaxFullTrackNameBytes)
}
ns = append(ns, field)
}
return ns, nil
}
// TrackNamespace appends a TrackNamespace per §2.4.1.
func (w *Writer) TrackNamespace(ns TrackNamespace) {
w.Varint(uint64(len(ns)))
for _, field := range ns {
w.VarintBytes(field)
}
}
// ByteLen reports the sum of field lengths (used to enforce the 4096-byte
// Full Track Name limit alongside the Track Name's length).
func (ns TrackNamespace) ByteLen() int {
total := 0
for _, f := range ns {
total += len(f)
}
return total
}
// HasPrefix reports whether prefix is a (non-strict) prefix of ns in the
// field-by-field sense of §2.4.1. A zero-length prefix matches every ns,
// matching the §6.1 "Either message with zero Track Namespace fields
// indicates the sender is interested in all namespaces" rule used by
// SUBSCRIBE_NAMESPACE / SUBSCRIBE_TRACKS matching.
//
// Fields are compared as opaque binary; namespace components MAY contain
// any bytes per §2.4.1.
func (ns TrackNamespace) HasPrefix(prefix TrackNamespace) bool {
if len(prefix) > len(ns) {
return false
}
for i, p := range prefix {
if !bytes.Equal(p, ns[i]) {
return false
}
}
return true
}
// String renders the namespace as "/comp1/comp2/..." with each
// component shown verbatim. Intended for log and error messages;
// callers that need a strict serialization should use Writer.TrackNamespace.
func (ns TrackNamespace) String() string {
var b []byte
b = append(b, '/')
for i, c := range ns {
if i > 0 {
b = append(b, '/')
}
b = append(b, c...)
}
return string(b)
}
package wire
// Scanner is a sticky-error decoding cursor over a [Reader]. Each accessor
// reads one field into the supplied pointer and records the first error it
// hits; once an error is recorded every later accessor is a no-op until Err is
// consulted. It removes the repetitive per-field error handling that otherwise
// dominates message Parse methods:
//
// func (m *Subscribe) Parse(r *wire.Reader) error {
// s := r.Scanner()
// s.Varint(&m.RequestID)
// s.TrackNamespace(&m.Namespace)
// s.VarintBytes(&m.Name)
// if err := s.Err(); err != nil {
// return err
// }
// return m.Parameters.parse(r)
// }
//
// A Scanner delegates to its Reader and advances the same read offset, so the
// underlying Reader stays usable directly after the Scanner (e.g. for
// Parameters.parse or a RemainingBytes tail) once Err reports no error.
//
// Scanner only wraps the in-memory [Reader]; the streaming [StreamReader] /
// [Decoder] path is unaffected.
type Scanner struct {
r *Reader
err error
}
// Scanner returns a sticky-error cursor over r.
func (r *Reader) Scanner() *Scanner { return &Scanner{r: r} }
// Err returns the first error any accessor recorded, or nil.
func (s *Scanner) Err() error { return s.err }
// scan runs read unless an error is already pending, storing the value into dst
// or recording the error. It is the single implementation behind every typed
// accessor below.
func scan[T any](s *Scanner, dst *T, read func() (T, error)) {
if s.err != nil {
return
}
v, err := read()
if err != nil {
s.err = err
return
}
*dst = v
}
// Varint reads a leading-ones varint (§1.4.1) into dst.
func (s *Scanner) Varint(dst *uint64) { scan(s, dst, s.r.Varint) }
// UInt8 reads a single byte into dst.
func (s *Scanner) UInt8(dst *uint8) { scan(s, dst, s.r.UInt8) }
// VarintBytes reads a varint-length-prefixed byte slice into dst.
func (s *Scanner) VarintBytes(dst *[]byte) { scan(s, dst, s.r.VarintBytes) }
// ReasonPhrase reads a §1.4.4 reason phrase into dst.
func (s *Scanner) ReasonPhrase(dst *string) { scan(s, dst, s.r.ReasonPhrase) }
// TrackNamespace reads a §2.4.1 track namespace into dst.
func (s *Scanner) TrackNamespace(dst *TrackNamespace) { scan(s, dst, s.r.TrackNamespace) }
// KVPairsRemaining reads delta-encoded KV pairs to end-of-buffer into dst.
func (s *Scanner) KVPairsRemaining(dst *[]KVPair) { scan(s, dst, s.r.KVPairsRemaining) }
package wire
import (
"encoding/binary"
"errors"
"io"
"math/bits"
)
// MoQT variable-length integers (draft-ietf-moq-transport-20 §1.4.1).
//
// Unlike QUIC's RFC 9000 §16 varints — which use the high 2 bits of the first
// byte to select a 1/2/4/8-byte length — MoQT uses a "leading-ones" scheme: the
// number of leading 1 bits in the first byte gives the encoded length (1 to 9
// bytes). The value occupies the bits after the first 0, plus all subsequent
// bytes, in network byte order.
//
// Leading bits | Length | First byte | Value bytes
// 0 | 1 | 0xxxxxxx | (none)
// 10 | 2 | 10xxxxxx | 1
// 110 | 3 | 110xxxxx | 2
// 1110 | 4 | 1110xxxx | 3
// 11110 | 5 | 11110xxx | 4
// 111110 | 6 | 111110xx | 5
// 1111110 | 7 | 1111110x | 6
// 11111110 | 8 | 11111110 | 7
// 11111111 | 9 | 11111111 | 8
//
// §1.4.1 also notes integers "do not need to be encoded using the minimum
// number of bytes", so decoders accept non-minimal encodings; AppendVarint
// always emits the minimal form.
// VarintLen returns the number of bytes AppendVarint uses to encode v.
func VarintLen(v uint64) int {
switch {
case v < 1<<7:
return 1
case v < 1<<14:
return 2
case v < 1<<21:
return 3
case v < 1<<28:
return 4
case v < 1<<35:
return 5
case v < 1<<42:
return 6
case v < 1<<49:
return 7
case v < 1<<56:
return 8
default:
return 9
}
}
// AppendVarint appends the minimal leading-ones encoding of v to dst and
// returns the extended slice.
func AppendVarint(dst []byte, v uint64) []byte {
n := VarintLen(v)
if n == 9 {
// 0xFF prefix (8 leading ones) followed by the full 64-bit value.
dst = append(dst, 0xFF)
return binary.BigEndian.AppendUint64(dst, v)
}
// For n<=8 the value uses 7n bits, so it fits in the low n bytes of its
// big-endian form, leaving the top n bits of the first byte free for the
// (n-1)-leading-ones prefix.
var buf [8]byte
binary.BigEndian.PutUint64(buf[:], v)
out := buf[8-n:]
out[0] |= ^(byte(0xFF) >> (n - 1))
return append(dst, out...)
}
// varintLenFromFirst returns the total encoded length implied by the first
// byte's leading-ones count.
func varintLenFromFirst(first byte) int {
// The encoded length is (leading ones)+1, except 0xFF (8 leading ones)
// which denotes the 9-byte form.
ones := bits.LeadingZeros8(^first)
if ones == 8 {
return 9
}
return ones + 1
}
// ParseVarint decodes a leading-ones varint from the front of b, returning the
// value and the number of bytes consumed. It returns ErrShortBuffer if b is
// shorter than the encoding the first byte announces.
func ParseVarint(b []byte) (uint64, int, error) {
if len(b) == 0 {
return 0, 0, ErrShortBuffer
}
n := varintLenFromFirst(b[0])
if len(b) < n {
return 0, 0, ErrShortBuffer
}
if n == 9 {
return binary.BigEndian.Uint64(b[1:9]), 9, nil
}
// Low (8-n) bits of the first byte are value bits (0 for n==8).
v := uint64(b[0] & (0xFF >> n))
for i := 1; i < n; i++ {
v = v<<8 | uint64(b[i])
}
return v, n, nil
}
// ReadVarint decodes a leading-ones varint from r, reading exactly the bytes of
// one encoding (never any look-ahead), so it is safe to call repeatedly on the
// same underlying stream.
func ReadVarint(r io.ByteReader) (uint64, error) {
first, err := r.ReadByte()
if err != nil {
return 0, err
}
n := varintLenFromFirst(first)
if n == 1 {
return uint64(first), nil
}
var rest [8]byte
for i := range n - 1 {
b, err := r.ReadByte()
if err != nil {
if errors.Is(err, io.EOF) {
err = io.ErrUnexpectedEOF
}
return 0, err
}
rest[i] = b
}
if n == 9 {
return binary.BigEndian.Uint64(rest[:8]), nil
}
v := uint64(first & (0xFF >> n))
for i := range n - 1 {
v = v<<8 | uint64(rest[i])
}
return v, nil
}
// NewByteReader adapts an io.Reader to io.ByteReader by reading a single byte
// per call, with no buffering or look-ahead, so a varint read leaves the
// underlying reader positioned exactly after the varint.
func NewByteReader(r io.Reader) io.ByteReader {
if br, ok := r.(io.ByteReader); ok {
return br
}
return &byteReaderAdapter{r: r}
}
// Package wire implements MoQT wire-format primitives per
// draft-ietf-moq-transport-20: variable-length integers (§1.4.1, RFC 9000 §16),
// reason phrases (§1.4.4), track namespaces (§2.4.1), key-value pairs used in
// SETUP options (§1.4.3, §10.3.1), and control-message framing (§10).
//
// Encoding follows an append-style: builders accumulate bytes into a Writer.
// Decoding uses a stateful Reader bounded by an input buffer; running past the
// buffer yields ErrShortBuffer, which the message layer maps to a session-level
// PROTOCOL_VIOLATION (§3.5).
//
// For streaming decoding (e.g. data uni-streams), use StreamReader which wraps
// an io.Reader and exposes the same Decoder interface as Reader.
package wire
import (
"errors"
"fmt"
"io"
"unicode/utf8"
)
// ErrShortBuffer is returned when a read would consume bytes past the end of
// the input buffer. Callers should treat this as a malformed message.
var ErrShortBuffer = errors.New("moqt/wire: short buffer")
// ErrFieldTooLarge is returned by StreamReader when a length-prefixed field
// claims more bytes than [MaxStreamFieldSize]. Callers should treat it as a
// malformed message (PROTOCOL_VIOLATION, §3.5).
var ErrFieldTooLarge = errors.New("moqt/wire: field exceeds maximum size")
// MaxStreamFieldSize bounds a single length-prefixed field (object payload,
// properties blob, name, …) that [StreamReader] will allocate for. Because a
// StreamReader reads from an unbounded io.Reader, FixedBytes refuses to
// pre-allocate more than this for a peer-supplied length, so a malicious peer
// cannot trigger an unbounded allocation by claiming a huge length before
// sending the bytes. (The in-memory [Reader] is already bounded by its buffer
// and is not subject to this limit.) The default is generous enough for large
// media objects such as 4K keyframes; deployments carrying larger objects can
// raise it.
var MaxStreamFieldSize = 16 << 20 // 16 MiB
// Reader consumes MoQT wire primitives from an in-memory buffer. It tracks the
// read offset; partial reads do not advance the offset.
type Reader struct {
buf []byte
off int
}
// NewReader returns a Reader over buf. buf is not copied; the caller must not
// mutate it while the Reader is in use.
func NewReader(buf []byte) *Reader { return &Reader{buf: buf} }
// Remaining returns the number of bytes left to consume.
func (r *Reader) Remaining() int { return len(r.buf) - r.off }
// Empty reports whether the reader has consumed all bytes.
func (r *Reader) Empty() bool { return r.off >= len(r.buf) }
// Varint reads a MoQT leading-ones varint (§1.4.1, 1–9 bytes).
func (r *Reader) Varint() (uint64, error) {
v, n, err := ParseVarint(r.buf[r.off:])
if err != nil {
return 0, err
}
r.off += n
return v, nil
}
// UInt8 reads a single byte.
func (r *Reader) UInt8() (uint8, error) {
if r.Remaining() < 1 {
return 0, ErrShortBuffer
}
v := r.buf[r.off]
r.off++
return v, nil
}
// FixedBytes reads exactly n bytes. The returned slice is a fresh copy that
// the caller owns; mutating it does not affect the Reader's buffer, and
// retaining it does not pin the buffer for GC. Zero-length reads return nil.
func (r *Reader) FixedBytes(n int) ([]byte, error) {
if r.Remaining() < n {
return nil, ErrShortBuffer
}
if n == 0 {
return nil, nil
}
out := make([]byte, n)
copy(out, r.buf[r.off:r.off+n])
r.off += n
return out, nil
}
// RemainingBytes consumes and returns a copy of all unconsumed bytes. Used
// when a message has a trailing variable-length field bounded only by the
// outer frame length (e.g. Track Properties in SUBSCRIBE_OK / PUBLISH).
// Zero-length returns nil.
func (r *Reader) RemainingBytes() []byte {
n := r.Remaining()
if n == 0 {
return nil
}
out := make([]byte, n)
copy(out, r.buf[r.off:])
r.off += n
return out
}
// VarintBytes reads a varint length followed by that many bytes. The returned
// slice is owned by the caller (see FixedBytes).
func (r *Reader) VarintBytes() ([]byte, error) {
n, err := r.Varint()
if err != nil {
return nil, err
}
//nolint:gosec // G115: n is a QUIC varint (<=2^62-1); Reader.FixedBytes bounds it by Remaining().
return r.FixedBytes(int(n))
}
// ReasonPhrase reads a varint-length-prefixed UTF-8 string per §1.4.4. The
// maximum allowed length is 1024 bytes; exceeding this yields an error that
// the caller should map to PROTOCOL_VIOLATION.
func (r *Reader) ReasonPhrase() (string, error) {
n, err := r.Varint()
if err != nil {
return "", err
}
if n > MaxReasonPhraseBytes {
return "", fmt.Errorf("moqt/wire: reason phrase length %d exceeds %d", n, MaxReasonPhraseBytes)
}
b, err := r.FixedBytes(int(n))
if err != nil {
return "", err
}
return string(b), nil
}
// Writer accumulates encoded MoQT bytes. The zero value is ready to use.
type Writer struct {
buf []byte
}
// NewWriter returns a Writer that appends to buf (which may be nil). Use
// Bytes to retrieve the accumulated output.
func NewWriter(buf []byte) *Writer { return &Writer{buf: buf} }
// Bytes returns the accumulated output. The returned slice aliases the
// Writer's internal buffer.
func (w *Writer) Bytes() []byte { return w.buf }
// Reset clears the writer's buffer, allowing it to be reused.
func (w *Writer) Reset() { w.buf = w.buf[:0] }
// Varint appends a MoQT leading-ones varint (§1.4.1).
func (w *Writer) Varint(v uint64) { w.buf = AppendVarint(w.buf, v) }
// UInt8 appends a single byte.
func (w *Writer) UInt8(v uint8) { w.buf = append(w.buf, v) }
// FixedBytes appends raw bytes without any length prefix.
func (w *Writer) FixedBytes(p []byte) { w.buf = append(w.buf, p...) }
// VarintBytes appends a varint length followed by the bytes themselves.
func (w *Writer) VarintBytes(p []byte) {
w.Varint(uint64(len(p)))
w.FixedBytes(p)
}
// MaxReasonPhraseBytes is the §1.4.4 cap on an encoded reason phrase. [Reader]
// rejects anything longer, and [Writer.ReasonPhrase] truncates to it.
const MaxReasonPhraseBytes = 1024
// ReasonPhrase appends a reason phrase per §1.4.4, truncating to
// [MaxReasonPhraseBytes].
//
// Truncating rather than encoding as-is because a Writer method has no way to
// report an error, so the alternative is emitting a frame that every conforming
// peer must treat as a PROTOCOL_VIOLATION — losing the tail of a diagnostic
// string is strictly better than losing the session that was trying to report
// it. The reason phrase is not always ours to bound: REQUEST_ERROR and
// PUBLISH_ERROR carry one built from a token verifier's error text, and a
// third-party [TokenVerifier] can return a string of any length.
//
// The cut lands on a rune boundary, since §1.4.4 specifies UTF-8 and slicing
// mid-rune would produce a phrase the peer decodes as replacement characters.
func (w *Writer) ReasonPhrase(s string) {
if len(s) > MaxReasonPhraseBytes {
s = s[:MaxReasonPhraseBytes]
// Drop a rune the cut split (and any invalid trailing bytes with it).
for len(s) > 0 {
if r, size := utf8.DecodeLastRuneInString(s); r == utf8.RuneError && size <= 1 {
s = s[:len(s)-1]
continue
}
break
}
}
w.VarintBytes([]byte(s))
}
// Decoder is the read-side interface shared by Reader (in-memory) and
// StreamReader (streaming io.Reader). Parse methods in the message package
// accept Decoder so they work in both contexts.
type Decoder interface {
Varint() (uint64, error)
UInt8() (uint8, error)
FixedBytes(n int) ([]byte, error)
VarintBytes() ([]byte, error)
}
// StreamReader wraps an io.Reader and exposes the same Decoder interface as
// Reader. It is intended for parsing self-delimiting wire objects directly
// from a QUIC uni-stream without buffering the entire object first.
type StreamReader struct {
r io.Reader
// qr reads one byte at a time (no look-ahead) so a varint read leaves the
// stream positioned exactly after the varint.
qr io.ByteReader
}
// NewStreamReader returns a StreamReader over r. r should already be buffered
// (e.g. a *bufio.Reader) for efficiency; StreamReader does not add its own
// buffering layer.
func NewStreamReader(r io.Reader) *StreamReader {
return &StreamReader{r: r, qr: NewByteReader(r)}
}
// Varint reads a MoQT leading-ones varint (§1.4.1) from the underlying stream.
func (s *StreamReader) Varint() (uint64, error) {
return ReadVarint(s.qr)
}
// UInt8 reads a single byte.
func (s *StreamReader) UInt8() (uint8, error) {
b, err := s.qr.ReadByte()
return b, err
}
// FixedBytes reads exactly n bytes.
func (s *StreamReader) FixedBytes(n int) ([]byte, error) {
if n == 0 {
return nil, nil
}
// n is derived from a peer-supplied varint; guard the allocation so a
// bogus length cannot OOM us. n < 0 only on a 32-bit int overflow.
if n < 0 || n > MaxStreamFieldSize {
return nil, fmt.Errorf("%w: %d > %d", ErrFieldTooLarge, n, MaxStreamFieldSize)
}
buf := make([]byte, n)
_, err := io.ReadFull(s.r, buf)
return buf, err
}
// VarintBytes reads a varint length then that many bytes.
func (s *StreamReader) VarintBytes() ([]byte, error) {
n, err := s.Varint()
if err != nil {
return nil, err
}
//nolint:gosec // G115: n is a QUIC varint (<=2^62-1); StreamReader.FixedBytes enforces MaxStreamFieldSize.
return s.FixedBytes(int(n))
}
// byteReaderAdapter wraps an io.Reader to implement io.ByteReader by reading
// one byte at a time. Used when the underlying reader does not implement
// io.ByteReader directly.
type byteReaderAdapter struct {
r io.Reader
buf [1]byte
}
func (b *byteReaderAdapter) ReadByte() (byte, error) {
_, err := io.ReadFull(b.r, b.buf[:])
return b.buf[0], err
}
package relay
import (
"context"
"errors"
"fmt"
"github.com/floatdrop/moq-go/pkg/moqt"
"github.com/floatdrop/moq-go/pkg/moqt/message"
"github.com/floatdrop/moq-go/pkg/moqt/session"
)
// Authorizer is the relay's pluggable authorization hook. Every request
// handler in [pkg/relay] consults the authorizer before performing any state
// mutation; a non-nil return causes the relay to reply REQUEST_ERROR with
// the [DeniedError]'s mapped code (see [DeniedError.RequestErrorCode]).
//
// The interface is split per request type for two reasons:
//
// - It lets a policy reject categories of request without having to
// type-switch internally — the most common case is "this peer is allowed
// to subscribe but not publish."
// - It surfaces the parsed message to the policy, so token-based schemes
// can inspect things like AUTH_TOKEN parameters or the target Track
// Namespace without re-parsing the wire form.
//
// Every method receives:
//
// - ctx: the per-request context. Authorizers may consult it for tracing,
// cancellation, or token-cache lookups. The relay cancels ctx when the
// request stream or the session terminates.
// - sess: the MOQT session the request arrived on. Policies often inspect
// [session.Session.PeerOptions] (for AUTHORITY / PATH / implementation
// name) or session-scoped TLS / SETUP-time attestations.
// - msg: the parsed request message. Authorizers MUST NOT mutate it.
//
// Returning nil grants the request; returning a non-nil error denies it.
// The relay treats any non-nil return as a denial regardless of error type,
// but wrapping with [*DeniedError] (or using [Deny] / [DenyReason]) gives
// the relay an explicit REQUEST_ERROR code to forward to the peer. A plain
// error (e.g. one returned from a downstream token-validation library)
// maps to [moqt.RequestUnauthorized] by default.
type Authorizer interface {
AuthorizeSubscribe(ctx context.Context, sess *session.Session, msg *message.Subscribe) error
AuthorizePublish(ctx context.Context, sess *session.Session, msg *message.Publish) error
AuthorizePublishNamespace(ctx context.Context, sess *session.Session, msg *message.PublishNamespace) error
AuthorizeFetch(ctx context.Context, sess *session.Session, msg *message.Fetch) error
AuthorizeSubscribeNamespace(ctx context.Context, sess *session.Session, msg *message.SubscribeNamespace) error
AuthorizeSubscribeTracks(ctx context.Context, sess *session.Session, msg *message.SubscribeTracks) error
AuthorizeTrackStatus(ctx context.Context, sess *session.Session, msg *message.TrackStatus) error
}
// DeniedError is returned by an [Authorizer] method to deny a request with
// an explicit MoQT REQUEST_ERROR code. The relay maps Code directly onto the
// REQUEST_ERROR it sends downstream; Reason is forwarded as the human-readable
// reason string.
//
// Code MUST be one of the §10.6 / IANA §15.11.2 REQUEST_ERROR codes (see
// [moqt.RequestErrorCode]). If Code is the zero value, the relay substitutes
// [moqt.RequestUnauthorized] when forming the REQUEST_ERROR.
type DeniedError struct {
Code moqt.RequestErrorCode
Reason string
}
// Error implements the error interface.
func (e *DeniedError) Error() string {
if e.Reason == "" {
return fmt.Sprintf("relay: request denied (code %#x)", uint64(e.Code))
}
return fmt.Sprintf("relay: request denied (code %#x): %s", uint64(e.Code), e.Reason)
}
// RequestErrorCode returns the REQUEST_ERROR code the relay should use when
// rejecting the request. A zero value collapses to [moqt.RequestUnauthorized]
// — the spec's default rejection code for an authorization failure.
func (e *DeniedError) RequestErrorCode() moqt.RequestErrorCode {
if e.Code == 0 {
return moqt.RequestUnauthorized
}
return e.Code
}
// Deny is a constructor for [*DeniedError]. Use it when the policy already
// knows which REQUEST_ERROR code to surface:
//
// return relay.Deny(moqt.RequestUnauthorized, "missing JWT")
func Deny(code moqt.RequestErrorCode, reason string) error {
return &DeniedError{Code: code, Reason: reason}
}
// DenyReason is a convenience for the common case where the policy only
// wants to attach a human-readable reason and is happy with the default
// REQUEST_ERROR code ([moqt.RequestUnauthorized]).
func DenyReason(reason string) error {
return &DeniedError{Code: moqt.RequestUnauthorized, Reason: reason}
}
// CodeForAuthorizerError extracts the REQUEST_ERROR code the relay should
// use when rejecting an authorization failure. If err wraps a [*DeniedError],
// its code is returned; otherwise the default [moqt.RequestUnauthorized] is
// returned so a policy that returns a plain error still gets a sensible
// MoQT reply rather than [moqt.RequestInternalError].
func CodeForAuthorizerError(err error) moqt.RequestErrorCode {
if denied, ok := errors.AsType[*DeniedError](err); ok {
return denied.RequestErrorCode()
}
return moqt.RequestUnauthorized
}
// ReasonForAuthorizerError extracts the human-readable reason for an
// authorization denial. If err wraps a [*DeniedError] and that error has a
// non-empty Reason field, the reason is returned; otherwise the error's
// Error() string is returned. This avoids leaking the internal "relay:
// request denied (code 0x1):" prefix into the wire reply.
func ReasonForAuthorizerError(err error) string {
if denied, ok := errors.AsType[*DeniedError](err); ok && denied.Reason != "" {
return denied.Reason
}
if err == nil {
return ""
}
return err.Error()
}
// AllowAllAuthorizer is the package's permissive default. Every method
// returns nil. It exists so unit tests, in-process integration tests, and
// pure-relay-of-relays topologies that defer authorization to a downstream
// layer can run without writing a custom policy.
//
// Production deployments SHOULD replace this with a token- or
// session-attestation-aware implementation via [Config.Authorizer]. The relay
// only invokes the authorizer once per request before any state mutation, so
// the cost of policy evaluation is bounded by the request rate rather than
// the object rate.
type AllowAllAuthorizer struct{}
var _ Authorizer = AllowAllAuthorizer{}
// AuthorizeSubscribe returns nil.
func (AllowAllAuthorizer) AuthorizeSubscribe(context.Context, *session.Session, *message.Subscribe) error {
return nil
}
// AuthorizePublish returns nil.
func (AllowAllAuthorizer) AuthorizePublish(context.Context, *session.Session, *message.Publish) error {
return nil
}
// AuthorizePublishNamespace returns nil.
func (AllowAllAuthorizer) AuthorizePublishNamespace(
context.Context,
*session.Session,
*message.PublishNamespace,
) error {
return nil
}
// AuthorizeFetch returns nil.
func (AllowAllAuthorizer) AuthorizeFetch(context.Context, *session.Session, *message.Fetch) error {
return nil
}
// AuthorizeSubscribeNamespace returns nil.
func (AllowAllAuthorizer) AuthorizeSubscribeNamespace(
context.Context,
*session.Session,
*message.SubscribeNamespace,
) error {
return nil
}
// AuthorizeSubscribeTracks returns nil.
func (AllowAllAuthorizer) AuthorizeSubscribeTracks(context.Context, *session.Session, *message.SubscribeTracks) error {
return nil
}
// AuthorizeTrackStatus returns nil.
func (AllowAllAuthorizer) AuthorizeTrackStatus(context.Context, *session.Session, *message.TrackStatus) error {
return nil
}
// Package cache holds the relay's per-track Object Cache (§9.4 fetch
// support). Storage is a fixed-capacity circular ring buffer (FIFO) with
// an auxiliary {GroupID, ObjectID} → ring-slot index map for O(1) point
// lookup and overwrite-in-place.
//
// Eviction policy:
//
// - Size-bounded: when the ring is full, a new Put evicts the oldest
// entry. Re-Put of an existing key overwrites in place and does NOT
// evict anything.
// - Time-bounded: per-entry MaxCacheDuration is applied at read time.
// Get / GetRange skip entries older than the configured age; the
// ring itself does no proactive cleanup. This avoids background
// goroutines and is exactly equivalent for callers because the
// only consumers of stored data are the FETCH handlers, which
// can't see anything Get / GetRange filters out.
//
// The §10.2.17 LARGEST_OBJECT watermark is maintained outside the ring,
// under the same mutex, and is monotonic — evictions and TTL expiry
// don't roll it back.
package cache
import (
"cmp"
"slices"
"sync"
"time"
"github.com/floatdrop/moq-go/pkg/moqt/message"
)
// defaultUnboundedCapacity is the ring size used when callers pass
// maxSize <= 0. Production callers (the track registry) always pass a
// positive value; the fallback exists so test helpers that want
// "effectively unbounded" keep working without exporting a separate
// constructor. 1<<16 is large enough that no in-tree test fills it.
const defaultUnboundedCapacity = 1 << 16
// ForwardingPreference distinguishes objects that arrived on a §11.4.2
// SUBGROUP_HEADER stream from objects that arrived as §11.3 datagrams.
// A FETCH response must replay this verbatim because the subscriber's
// decoding depends on it.
type ForwardingPreference uint8
const (
// ForwardingSubgroup: the object came from a SUBGROUP_HEADER stream.
ForwardingSubgroup ForwardingPreference = iota
// ForwardingDatagram: the object arrived as a §11.3 OBJECT_DATAGRAM.
ForwardingDatagram
)
// CachedObject is the unified record stored by [ObjectCache]. It covers both
// subgroup objects (where SubgroupID + an absolute ObjectID arrive on a
// stream header + per-object delta) and datagrams (which carry their own
// absolute ObjectID and no subgroup notion).
type CachedObject struct {
GroupID uint64
ObjectID uint64 // absolute, decoded
SubgroupID uint64 // 0 for datagrams (no subgroup notion)
PublisherPriority uint8
ForwardingPref ForwardingPreference
Status uint64 // §11.2.1.1; 0 for normal objects
Properties []byte // retained by reference; opaque to the cache
Payload []byte // retained by reference; opaque
ReceivedAt time.Time
// 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
// {GroupID, ObjectID} (inclusive) has unknown status. Markers exist only
// on the FETCH serve path — upstream stitching manufactures them for
// sub-ranges no source could vouch for — and are never stored in the
// cache ring.
EndOfUnknownRange bool
// EndOfTimedOutRange marks this element as a §11.4.4.2 End of Timed-Out
// Range (0x20C) FETCH marker: same shape as EndOfUnknownRange, but the
// Objects it covers were abandoned because FILL_TIMEOUT (§10.2.5) expired
// rather than because no source could vouch for them. draft-20 split the
// two so a subscriber can tell "retry might help" from "status unknown".
EndOfTimedOutRange bool
}
// IsRangeMarker reports whether the element is any §11.4.4.2 end-of-range
// marker rather than a stored object.
func (o *CachedObject) IsRangeMarker() bool {
return o.EndOfUnknownRange || o.EndOfTimedOutRange
}
// IsStatusMarker reports whether the object is a §11.2.1.1 status marker
// (End of Group / End of Track) rather than a real object. Markers describe
// the absence of objects: the subscription path forwards them, but FETCH
// responses never serialize them — the Object Status field "is absent in
// Objects delivered via a FETCH". Zero-length payloads with Status 0 are
// real (Normal) objects, not markers.
func (o *CachedObject) IsStatusMarker() bool {
return len(o.Payload) == 0 && o.Status != 0
}
// cacheKey is the composite (GroupID, ObjectID) key used to deduplicate
// re-Puts of the same object onto the same ring slot.
type cacheKey struct {
Group uint64
Object uint64
}
// ObjectCache is a per-track, fixed-capacity, FIFO ring-buffer object
// cache. Each [TrackEntry] holds one.
//
// Concurrency: an RWMutex guards the ring. Writes (Put / Delete) take the
// write lock and are O(1); reads (Get / GetRange / OldestRetained / Len)
// take the read lock. FETCH reads are
// O(capacity) (default 1024), so the read lock lets concurrent FETCHes — the
// flash-crowd-of-joining-subscribers case the relay is built for — scan in
// parallel and contend only with the short live-ingest Put rather than
// serialising behind one another. Stored structs are never mutated in place
// (see [ObjectCache.insertLocked]), so readers may dereference the pointers
// they collect without holding the lock.
type ObjectCache struct {
mu sync.RWMutex
// ring is a fixed-length slice of pointers; nil means "empty slot".
// head is the next write position (mod len(ring)).
ring []*CachedObject
head int
size int
// index maps live keys to their position in ring, for O(1) Get,
// overwrite-in-place on duplicate Put, and Delete.
index map[cacheKey]int
// maxAge is the read-side TTL filter. Zero disables filtering.
maxAge time.Duration
}
// effectiveMaxSize returns a non-zero capacity. Callers that pass 0
// (test helpers that want "effectively unbounded") get
// [defaultUnboundedCapacity].
func effectiveMaxSize(maxSize int) int {
if maxSize <= 0 {
return defaultUnboundedCapacity
}
return maxSize
}
// NewObjectCache constructs an empty ObjectCache.
//
// - maxSize: maximum number of stored objects per cache. <= 0 falls
// back to [defaultUnboundedCapacity].
// - maxDuration: per-object TTL applied at read time. <= 0 disables
// time-based filtering; stored objects then live until size-based
// eviction or explicit Delete.
func NewObjectCache(maxSize int, maxDuration time.Duration) *ObjectCache {
capacity := effectiveMaxSize(maxSize)
return &ObjectCache{
ring: make([]*CachedObject, capacity),
index: make(map[cacheKey]int, capacity),
maxAge: maxDuration,
}
}
// Put stores obj in the cache, taking ownership of it: the cache keeps
// obj's pointer (and its Properties / Payload slices) by reference — it
// does NOT copy them. Callers MUST NOT mutate obj or its slices after Put
// returns, since the very same struct is later handed out by Get /
// GetRange. Storing by reference is what keeps the fanout hot path free of
// per-object copies; the one allocation is the CachedObject the caller
// builds.
//
// Put overwrites obj.ReceivedAt with the current time.
//
// If the cache already holds an entry at the same {GroupID, ObjectID},
// Put replaces it (the previous struct is dropped) and does NOT advance
// the ring head — the new entry inherits the existing slot. If the ring is
// full and the key is new, the oldest entry (the one currently at the head
// position) is evicted to make room.
func (c *ObjectCache) Put(obj *CachedObject) {
if obj == nil {
return
}
// Stamp the arrival time before taking the lock so time.Now() stays out
// of the write critical section that contends with FETCH range scans.
obj.ReceivedAt = time.Now()
c.mu.Lock()
c.insertLocked(obj)
c.mu.Unlock()
}
// PutDatagram is a thin adapter that converts a §11.3 OBJECT_DATAGRAM
// into a CachedObject and stores it. Datagrams have no subgroup, so
// SubgroupID is 0; ForwardingPref records the wire shape so a FETCH
// response can replay it as a datagram even if the subscriber's transport
// supports both.
func (c *ObjectCache) PutDatagram(d *message.ObjectDatagram) {
if d == nil {
return
}
c.Put(&CachedObject{
GroupID: d.GroupID,
ObjectID: d.ObjectID,
SubgroupID: 0,
PublisherPriority: d.PublisherPriority,
ForwardingPref: ForwardingDatagram,
Status: d.ObjectStatus,
Properties: d.Properties,
Payload: d.ObjectPayload,
})
}
// insertLocked stores src (by reference) into the ring. On a duplicate key
// it replaces the slot's pointer — the previous struct is dropped, not
// reused; on a new key it consumes the head slot, evicting whatever struct
// occupied it. Structs are never mutated in place or recycled: that is
// exactly what lets Get / GetRange hand out the raw stored pointers without
// a torn-read hazard (an evicted struct is simply orphaned from the ring
// and stays valid for any existing holder). This is the single mutation
// point for ring + index + size; the caller MUST hold c.mu.
func (c *ObjectCache) insertLocked(src *CachedObject) {
key := cacheKey{Group: src.GroupID, Object: src.ObjectID}
if idx, ok := c.index[key]; ok {
c.ring[idx] = src
return
}
// New key: evict whatever currently occupies the head slot (removing it
// from the index), overwrite the slot with src, and advance head.
prev := c.ring[c.head]
if prev != nil {
delete(c.index, cacheKey{Group: prev.GroupID, Object: prev.ObjectID})
c.size--
}
c.ring[c.head] = src
c.index[key] = c.head
c.head = (c.head + 1) % len(c.ring)
c.size++
}
// notExpiredLocked reports whether obj is still within the read-side
// TTL. With maxAge <= 0, every entry is considered fresh.
// Caller must hold c.mu.
func (c *ObjectCache) notExpiredLocked(obj *CachedObject) bool {
if c.maxAge <= 0 {
return true
}
return time.Since(obj.ReceivedAt) <= c.maxAge
}
// Get returns the stored object at {group, object}, or (nil, false) if
// nothing is recorded there (never written, evicted by size pressure,
// or filtered out by TTL).
//
// The returned *CachedObject is the cache's own stored pointer, NOT a copy
// — callers MUST treat it (and its Properties / Payload slices) as
// read-only. The pointer stays valid indefinitely, even after the entry is
// evicted: [Put] never mutates a stored struct in place, it only replaces a
// ring slot's pointer, so an evicted struct is merely orphaned from the
// ring and remains safe for any existing holder.
//
// Note: this method is O(1) and copy-free. It is not used on the relay hot
// path, but the test suite exercises it heavily.
func (c *ObjectCache) Get(group, object uint64) (*CachedObject, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
idx, ok := c.index[cacheKey{Group: group, Object: object}]
if !ok {
return nil, false
}
obj := c.ring[idx]
if obj == nil || !c.notExpiredLocked(obj) {
return nil, false
}
return obj, true
}
// Len returns the number of currently-stored objects (including
// non-existence markers and including TTL-expired entries that have
// not yet been overwritten). The Len is exactly the count of live ring
// slots; with TTL enabled, callers should remember that a non-zero Len
// does not guarantee a Get will return anything.
func (c *ObjectCache) Len() int {
c.mu.RLock()
defer c.mu.RUnlock()
return c.size
}
// ---------------------------------------------------------------------------
// Range scan
// ---------------------------------------------------------------------------
// GetRange returns every stored object whose Location is in [start, end]
// (inclusive on both ends), sorted by (group, object) in the requested
// direction:
//
// - [message.GroupOrderAscending]: groups asc, objects asc within group.
// - [message.GroupOrderDescending]: groups desc, objects asc within group.
//
// Within a group the inner order is always ascending by Object ID, matching
// §11.4.3's subgroup-stream constraint.
//
// An empty or inverted range (end < start) returns nil.
//
// The returned slice holds the cache's own stored pointers (no copy);
// callers MUST treat the objects as read-only. They stay valid after
// eviction for the same reason as [ObjectCache.Get]'s result: Put never
// mutates a stored struct in place, it only replaces ring pointers.
//
// Implementation note: GetRange walks the entire ring once and filters
// matches. The ring is small (default 1024 entries) and FETCH is not
// the hot path, so the O(capacity) cost is acceptable. A sorted index
// could be added without changing the signature if profiling later shows
// the scan to dominate.
func (c *ObjectCache) GetRange(start, end message.Location, order message.GroupOrder) []*CachedObject {
if end.Less(start) {
return nil
}
c.mu.RLock()
out := make([]*CachedObject, 0)
for _, obj := range c.ring {
if obj == nil {
continue
}
if !c.notExpiredLocked(obj) {
continue
}
loc := message.Location{Group: obj.GroupID, Object: obj.ObjectID}
if loc.Less(start) {
continue
}
if end.Less(loc) {
continue
}
// Append the stored pointer directly — Put never recycles or
// mutates a stored struct, so this never aliases storage a later
// Put could overwrite.
out = append(out, obj)
}
c.mu.RUnlock()
sortObjects(out, order)
return out
}
// OldestRetained returns the lowest Location currently held by the cache —
// the eviction floor — and a bool that is false when the cache holds no live
// object.
//
// Because the ring evicts oldest-first and objects are stored in (broadly
// increasing) arrival order, the retained set is a suffix of the track by
// Location: everything below OldestRetained has either been evicted by size
// or TTL pressure, or was never cached by this relay. Either way the relay
// does not hold it. A FETCH responder uses this boundary to decide which part
// of a requested range it can answer from cache and which part it must stitch
// from upstream — a gap below the floor is "maybe exists upstream", whereas a
// gap at or above the floor is ground-truth non-existence.
//
// Like [ObjectCache.GetRange], this is an O(capacity) scan; FETCH is not the
// hot path.
func (c *ObjectCache) OldestRetained() (message.Location, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
var (
oldest message.Location
found bool
)
for _, obj := range c.ring {
if obj == nil || !c.notExpiredLocked(obj) {
continue
}
loc := message.Location{Group: obj.GroupID, Object: obj.ObjectID}
if !found || loc.Less(oldest) {
oldest = loc
found = true
}
}
return oldest, found
}
// sortObjects sorts in-place by (group, object). Group direction is
// controlled by order; objects within a group are always ascending.
// An unknown GroupOrder falls back to ascending.
func sortObjects(objs []*CachedObject, order message.GroupOrder) {
slices.SortStableFunc(objs, func(a, b *CachedObject) int {
if a.GroupID != b.GroupID {
if order == message.GroupOrderDescending {
return cmp.Compare(b.GroupID, a.GroupID)
}
return cmp.Compare(a.GroupID, b.GroupID)
}
return cmp.Compare(a.ObjectID, b.ObjectID)
})
}
// Delete removes the entry at {group, object} if any. Idempotent: a
// missing entry is a silent no-op.
//
// Note: Delete leaves a tombstone — the ring slot becomes empty but
// the head pointer is not rewound, so the freed capacity is reclaimed
// by the next Put rather than immediately. This keeps FIFO ordering
// stable across mixed Put / Delete sequences.
func (c *ObjectCache) Delete(group, object uint64) {
key := cacheKey{Group: group, Object: object}
c.mu.Lock()
defer c.mu.Unlock()
idx, ok := c.index[key]
if !ok {
return
}
c.ring[idx] = nil
delete(c.index, key)
c.size--
}
package relay
import (
"bytes"
"time"
"github.com/floatdrop/moq-go/pkg/moqt/track"
)
// CacheTTLPolicy is a per-track override for [Config.MaxCacheDuration]. The
// relay invokes it once per track at TrackEntry creation time (never on the
// fanout hot path) to decide that track's object-cache retention.
//
// Semantics:
//
// - Return a positive duration to use that TTL for the matching track.
// - Return [CacheTTLInfinite] to disable time-based eviction entirely for
// the matching track (the FIFO size cap from [Config.MaxCacheSize] still
// applies).
// - Return 0 (the zero value) to fall through to [Config.MaxCacheDuration].
//
// The policy MUST be safe for concurrent invocation, MUST NOT block, and
// SHOULD be free of side effects — it runs inside the registry's write lock.
// Implementations are typically small predicates on Name (e.g. for an MSF
// catalog track). The relay deliberately exposes only a function-shaped hook
// rather than coupling to any specific Track-Name vocabulary: the binary that
// builds the policy (cmd/relay, an embedded app, …) owns the protocol-specific
// rules.
type CacheTTLPolicy func(name track.FullTrackName) time.Duration
// CacheTTLInfinite is the sentinel a [CacheTTLPolicy] returns to request
// "no time-based eviction" for the matching track. It exists so policy authors
// can express "retain indefinitely" without knowing the object cache's
// internal "non-positive means TTL disabled" convention, and so a return value
// of 0 keeps its natural meaning ("use the default").
const CacheTTLInfinite = time.Duration(-1)
// TrackNameTTL returns a [CacheTTLPolicy] giving every track whose Name equals
// name the retention ttl, and leaving every other track on
// [Config.MaxCacheDuration]. A ttl of 0 is read as "retain indefinitely" and
// becomes [CacheTTLInfinite]; any positive ttl is honoured verbatim. An empty
// name returns nil, which disables the override entirely.
//
// Matching is namespace-agnostic: every publisher's track of that Name gets the
// same retention. That fits the MSF per-broadcaster catalog model, where each
// participant owns a namespace but they all share one catalog Name.
//
// This lives here, rather than in the binary that wants it, because it is the
// rule two binaries need and only one of them had. A relay serving MSF must
// retain catalogs longer than media: a catalog is published once on join and
// republished only when tracks change, so under the default 30-second
// retention it is evicted from the cache within the first minute of a call.
// After that a participant who joins later gets nothing from the Relative
// Joining FETCH that backfills it — and since the live SUBSCRIBE starts at the
// largest object, they never learn that participant's nickname, version or
// tracks at all. The bug is invisible from the publisher's side, because the
// people already in the room are unaffected.
//
// The choice of which Name and how long still belongs to the binary; only the
// shape of the predicate is shared.
func TrackNameTTL(name string, ttl time.Duration) CacheTTLPolicy {
if name == "" {
return nil
}
want := []byte(name)
override := ttl
if override == 0 {
override = CacheTTLInfinite
}
return func(n track.FullTrackName) time.Duration {
if bytes.Equal(n.Name, want) {
return override
}
return 0 // fall through to Config.MaxCacheDuration
}
}
// Package discovery is the relay's cross-instance track + namespace
// advertisement abstraction.
//
// The interface answers two questions: "which relay instance hosts a
// publisher for this track?" and "which relay instance serves this
// namespace prefix?" — both essential for routing in a multi-relay
// deployment.
//
// The default implementation is [MemoryStore], which keeps state local
// to a single relay process. Watch channels only see events emitted by
// the same MemoryStore, so a single relay with MemoryStore behaves
// identically to a relay with no discovery at all. Production
// deployments swap in a distributed backend (NATS JetStream KV, Redis,
// etc.) behind the same interface; the relay code does not change.
//
// The relay's [TrackRegistry] and [NamespaceRegistry] use this
// abstraction so multi-instance support is a backend swap rather than
// a rewrite.
package discovery
import (
"context"
"time"
"github.com/floatdrop/moq-go/pkg/moqt/track"
"github.com/floatdrop/moq-go/pkg/moqt/wire"
)
// Op is the kind of a discovery event. Publish announces availability;
// Unpublish announces removal. A single backend may emit either kind on
// the same key over the entry's lifetime.
type Op int
const (
// OpPublish — a track or namespace became available on a relay.
OpPublish Op = iota
// OpUnpublish — a track or namespace is no longer available.
OpUnpublish
)
// String returns "publish" or "unpublish".
func (o Op) String() string {
switch o {
case OpPublish:
return "publish"
case OpUnpublish:
return "unpublish"
}
return "unknown"
}
// TrackInfo describes a track available on a relay instance.
type TrackInfo struct {
// Key uniquely identifies the track per §2.4.1. Used as the map
// index by all backends.
Key track.Key
// FullName retains the unhashed {namespace, name} tuple so
// downstream subscribers can echo it on the wire and humans can
// read it in logs.
FullName track.FullTrackName
// Properties is the opaque Track Properties blob the upstream
// publisher attached (see §9.6). Stored by reference; callers
// MUST NOT mutate after handing it to the store.
Properties []byte
// RelayAddr identifies the relay instance hosting this track. For
// MemoryStore it is whatever the local relay registered itself as
// (typically empty in single-relay deployments). NATS/Redis
// backends use it to route upstream connections to the right peer.
RelayAddr string
// PublishedAt records when the entry was last written. Backends
// MAY use this for TTL eviction.
PublishedAt time.Time
}
// NamespaceInfo describes a namespace prefix available on a relay
// instance. Multiple TrackInfos share a NamespaceInfo iff their full
// names start with the same Prefix.
type NamespaceInfo struct {
// Prefix is the namespace tuple advertised by PUBLISH_NAMESPACE
// (§6.2 / §10.16). A zero-length tuple matches every track — used
// by SUBSCRIBE_NAMESPACE with no filter.
Prefix wire.TrackNamespace
// RelayAddr — see [TrackInfo.RelayAddr].
RelayAddr string
// PublishedAt — see [TrackInfo.PublishedAt].
PublishedAt time.Time
}
// TrackEvent is what [DiscoveryStore.WatchTracks] yields. Op tells
// callers whether the entry is being added or removed.
type TrackEvent struct {
Op Op
Info TrackInfo
}
// NamespaceEvent is what [DiscoveryStore.WatchNamespaces] yields.
type NamespaceEvent struct {
Op Op
Info NamespaceInfo
}
// DiscoveryStore is the relay's cross-instance metadata fabric.
//
// All methods are safe for concurrent use. Backends SHOULD treat
// repeated Publish for the same (Key|Prefix, RelayAddr) tuple as
// idempotent updates rather than duplicates — multiple sessions on
// the same relay can independently advertise the same track or
// namespace and the store should collapse them.
//
// Find operations return a snapshot; callers must not rely on
// subsequent reads observing the same set. Watch is the right
// primitive for "tell me when this changes".
//
// Implementations MUST honor ctx cancellation and deadlines on every
// call: the relay's registries invoke Publish/Unpublish while holding
// their internal locks (that is what keeps the store's record order
// consistent with registry state), bounding each call with a short
// deadline. A backend that ignores ctx and blocks on a dead network
// connection would stall the whole registry, not just the call.
//
// Close releases backend resources (network connections, goroutines).
// Watch channels MUST be drained or their owning context cancelled
// before Close to avoid backend-side blocking. After Close all methods
// return [ErrClosed].
type DiscoveryStore interface {
// PublishTrack advertises a track. RelayAddr / Properties /
// PublishedAt come from info; the backend uses Key as the unique
// index.
PublishTrack(ctx context.Context, info TrackInfo) error
// UnpublishTrack removes the track advertisement keyed by
// (key, relayAddr). Unknown entries are silent no-ops.
UnpublishTrack(ctx context.Context, key track.Key, relayAddr string) error
// FindTrack returns every advertisement of this track across all
// relay instances. A zero-length slice with no error means
// "nobody hosts this track right now."
FindTrack(ctx context.Context, key track.Key) ([]TrackInfo, error)
// PublishNamespace advertises a namespace prefix.
PublishNamespace(ctx context.Context, info NamespaceInfo) error
// UnpublishNamespace removes the namespace advertisement keyed by
// (prefix, relayAddr).
UnpublishNamespace(ctx context.Context, prefix wire.TrackNamespace, relayAddr string) error
// FindNamespace returns every namespace advertisement whose
// Prefix is a prefix of namespace (in the §9.5 / wire.TrackNamespace
// HasPrefix sense). A query for ["a","b","c"] matches advertised
// prefixes ["a"], ["a","b"], and ["a","b","c"]; advertised
// prefix ["a","b","c","d"] does NOT match. This is the ancestor
// direction: "which relays serve a covering prefix for this track?"
FindNamespace(ctx context.Context, namespace wire.TrackNamespace) ([]NamespaceInfo, error)
// FindNamespacesUnder is the descendant complement of FindNamespace:
// it returns every advertisement whose Prefix extends (is at or below)
// prefix. A query for ["a"] matches advertised prefixes ["a"], ["a","b"],
// and ["a","b","c"]; ["x"] does NOT match. A zero-length prefix matches
// every advertisement. It answers "which namespaces advertised across the
// deployment fall under this SUBSCRIBE_NAMESPACE prefix?", used to seed a
// new namespace subscriber with state advertised before it registered.
FindNamespacesUnder(ctx context.Context, prefix wire.TrackNamespace) ([]NamespaceInfo, error)
// WatchTracks returns a channel that first delivers the current set of
// track advertisements as OpPublish events (the snapshot), then streams
// every subsequent Publish / Unpublish the backend observes (local +
// remote), until ctx is cancelled or the store is closed. The channel is
// closed when the watch ends.
//
// The snapshot→follow handoff is gapless: across it no event is missed or
// duplicated. A consumer that wants "current state plus every change from
// here on" therefore needs only this call, never a separate Find followed
// by a Watch (which would race any event landing between the two).
//
// The initial snapshot is delivered in full — a consumer interested only in
// deltas can ignore the leading OpPublish burst. For events after the
// snapshot a slow consumer must not block other watchers: backends SHOULD
// use a per-watcher buffered channel and drop live events on overflow with
// a logged warning.
WatchTracks(ctx context.Context) (<-chan TrackEvent, error)
// WatchNamespaces streams namespace events. Same snapshot-then-follow
// contract as WatchTracks.
WatchNamespaces(ctx context.Context) (<-chan NamespaceEvent, error)
// Withdraw removes every advertisement this store published for relayAddr.
// It is the graceful-shutdown counterpart of the Publish calls: a relay
// calls it before it stops accepting connections so peers stop resolving it
// as an upstream while it drains (§3.6) rather than dialing an endpoint that
// is about to close. Peers observe the removals as OpUnpublish events on
// their watches, exactly as they would individual Unpublish calls.
//
// Withdraw is terminal for that address's advertising side: afterwards
// PublishTrack / PublishNamespace for relayAddr MUST NOT restore an
// advertisement, and MUST return [ErrWithdrawn] — a publisher arriving while
// the relay drains must not put it back into the fabric. Everything else
// stays usable: the relay keeps resolving *other* relays' advertisements
// through Find / Watch for the rest of its drain, and the per-track
// Unpublish calls that session teardown issues degrade to no-ops.
//
// Withdrawing an address that advertised nothing, or withdrawing twice, is a
// silent no-op. Unlike Close, Withdraw releases no backend resources.
Withdraw(ctx context.Context, relayAddr string) error
// Close releases backend resources.
Close() error
}
package discovery
import (
"context"
"errors"
"log/slog"
"sync"
"time"
"github.com/floatdrop/moq-go/pkg/moqt/track"
"github.com/floatdrop/moq-go/pkg/moqt/wire"
)
// nowFunc is the time source used by [MemoryStore.PublishTrack] /
// [MemoryStore.PublishNamespace] to stamp PublishedAt when the caller
// leaves it zero. Overridable from tests if deterministic timestamps
// ever become useful.
var nowFunc = time.Now
// ErrClosed is returned by [DiscoveryStore] methods after Close has run.
var ErrClosed = errors.New("discovery: store closed")
// ErrWithdrawn is returned by the Publish calls for a relay address that has
// been withdrawn (see [DiscoveryStore.Withdraw]). It is not a failure: the
// relay is shutting down, and re-advertising it would undo the withdrawal.
var ErrWithdrawn = errors.New("discovery: relay withdrawn")
// defaultWatchBufferSize bounds the per-watcher event channel. A slow
// consumer can drop up to this many events before the backend stops
// trying to deliver. The size is a compromise between burst tolerance
// and memory pressure under a misbehaving subscriber; 32 is large enough
// to absorb typical bursty publish patterns and small enough that a
// stalled consumer is noticed within a few seconds at typical event
// rates.
const defaultWatchBufferSize = 32
// MemoryStore is the in-process [DiscoveryStore] for single-relay
// deployments. All state is local; Watch channels only see events the
// MemoryStore itself emitted, so a single relay using MemoryStore
// behaves identically to one with no discovery at all. Distributed
// backends (NATS / Redis) replace this without touching relay internals.
//
// Concurrency: the store is safe for concurrent use. Internally a
// single sync.RWMutex guards the maps and watcher lists — readers
// (Find*, Watch*) take the RLock; writers (Publish/Unpublish/Close)
// take the Lock. Watch delivery itself is non-blocking: the publish
// path sends on the watcher channel with a default case so a slow
// consumer cannot stall the publisher.
type MemoryStore struct {
mu sync.RWMutex
tracks map[trackEntryKey]TrackInfo
namespaces map[namespaceEntryKey]NamespaceInfo
trackWatch []chan TrackEvent
nsWatch []chan NamespaceEvent
closed bool
// withdrawn records relay addresses that called Withdraw, so a late
// Publish cannot re-advertise a relay that is draining.
withdrawn map[string]struct{}
log *slog.Logger
bufferSize int
}
// trackEntryKey indexes a TrackInfo by (key, relayAddr): the same track
// hosted on different relays produces distinct entries.
type trackEntryKey struct {
key track.Key
addr string
}
// namespaceEntryKey indexes a NamespaceInfo. The prefix is stored as
// its wire-encoded byte string (canonical key for nested tuples — see
// [track.Key.namespace] for the same trick).
type namespaceEntryKey struct {
prefix string
addr string
}
// NewMemoryStore constructs an empty in-memory store. The optional
// logger is used for warn-level reports when a slow watcher causes
// events to be dropped. A nil logger uses [slog.Default].
func NewMemoryStore(opts ...MemoryStoreOption) *MemoryStore {
s := &MemoryStore{
tracks: make(map[trackEntryKey]TrackInfo),
namespaces: make(map[namespaceEntryKey]NamespaceInfo),
withdrawn: make(map[string]struct{}),
bufferSize: defaultWatchBufferSize,
}
for _, opt := range opts {
opt(s)
}
if s.log == nil {
s.log = slog.Default()
}
return s
}
// MemoryStoreOption tweaks a [MemoryStore] at construction time.
type MemoryStoreOption func(*MemoryStore)
// WithWatchBufferSize overrides the per-watcher channel capacity.
// Values <= 0 fall back to the package default.
func WithWatchBufferSize(n int) MemoryStoreOption {
return func(s *MemoryStore) {
if n > 0 {
s.bufferSize = n
}
}
}
var _ DiscoveryStore = (*MemoryStore)(nil)
// PublishTrack stores info; an existing entry with the same
// (Key, RelayAddr) is replaced atomically. The store ignores PublishedAt
// if zero (caller-friendly default).
func (s *MemoryStore) PublishTrack(_ context.Context, info TrackInfo) error {
s.mu.Lock()
if s.closed {
s.mu.Unlock()
return ErrClosed
}
if _, ok := s.withdrawn[info.RelayAddr]; ok {
s.mu.Unlock()
return ErrWithdrawn
}
if info.PublishedAt.IsZero() {
info.PublishedAt = nowFunc()
}
s.tracks[trackEntryKey{key: info.Key, addr: info.RelayAddr}] = info
// Send under the lock (non-blocking) so a send can't race a watcher
// channel close (lifecycle / Close, which close under the same lock).
// Count the drops and log AFTER unlocking — a slow logger must not stall
// other store operations while s.mu is held.
dropped := fanout(s.trackWatch, TrackEvent{Op: OpPublish, Info: info})
s.mu.Unlock()
s.warnDropped(dropped, OpPublish, "key", info.Key)
return nil
}
// UnpublishTrack removes the (key, relayAddr) entry. Missing entries
// are no-ops; no event is emitted in that case.
func (s *MemoryStore) UnpublishTrack(_ context.Context, key track.Key, relayAddr string) error {
s.mu.Lock()
if s.closed {
s.mu.Unlock()
return ErrClosed
}
idx := trackEntryKey{key: key, addr: relayAddr}
info, ok := s.tracks[idx]
if !ok {
s.mu.Unlock()
return nil
}
delete(s.tracks, idx)
// Send under the lock, log after — see [MemoryStore.PublishTrack].
dropped := fanout(s.trackWatch, TrackEvent{Op: OpUnpublish, Info: info})
s.mu.Unlock()
s.warnDropped(dropped, OpUnpublish, "key", key)
return nil
}
// FindTrack returns every advertisement of key across all RelayAddrs.
func (s *MemoryStore) FindTrack(_ context.Context, key track.Key) ([]TrackInfo, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if s.closed {
return nil, ErrClosed
}
var out []TrackInfo
for k, v := range s.tracks {
if k.key == key {
out = append(out, v)
}
}
return out, nil
}
// PublishNamespace stores info; identical (Prefix, RelayAddr) replaces.
func (s *MemoryStore) PublishNamespace(_ context.Context, info NamespaceInfo) error {
s.mu.Lock()
if s.closed {
s.mu.Unlock()
return ErrClosed
}
if _, ok := s.withdrawn[info.RelayAddr]; ok {
s.mu.Unlock()
return ErrWithdrawn
}
if info.PublishedAt.IsZero() {
info.PublishedAt = nowFunc()
}
s.namespaces[namespaceEntryKey{prefix: namespaceWireKey(info.Prefix), addr: info.RelayAddr}] = info
// Send under the lock, log after — see [MemoryStore.PublishTrack].
dropped := fanout(s.nsWatch, NamespaceEvent{Op: OpPublish, Info: info})
s.mu.Unlock()
s.warnDropped(dropped, OpPublish, "prefix", info.Prefix)
return nil
}
// UnpublishNamespace removes the (prefix, relayAddr) entry.
func (s *MemoryStore) UnpublishNamespace(_ context.Context, prefix wire.TrackNamespace, relayAddr string) error {
s.mu.Lock()
if s.closed {
s.mu.Unlock()
return ErrClosed
}
idx := namespaceEntryKey{prefix: namespaceWireKey(prefix), addr: relayAddr}
info, ok := s.namespaces[idx]
if !ok {
s.mu.Unlock()
return nil
}
delete(s.namespaces, idx)
// Send under the lock, log after — see [MemoryStore.PublishTrack].
dropped := fanout(s.nsWatch, NamespaceEvent{Op: OpUnpublish, Info: info})
s.mu.Unlock()
s.warnDropped(dropped, OpUnpublish, "prefix", prefix)
return nil
}
// FindNamespace returns every advertisement whose Prefix is a non-strict
// prefix of namespace. A query for ["a","b","c"] matches stored prefixes
// ["a"], ["a","b"], ["a","b","c"]; ["a","b","c","d"] does NOT match.
func (s *MemoryStore) FindNamespace(_ context.Context, namespace wire.TrackNamespace) ([]NamespaceInfo, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if s.closed {
return nil, ErrClosed
}
var out []NamespaceInfo
for _, v := range s.namespaces {
if namespace.HasPrefix(v.Prefix) {
out = append(out, v)
}
}
return out, nil
}
// FindNamespacesUnder returns every advertisement whose Prefix extends prefix
// (the descendant direction — see [DiscoveryStore.FindNamespacesUnder]).
func (s *MemoryStore) FindNamespacesUnder(_ context.Context, prefix wire.TrackNamespace) ([]NamespaceInfo, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if s.closed {
return nil, ErrClosed
}
var out []NamespaceInfo
for _, v := range s.namespaces {
if v.Prefix.HasPrefix(prefix) {
out = append(out, v)
}
}
return out, nil
}
// WatchTracks delivers the current tracks as an OpPublish snapshot, then every
// subsequent track event, until ctx is cancelled or the store is closed (see
// [DiscoveryStore.WatchTracks]). Snapshotting and registering happen under the
// same lock, so the handoff is gapless: a publish concurrent with this call
// either lands in the snapshot or fans out to the channel afterwards, never
// both and never neither. The channel is sized to hold the whole snapshot plus
// the usual live headroom (see [WithWatchBufferSize]), so seeding never drops.
func (s *MemoryStore) WatchTracks(ctx context.Context) (<-chan TrackEvent, error) {
s.mu.Lock()
if s.closed {
s.mu.Unlock()
return nil, ErrClosed
}
ch := make(chan TrackEvent, len(s.tracks)+s.bufferSize)
for _, v := range s.tracks {
ch <- TrackEvent{Op: OpPublish, Info: v} // fits: capacity includes len(tracks)
}
s.trackWatch = append(s.trackWatch, ch)
s.mu.Unlock()
go s.watchTrackLifecycle(ctx, ch)
return ch, nil
}
// WatchNamespaces — see [MemoryStore.WatchTracks].
func (s *MemoryStore) WatchNamespaces(ctx context.Context) (<-chan NamespaceEvent, error) {
s.mu.Lock()
if s.closed {
s.mu.Unlock()
return nil, ErrClosed
}
ch := make(chan NamespaceEvent, len(s.namespaces)+s.bufferSize)
for _, v := range s.namespaces {
ch <- NamespaceEvent{Op: OpPublish, Info: v} // fits: capacity includes len(namespaces)
}
s.nsWatch = append(s.nsWatch, ch)
s.mu.Unlock()
go s.watchNamespaceLifecycle(ctx, ch)
return ch, nil
}
// Withdraw drops every track and namespace advertisement whose RelayAddr is
// relayAddr, emitting an OpUnpublish for each so watchers converge exactly as
// they would on individual Unpublish calls, and records the address so a later
// Publish returns [ErrWithdrawn] instead of re-advertising it. See
// [DiscoveryStore.Withdraw].
func (s *MemoryStore) Withdraw(_ context.Context, relayAddr string) error {
s.mu.Lock()
if s.closed {
s.mu.Unlock()
return ErrClosed
}
s.withdrawn[relayAddr] = struct{}{}
// Deleting the current key while ranging is defined behaviour in Go, and
// the events go out under the lock for the same reason the Publish paths
// do — see [MemoryStore.PublishTrack].
dropped := 0
for idx, info := range s.tracks {
if idx.addr != relayAddr {
continue
}
delete(s.tracks, idx)
dropped += fanout(s.trackWatch, TrackEvent{Op: OpUnpublish, Info: info})
}
for idx, info := range s.namespaces {
if idx.addr != relayAddr {
continue
}
delete(s.namespaces, idx)
dropped += fanout(s.nsWatch, NamespaceEvent{Op: OpUnpublish, Info: info})
}
s.mu.Unlock()
s.warnDropped(dropped, OpUnpublish, "relay_addr", relayAddr)
return nil
}
// Close closes every active watch channel and rejects further
// operations with [ErrClosed].
func (s *MemoryStore) Close() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return nil
}
s.closed = true
// Close under the lock so closes cannot race a concurrent fanout send
// (which also holds s.mu). A lifecycle goroutine that wakes after this
// sees s.closed and does not double-close.
for _, ch := range s.trackWatch {
close(ch)
}
for _, ch := range s.nsWatch {
close(ch)
}
s.trackWatch = nil
s.nsWatch = nil
return nil
}
// fanout delivers ev to each watcher with a non-blocking send and returns the
// number of watchers whose buffer was full (so the event was dropped). It MUST
// be called with s.mu held: the sends are then mutually exclusive with watcher
// channel closes (lifecycle / Close), which would otherwise race a send and
// panic. The publish path still never blocks on a slow watcher (sends are
// non-blocking); the caller logs the returned drop count AFTER releasing s.mu
// so a slow log sink cannot stall other store operations under the lock.
func fanout[T any](watchers []chan T, ev T) int {
dropped := 0
for _, ch := range watchers {
select {
case ch <- ev:
default:
dropped++
}
}
return dropped
}
// warnDropped logs that n events were dropped to slow watchers, if any. Called
// after s.mu is released so the (potentially blocking) log sink never contends
// the store lock. keyAttr/keyVal carry the identifying field of the dropped
// event (e.g. "key"/track.Key or "prefix"/wire.TrackNamespace).
func (s *MemoryStore) warnDropped(n int, op Op, keyAttr string, keyVal any) {
if n == 0 {
return
}
s.log.Warn("discovery: dropped events on slow watcher(s)",
"op", op.String(), keyAttr, keyVal, "dropped", n)
}
// watchTrackLifecycle removes ch from the watch list when ctx is
// cancelled or the store is closed. The channel is closed exactly once.
func (s *MemoryStore) watchTrackLifecycle(ctx context.Context, ch chan TrackEvent) {
<-ctx.Done()
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
// Close already shut us down; channel already closed.
return
}
for i, w := range s.trackWatch {
if w == ch {
s.trackWatch = append(s.trackWatch[:i], s.trackWatch[i+1:]...)
break
}
}
// Close under the lock so it cannot race a concurrent fanout send (which
// also holds s.mu). Once removed from s.trackWatch above, no later fanout
// will reference ch.
close(ch)
}
func (s *MemoryStore) watchNamespaceLifecycle(ctx context.Context, ch chan NamespaceEvent) {
<-ctx.Done()
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return
}
for i, w := range s.nsWatch {
if w == ch {
s.nsWatch = append(s.nsWatch[:i], s.nsWatch[i+1:]...)
break
}
}
// Close under the lock — see [MemoryStore.watchTrackLifecycle].
close(ch)
}
// namespaceWireKey serialises a TrackNamespace into a canonical byte
// string suitable for use as a map key. The same trick is used by
// track.Key so callers don't have to worry about field-count vs.
// concatenated-bytes collisions.
func namespaceWireKey(ns wire.TrackNamespace) string {
w := wire.NewWriter(nil)
w.TrackNamespace(ns)
return string(w.Bytes())
}
package relay
import (
"context"
"errors"
"log/slog"
"github.com/floatdrop/moq-go/pkg/moqt/message"
)
// runDatagramLoop is the datagram fanout entry point. It pulls
// [message.ObjectDatagram]s off the session and forwards each to every
// downstream subscriber whose §5.1.2 filter passes, with the Track Alias
// remapped to that subscriber's per-session outbound alias.
//
// Per §11.3 a datagram is a fire-and-forget delivery — the underlying
// transport drops oversized or unschedulable datagrams without notification.
// The loop therefore swallows per-send failures: there is no slow-reader
// escalation analogous to the subgroup path, and there is no
// stream-lifecycle propagation — each datagram is its own self-contained
// §11.4.3-style "stream".
//
// Termination:
//
// - Transport-level errors from [session.Session.ReceiveDatagram]
// (session closed, ctx cancelled, PROTOCOL_VIOLATION on parse) end
// the loop and propagate to [sessionHandler.run]'s aggregator.
// - Per-datagram lookup misses (unknown Track Alias, evicted track entry)
// drop the datagram silently — §11.3 explicitly permits this.
func (h *sessionHandler) runDatagramLoop(ctx context.Context) error {
for {
d, err := h.sess.ReceiveDatagram(ctx)
if err != nil {
if errors.Is(err, context.Canceled) {
return ctx.Err()
}
return err
}
h.handleDatagram(ctx, d)
}
}
// handleDatagram is the per-datagram fanout. It mirrors [runFanout]'s
// per-object body but with a flat structure: no per-subscriber writer
// goroutine, no §11.4.3 stream-lifecycle bookkeeping, no ObjectIDDelta
// re-encoding (datagrams carry an absolute Object ID, §11.3.1).
func (h *sessionHandler) handleDatagram(ctx context.Context, d *message.ObjectDatagram) {
key, ok := h.sess.LookupInboundTrackAlias(d.TrackAlias)
if !ok {
// §11.3: an unknown Track Alias MAY be dropped or briefly buffered for
// reordering against the establishing control message. We drop.
h.log.LogAttrs(ctx, slog.LevelDebug, "datagram: unknown inbound Track Alias",
slog.Uint64("alias", d.TrackAlias))
return
}
entry, ok := h.tracks.Get(key)
if !ok {
h.log.LogAttrs(ctx, slog.LevelDebug, "datagram: track entry gone",
slog.Uint64("alias", d.TrackAlias))
return
}
// §2.1 dedup across redundant upstream publishers, same ledger as the
// subgroup path (handler_fanout): the first copy of {GroupID, ObjectID}
// wins; later copies from peer upstreams are dropped so each subscriber
// sees the object exactly once — and the loser neither re-caches nor
// re-bumps the watermark.
if !entry.ClaimDelivered(d.GroupID, d.ObjectID) {
return
}
// §10.2.17: a forwarded datagram counts towards the track's
// LARGEST_OBJECT watermark just like a subgroup object does.
entry.UpdateLargest(message.Location{Group: d.GroupID, Object: d.ObjectID})
// Cache via the per-track ObjectCache. The cache retains the payload +
// properties BY REFERENCE (see cache.PutDatagram); ReceiveDatagram
// hands out caller-owned buffers, so nothing here mutates them after
// the Put.
entry.Cache.PutDatagram(d)
downstream := entry.CopyDownstream()
for _, sub := range downstream {
if !sub.IsEstablished() {
continue
}
// One lock acquisition folds the §9.2 Forward-State gate and the
// §5.1.2 filter test, exactly like the subgroup fanout: a paused
// subscription (Forward State 0) receives no datagrams. There is
// no per-datagram stream to reset, so the groupExhausted signal
// is irrelevant here.
// Datagrams have no subgroup; §5.1.4 SUBGROUP_FILTER treats them as
// subgroup 0. Object ID / Priority / Properties feed the other filters.
forward, _ := sub.ForwardDecision(d.GroupID, d.ObjectID, 0, d.PublisherPriority, d.Properties)
if !forward {
continue
}
// Re-encode the datagram with the subscriber's outbound
// Track Alias. Per §9.7 the relay does not modify any other
// object fields — Type, Group ID, Object ID, Priority,
// Properties, Status, Payload all forward verbatim.
out := *d
out.TrackAlias = sub.TrackAlias
if err := sub.Session.SendDatagram(&out); err != nil {
// Per §11.3 datagrams may be dropped silently when
// the transport can't deliver them; treat send errors
// the same way and log at Debug for postmortem.
h.log.LogAttrs(ctx, slog.LevelDebug, "datagram: SendDatagram failed",
slog.Uint64("sub_id", sub.ID),
slog.String("err", err.Error()))
}
}
}
package relay
import (
"context"
"errors"
"io"
"log/slog"
"sync"
"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/cache"
"github.com/floatdrop/moq-go/pkg/relay/internal/registry"
)
// fwdObject pairs a SubgroupObject with its absolute Object ID on the
// inbound stream. The writer goroutine needs both: the ObjectIDDelta on the
// wire is *relative to the previous object on its own stream*, so once
// filtering or §11.4.3 gap-driven stream resets can punch holes in the
// forwarded object sequence the relay MUST re-encode the delta on the
// outbound side or the subscriber's decoded absolute IDs will drift.
type fwdObject struct {
obj *message.SubgroupObject
absID uint64
enqueuedAt time.Time // stamped in publish; used for the §8 lag window
// first marks the subgroup's true first object: the first object read
// off an inbound stream whose header had the §11.4.2 FIRST_OBJECT bit
// set. A writer whose outbound stream begins with this object — and
// only such a stream — sets FIRST_OBJECT on its own header.
first bool
}
// subgroupWriterSet is the parent-managed payload of a
// [registry.SharedSubgroup]: the one outbound writer per downstream subscriber
// for a single (GroupID, SubgroupID), shared across every inbound runFanout
// goroutine producing that Subgroup (including redundant upstream publishers).
// All access is serialised by the [registry.SharedSubgroup.Mu] the registry
// hands back, so two contributors never double-open a writer or race the
// joiner scan.
//
// The map key is the *registry.DownstreamSub pointer: the sub's identity is
// exactly what the writer serves, with no ID indirection (IDs are globally
// unique since allocSubID went process-wide, but the pointer needs no lookup).
// A nil value records "sub wasn't Established when scanned" so we don't retry.
type subgroupWriterSet struct {
writers map[*registry.DownstreamSub]*subgroupWriter
// hdr is the canonical SUBGROUP_HEADER (the first contributor's), reused for
// every writer open so joiners added by a redundant contributor get the same
// Group/Subgroup framing. TrackAlias is overwritten per subscriber.
hdr message.SubgroupHeader
// gen is the entry.downstreamGen observed on the last joiner scan, so the
// O(len(Downstream)) scan is skipped while membership is unchanged.
gen uint64
// sawClean records that at least one contributor ended its inbound stream
// cleanly (io.EOF). With redundant upstreams a clean completion of the
// Subgroup is authoritative: the merged outbound stream then FINs even if a
// peer upstream reset. resetCode is the §3.3.4 code used only when NO
// contributor ended cleanly (every upstream reset). These are written by each
// contributor at release under the SharedSubgroup mutex.
sawClean bool
resetCode moqt.StreamResetCode
}
// resolveImplicitSubgroupID handles §11.4.2 SUBGROUP_ID_MODE 0b01, where the
// Subgroup ID equals the stream's first Object ID: it reads the first object
// and rewrites hdr in place to the explicit form. The returned pending
// object must be processed as the stream's first (its delta is the absolute
// ID). Headers in any other mode pass through untouched with (nil, true).
//
// ok=false means the stream ended before its identity resolved — an empty
// 0b01 stream (clean EOF) has nothing to forward, and a read error means the
// stream died; there is no subgroup state to join or tear down yet, so the
// caller just returns.
func (h *sessionHandler) resolveImplicitSubgroupID(
ctx context.Context,
stream *session.IncomingSubgroupStream,
hdr *message.SubgroupHeader,
) (pending *message.SubgroupObject, ok bool) {
if hdr.SubgroupIDMode != message.SubgroupIDImplicitFirstObject {
return nil, true
}
if hdr.ReplayingSubgroup {
// §11.4.2's receiver rule is mechanical (Subgroup ID = first
// object on the stream), but on a replay the first object is not
// necessarily the subgroup's first — the implied ID is only as
// reliable as the sender. Worth a trace when it leads to
// mis-keyed subgroups.
h.log.LogAttrs(ctx, slog.LevelDebug,
"fanout: implicit-first-object Subgroup ID on a replay stream",
slog.Uint64("group", hdr.GroupID))
}
obj, err := stream.ReadObject()
if err != nil {
if !errors.Is(err, io.EOF) {
h.log.LogAttrs(ctx, slog.LevelDebug,
"fanout: inbound stream ended before first-object Subgroup ID resolved",
slog.String("err", err.Error()))
// Stop a publisher still writing into a stream nobody reads
// (a STOP_SENDING on an already-reset stream is a transport
// no-op).
stream.Cancel(moqt.StreamResetInternalError)
}
return nil, false
}
hdr.SubgroupID = obj.ObjectIDDelta // first object: the delta IS the absolute ID
hdr.SubgroupIDMode = message.SubgroupIDExplicit
return obj, true
}
// runFanout is the subgroup-stream fanout entry point. One inbound
// SUBGROUP_HEADER stream produces one or more outbound SUBGROUP_HEADER
// streams per downstream subscriber, with the publisher's Track Alias
// remapped to the subscriber's per-session outbound alias.
//
// §9.5 multiple publishers: many inbound streams may carry the same
// (GroupID, SubgroupID) — independent publishers, a switchover overlap, or
// redundant origins. They share ONE outbound writer per subscriber (§2.2 forbids
// splitting a Subgroup across streams) via the entry's [registry.SharedSubgroup],
// and the §2.1 dedup ledger ([registry.TrackEntry.ClaimDelivered]) drops the
// second and later copy of each {GroupID, ObjectID} so the subscriber sees each
// object exactly once. A single publisher is just the one-contributor case.
//
// The per-subscriber forward path runs in a dedicated [subgroupWriter]
// goroutine fed by a bounded send queue: §5.1.2 filters are evaluated
// pre-enqueue and ObjectIDDelta re-encoded outbound so drops don't shift the
// subscriber's absolute IDs. The inbound FIN-vs-reset distinction propagates to
// the outbound streams only when the LAST contributor leaves.
func (h *sessionHandler) runFanout(ctx context.Context, stream *session.IncomingSubgroupStream) {
hdr := stream.Header
key, ok := stream.TrackKey()
if !ok {
// Per §11.1, a Track Alias on a data stream must have been
// previously registered (via SUBSCRIBE_OK or PUBLISH). An
// unknown alias is a publisher protocol error scoped to this
// stream — reset the stream but keep the session alive.
h.log.LogAttrs(ctx, slog.LevelWarn, "fanout: unknown inbound Track Alias",
slog.Uint64("alias", hdr.TrackAlias))
stream.Cancel(moqt.StreamResetInternalError)
return
}
entry, ok := h.tracks.Get(key)
if !ok {
// Alias was registered but the entry has since been removed —
// the subscription terminated between alias registration and
// the first object arriving.
h.log.LogAttrs(ctx, slog.LevelDebug, "fanout: track entry gone, dropping stream",
slog.Uint64("alias", hdr.TrackAlias))
stream.Cancel(moqt.StreamResetInternalError)
return
}
// One TrackRef for the whole stream: it allocates, and everything below
// that reports it does so per object.
ref := h.trackRef(entry.FullName)
// §11.4.2 mode 0b01: the Subgroup ID is implied by the stream's FIRST
// object's ID. Everything from here on keys on hdr.SubgroupID — the
// shared-subgroup key, the cache (and thus FETCH responses), and the
// outbound header template — so resolve it before touching any of that.
// The pre-read object is fed through the normal loop below.
pending, ok := h.resolveImplicitSubgroupID(ctx, stream, &hdr)
if !ok {
return
}
// Join (or create) the shared fan-out state for this (group, subgroup). The
// first contributor opens writers for the current Downstream snapshot;
// redundant contributors reuse the existing set and only add joiners /
// deliver deduped objects.
sgKey := registry.SubgroupKey{Group: hdr.GroupID, Subgroup: hdr.SubgroupID}
sg, created := entry.AcquireSubgroup(sgKey, func() any {
return &subgroupWriterSet{
writers: make(map[*registry.DownstreamSub]*subgroupWriter),
hdr: hdr,
}
})
set, _ := sg.Set.(*subgroupWriterSet)
if created {
// Open initial writers from the current Downstream snapshot, under
// sg.Mu so a concurrent contributor's joiner scan can't double-open.
// Per §9.7 we drain even with zero subscribers (publisher flow control);
// the per-object joiner scan picks up subs that join mid-stream.
initialSubs, gen := entry.CopyDownstreamWithGen()
pubTimeouts := entry.DeliveryTimeouts()
sg.Mu.Lock()
set.gen = gen
for _, sub := range initialSubs {
h.openWriterForSub(ctx, set.hdr, sub, set.writers, pubTimeouts, ref)
}
sg.Mu.Unlock()
}
// inboundReset records THIS contributor's termination mode: false on clean
// io.EOF, true on any other read error. inboundResetCode is the §3.3.4 code.
// They are applied to the outbound streams only when this is the LAST
// contributor to leave the Subgroup — a single publisher dropping out (clean
// or reset) while others still feed the Subgroup must not disturb the
// subscribers' streams (§9.5 fault tolerance).
var (
inboundReset bool
inboundResetCode = moqt.StreamResetCancelled
)
defer func() {
// Record this contributor's outcome into the shared set before we drop
// our reference, so the last contributor can decide FIN vs reset over ALL
// contributors (§11.4.3 redundancy: a clean completion by any upstream
// FINs the merged stream even if a peer reset).
sg.Mu.Lock()
if inboundReset {
set.resetCode = inboundResetCode
} else {
set.sawClean = true
}
last := entry.ReleaseSubgroup(sgKey)
if !last {
sg.Mu.Unlock()
return // other upstreams still feed this Subgroup — leave writers up.
}
// Last contributor: close and drain every downstream writer. FIN if any
// upstream completed cleanly; otherwise reset with the recorded code.
reset := !set.sawClean
code := set.resetCode
ws := make([]*subgroupWriter, 0, len(set.writers))
for _, w := range set.writers {
if w == nil {
continue
}
wReset, wCode := reset, code
// §11.4.3: a Subgroup whose group has fallen outside the
// subscription's range (e.g. a REQUEST_UPDATE narrowed it) MUST
// be reset, not FIN'd, even on a clean inbound EOF — a FIN would
// falsely signal the group was fully delivered.
if !wReset && registry.GroupOutOfRange(hdr.GroupID, w.sub.GetFilter()) {
wReset, wCode = true, moqt.StreamResetCancelled
}
w.close(wReset, wCode)
ws = append(ws, w)
}
sg.Mu.Unlock()
joinWriters(ws)
}()
// objectID tracks the running absolute Object ID across the inbound
// stream. Per §11.4.2, ObjectIDDelta on the wire is the absolute Object
// ID for the first object, and (currentID - previousID - 1) for every
// subsequent object — so sequential IDs all encode as 0.
var (
objectID uint64
firstObj = true
// terminalSeen records that a terminal-status object (EndOfGroup /
// EndOfTrack) has been seen on this Subgroup stream. Per §11.4.3
// no further objects may follow it; one that does makes the track
// malformed (§2.4.2). Tracked per inbound stream so a redundant
// upstream's own terminal accounting is independent.
terminalSeen bool
)
for {
// The first object of a mode-0b01 stream was already read during
// Subgroup ID resolution above.
obj, err := pending, error(nil)
pending = nil
if obj == nil {
obj, err = stream.ReadObject()
}
if err != nil {
if errors.Is(err, io.EOF) {
return // clean end of stream — last contributor will FIN.
}
if errors.Is(err, context.Canceled) {
// ctx cancellation is treated as a reset — the
// session is going away and we can't safely
// FIN the outbound streams.
inboundReset = true
return
}
h.log.LogAttrs(ctx, slog.LevelDebug, "fanout: inbound ReadObject failed",
slog.String("err", err.Error()))
// A malformed object (not a transport reset) leaves the
// publisher still writing; stop it. On an already-reset
// stream the STOP_SENDING is a transport no-op.
stream.Cancel(moqt.StreamResetInternalError)
inboundReset = true
return
}
// §11.4.3 / §2.4.2: an object after a terminal-status object on the
// same Subgroup stream is a protocol violation. Reset the inbound and
// (if last) outbound streams with MALFORMED_TRACK rather than forwarding.
if terminalSeen {
h.log.LogAttrs(ctx, slog.LevelDebug,
"fanout: object after EndOfGroup/EndOfTrack — malformed track",
slog.Uint64("group", hdr.GroupID), slog.Uint64("subgroup", hdr.SubgroupID))
stream.Cancel(moqt.StreamResetMalformedTrack)
inboundReset = true
inboundResetCode = moqt.StreamResetMalformedTrack
return
}
// §11.4.2: the subgroup's true first object is the first object on
// an inbound stream whose header carried the FIRST_OBJECT bit
// (ReplayingSubgroup false). Writers use this to set the bit on
// their own outbound headers only when their stream really begins
// with it.
isTrueFirst := firstObj && !hdr.ReplayingSubgroup
if firstObj {
objectID = obj.ObjectIDDelta
firstObj = false
} else {
objectID += obj.ObjectIDDelta + 1
}
// §11.4.3: terminal status is tracked per inbound stream regardless of
// whether this copy wins the dedup claim below, so a post-terminal object
// on THIS stream is still caught at the top of the next iteration.
terminal := obj.IsTerminal()
// §2.1 dedup across redundant upstreams: claim {GroupID, ObjectID} on the
// entry's persistent, group-windowed ledger. The first upstream to reach an
// object forwards it; a later copy from a peer — even one that is lagging,
// or that arrives on a fresh stream after the first upstream's stream has
// already FIN'd — is dropped here so the subscriber sees each object once.
// Done outside sg.Mu (its own lock) so dedup losers never touch the writer
// set.
if !entry.ClaimDelivered(hdr.GroupID, objectID) {
if terminal {
terminalSeen = true
}
continue // redundant copy already forwarded by a peer upstream.
}
// Counted after the dedup claim, so this is objects the relay is
// actually responsible for delivering — not raw wire arrivals, which
// on a redundantly-fed track would double-count.
h.metrics.ObjectReceived(ref, hdr.SubgroupID)
// Deliver to the shared writer set under sg.Mu so joiner detection, writer
// open, and the publish loop are atomic against a concurrent contributor
// and the last-contributor teardown.
sg.Mu.Lock()
// Cache the object (for joining FETCHes) before bumping LARGEST_OBJECT so
// a concurrent handleSubscribe-then-FETCH that snapshots the new watermark
// always finds it cached.
entry.Cache.Put(&cache.CachedObject{
GroupID: hdr.GroupID,
ObjectID: objectID,
SubgroupID: hdr.SubgroupID,
PublisherPriority: hdr.PublisherPriority,
ForwardingPref: cache.ForwardingSubgroup,
Status: obj.ObjectStatus,
Properties: obj.Properties,
Payload: obj.Payload,
})
// Atomically bump §10.2.17 LARGEST_OBJECT and snapshot any Downstream
// subs that joined since the last scan. The entry.mu acquisition inside
// serialises with handleSubscribe's AddDownstreamSnapshotLargest: a new
// sub either snapshots the pre-update Largest AND appears in newSubs
// (delivered live below), or snapshots the post-update Largest (its
// Joining FETCH covers this object — already cached above).
loc := message.Location{Group: hdr.GroupID, Object: objectID}
var newSubs []*registry.DownstreamSub
newSubs, set.gen = entry.UpdateLargestAndDetectNew(loc,
func(s *registry.DownstreamSub) bool { _, ok := set.writers[s]; return ok }, set.gen)
for _, sub := range newSubs {
h.openWriterForSub(ctx, set.hdr, sub, set.writers, entry.DeliveryTimeouts(), ref)
}
// §5.1.2 filter evaluation per-subscriber, pre-enqueue. A filter miss
// means we don't take a queue slot. Per §9.7 the relay does not modify
// the object; it is purely a forwarding gate.
for _, w := range set.writers {
if w == nil {
continue
}
// One lock acquisition folds the §9.2 Forward-State gate and the
// §5.1.2 filter test. A paused subscription (Forward State 0) takes
// no queue slot; control messages on its request stream still flow.
forward, groupExhausted := w.sub.ForwardDecision(
hdr.GroupID, objectID, hdr.SubgroupID, hdr.PublisherPriority, obj.Properties)
if !forward {
// §11.4.3: if the subscription has narrowed so this whole
// group is now out of range, the stream will never carry
// another object — reset it promptly (not FIN). close is
// idempotent; the teardown still waits on w.done.
if groupExhausted {
w.close(true, moqt.StreamResetCancelled)
}
continue
}
w.publish(fwdObject{obj: obj, absID: objectID, first: isTrueFirst})
}
sg.Mu.Unlock()
if terminal {
terminalSeen = true
}
}
}
// openWriterForSub builds a subgroupWriter for sub and records it in writers
// keyed by the *registry.DownstreamSub pointer. If sub is not Established,
// writers[sub] is set to nil so we don't retry. The §11.4.2 FIRST_OBJECT bit
// is not decided here: the writer computes it per outbound stream, from
// whether the first object it actually writes is the subgroup's true first
// (see [fwdObject.first]) — a joiner, a filter that drops the head of the
// subgroup, and a §11.4.3 gap-reopen all end up with the bit clear.
//
// Deliberately NO transport I/O happens here: both call sites run under
// sg.Mu — the lock every contributor takes per forwarded object — and
// writing the SUBGROUP_HEADER can block on ONE subscriber's flow control,
// which would stall the entire subgroup's fanout (plus the inbound read
// loop) on one slow peer. The writer goroutine opens the stream and writes
// the header lazily, before the first object it forwards; a subscriber whose
// filter drops every object never gets an empty header-only stream at all.
func (h *sessionHandler) openWriterForSub(
ctx context.Context,
hdr message.SubgroupHeader,
sub *registry.DownstreamSub,
writers map[*registry.DownstreamSub]*subgroupWriter,
pubTimeouts message.DeliveryTimeouts,
ref TrackRef,
) {
if _, already := writers[sub]; already {
return
}
if !sub.IsEstablished() {
writers[sub] = nil
return
}
subHdr := hdr
subHdr.TrackAlias = sub.TrackAlias
// ioCtx bounds every blocking stream operation the writer performs
// (open, header write, object writes): cancelIO unblocks a writer
// wedged on a subscriber that stopped reading, so the teardown join
// cannot be held hostage (see [subgroupWriter.join]).
ioCtx, cancelIO := context.WithCancel(ctx)
w := &subgroupWriter{
sub: sub,
ctx: ioCtx,
cancelIO: cancelIO,
hdr: subHdr,
inbox: make(chan fwdObject, h.sendQueueSize),
done: make(chan struct{}),
log: h.log,
metrics: h.metrics,
ref: ref,
maxDropsBeforeReset: h.maxDropsBeforeReset,
maxLag: h.maxFanoutLag,
// §8: the two halves stay apart. The §12.1 / §12.2 first-object
// override belongs to the publisher's half alone, and
// OutgoingSubgroupStream applies it — being the only party that sees
// the object carrying it — so handing over a pre-merged pair would let
// an override outrank a shorter subscriber timeout.
pubTimeouts: pubTimeouts,
subTimeouts: sub.GetDeliveryTimeouts(),
}
writers[sub] = w
h.spawn(w.run)
}
// subgroupWriter is the per-subscriber writer goroutine. It consumes
// objects from an inbox channel and writes them to outbound
// SUBGROUP_HEADER streams on the subscriber's session.
//
// §11.4.3 lifecycle:
//
// - When the next forwarded Object ID is not (prevWrittenID + 1) — i.e.
// the inbound or filter punched a hole — the current outbound stream
// is reset and a fresh one opened. Per §11.4.3 the relay MUST NOT
// forward a non-consecutive Object on an existing subgroup stream.
// - On clean inbound EOF the outbound stream is FIN'd; on inbound error
// (or ctx-cancel) it is reset.
// - When the inbox overflows (publisher fills it faster than the QUIC
// send window drains), the publish path drops the object. Each object
// records its enqueue time; if the writer later dequeues one that waited
// longer than maxLag, the subscriber has fallen too far behind the live
// edge (§8 Delivery Timeouts) and the writer resets its outbound stream
// with TOO_FAR_BEHIND (§3.3.4), transitions the [registry.DownstreamSub] to
// [registry.SubTerminated], and exits. The optional maxDropsBeforeReset cap is a
// coarse backstop on cumulative drops, reset with EXCESSIVE_LOAD instead.
// - When the §8 delivery timeouts elapse, [session.OutgoingSubgroupStream]
// resets this one stream with DELIVERY_TIMEOUT and the writer stops
// forwarding — WITHOUT terminating the subscription. The two escalations
// are not interchangeable, and §3.3.4 is explicit about which is which:
// TOO_FAR_BEHIND says "the corresponding subscription ... is being
// terminated", whereas DELIVERY_TIMEOUT says only "a delivery timeout was
// exceeded for this stream". So a subgroup the publisher marked as
// short-lived expires on its own without costing the subscriber the track,
// which is what lets a publisher stripe disposable data (an enhancement
// layer, say) across subgroups the relay may shed under load.
type subgroupWriter struct {
sub *registry.DownstreamSub
// ctx is writer-scoped: it bounds every blocking stream operation
// (open, header write, object writes). cancelIO cancels it, resetting
// the in-flight stream via the per-stream bridge in reopen — the
// escape hatch for a writer wedged on a subscriber that stopped
// reading (close only closes the inbox, and the §8 lag check runs
// only between dequeues).
ctx context.Context
cancelIO context.CancelFunc
hdr message.SubgroupHeader // template; TrackAlias already remapped
out *session.OutgoingSubgroupStream // nil until run opens it lazily
unbridge func() bool // stops the current stream's ctx→Cancel bridge
inbox chan fwdObject
done chan struct{}
log *slog.Logger
metrics Metrics
// ref labels every Metrics call this writer makes. Built once by
// openWriterForSub because constructing it allocates (see
// [sessionHandler.trackRef]) and publish runs per object.
ref TrackRef
maxDropsBeforeReset int
maxLag time.Duration
// pubTimeouts and subTimeouts are the §8 delivery-timeout halves for this
// (subgroup, subscriber), handed to every outbound stream the writer opens
// and resolved there once the first object's properties are known. Zero
// values disable the corresponding dimension.
pubTimeouts message.DeliveryTimeouts
subTimeouts message.DeliveryTimeouts
closeOnce sync.Once
dropsMu sync.Mutex
drops int
closed bool // set under dropsMu inside close
inboundReset bool // set under dropsMu inside close
inboundResetCode moqt.StreamResetCode // §3.3.4 reset code when inboundReset; set inside close
}
// publish does a non-blocking send onto the inbox, stamping the enqueue time
// so the writer goroutine can measure how long the object waited before it was
// written (the §8 lag window — see [subgroupWriter.run]). On overflow the
// object is dropped; if the optional MaxDropsBeforeReset cap is enabled and
// exceeded, the writer is closed in reset mode so its goroutine terminates the
// subscription.
//
// After close has been called the writer no longer accepts objects; publish
// returns silently in that case to avoid sending on a closed channel
// (which would panic).
func (w *subgroupWriter) publish(fwd fwdObject) {
w.dropsMu.Lock()
if w.closed {
w.dropsMu.Unlock()
return
}
w.dropsMu.Unlock()
fwd.enqueuedAt = time.Now()
select {
case w.inbox <- fwd:
w.metrics.ObjectForwarded(w.ref, w.hdr.SubgroupID)
default:
w.metrics.ObjectDropped(w.ref, w.hdr.SubgroupID)
w.dropsMu.Lock()
w.drops++
drops := w.drops
capped := w.maxDropsBeforeReset > 0 && w.drops > w.maxDropsBeforeReset
w.dropsMu.Unlock()
w.log.Debug("fanout: dropped object on full inbox",
"sub_id", w.sub.ID, "drops", drops)
if capped {
w.log.Warn("fanout: subscriber hit MaxDropsBeforeReset cap, terminating",
"sub_id", w.sub.ID, "drops", drops)
// Close the inbox; the writer goroutine's post-drain path resets
// the outbound stream with EXCESSIVE_LOAD and terminates the sub.
w.close(true, moqt.StreamResetExcessiveLoad)
}
}
}
// run is the writer goroutine. It drains the inbox and writes objects to the
// outbound stream until the inbox is closed, then decides the stream's fate
// (FIN / reset) from the flags close recorded — see the post-drain block below.
//
// If WriteObject fails mid-stream (QUIC-level error) the writer cancels the
// outbound stream, marks writeFailed, and keeps draining until close is called,
// keeping publish non-blocking without a second drain goroutine.
func (w *subgroupWriter) run() {
defer close(w.done)
var (
prevID uint64
hasWritten bool
writeFailed bool
)
// reopen cancels the current outbound stream (if any) and opens a fresh
// one, writing its SUBGROUP_HEADER. Used for the lazy first open and
// when a §11.4.3 gap is detected — the current stream is no longer
// eligible to carry the next forwarded object. The effective §7.2
// priority is reapplied on the new stream.
//
// first is whether the object about to go out is the subgroup's true
// first object: only then does the header carry the §11.4.2
// FIRST_OBJECT bit ("the first object in this subgroup stream is the
// first object published in the subgroup by the original publisher").
// A gap-reopen or a filtered head therefore clears it, and a header
// whose Subgroup ID was implied by its first object (mode 0b01) is
// rewritten to the explicit form — the replayed stream's first object
// would imply the wrong ID.
//
// All blocking I/O is bounded by w.ctx: the open itself via
// OpenSubgroupContext, and the stream's later writes via a
// context.AfterFunc bridge to Cancel.
reopen := func(first bool) bool {
if w.unbridge != nil {
w.unbridge()
w.unbridge = nil
}
if w.out != nil {
w.out.Cancel(moqt.StreamResetCancelled)
}
hdr := w.hdr
hdr.ReplayingSubgroup = !first
if !first && hdr.SubgroupIDMode == message.SubgroupIDImplicitFirstObject {
// A replay stream's first object would imply the wrong ID, so
// spell the Subgroup ID out. (Defensive: runFanout resolves
// 0b01 headers to the explicit form at ingest, so the template
// should never carry this mode here.)
hdr.SubgroupIDMode = message.SubgroupIDExplicit
}
fresh, err := w.sub.Session.OpenSubgroupContext(w.ctx, hdr)
if err != nil {
w.log.Debug("fanout: OpenSubgroup (reopen) failed",
"sub_id", w.sub.ID, "err", err.Error())
w.out = nil
return false
}
// §8: enforce the delivery timeouts on this stream.
// WithDeliveryTimeouts returns a copy, so the bridge below must cancel
// the copy — both wrap the same SendStream, but only the copy is the
// one this writer goes on to use. Two zero pairs disable both
// dimensions, which is the no-timeout behaviour every existing caller
// had.
fresh = fresh.WithDeliveryTimeouts(w.pubTimeouts, w.subTimeouts)
w.out = fresh
w.unbridge = context.AfterFunc(w.ctx, func() {
fresh.Cancel(moqt.StreamResetCancelled)
})
hasWritten = false
w.applyPriority()
// §11.4.3: keep the new stream's header reliable across resets.
w.out.MarkReliable()
return true
}
defer func() {
if w.unbridge != nil {
w.unbridge()
}
}()
// failWrites latches this writer broken: no further stream writes will
// be attempted, and — via w.closed — contributors stop enqueueing (and
// stop counting ObjectForwarded for objects that would be discarded).
// The inbox channel itself is only ever closed by close() under sg.Mu.
var writeFailedLatched bool
failWrites := func() {
writeFailed = true
if !writeFailedLatched {
writeFailedLatched = true
w.dropsMu.Lock()
w.closed = true
w.dropsMu.Unlock()
}
}
var lagExceeded bool
for fwd := range w.inbox {
// §8 lag window: how long this object waited in the queue is how far
// behind the live edge the subscriber is. Once that exceeds maxLag the
// subscriber has been unable to keep up for too long — stop draining
// and escalate to a reset below.
if w.maxLag > 0 && time.Since(fwd.enqueuedAt) > w.maxLag {
w.log.Warn("fanout: subscriber exceeded MaxFanoutLag, terminating",
"sub_id", w.sub.ID, "lag", time.Since(fwd.enqueuedAt).String())
lagExceeded = true
break
}
if writeFailed {
continue
}
// Lazy first open: openWriterForSub runs under sg.Mu and must not
// perform transport I/O, so the stream (and its SUBGROUP_HEADER
// write, which can block on this subscriber's flow control) happens
// here, on this subscriber's own goroutine.
if w.out == nil {
if !reopen(fwd.first) {
failWrites()
continue
}
}
// §11.4.3: the relay MUST NOT forward a non-consecutive
// Object on an existing subgroup stream. When the next
// forwarded Object ID isn't prevID + 1 — gap from a filter
// drop, REQUEST_UPDATE end-shift, or out-of-order inbound —
// reset the current outbound stream and open a new one.
if hasWritten && fwd.absID != prevID+1 {
w.metrics.SubgroupStreamReset(w.ref, w.hdr.SubgroupID, ResetCauseGap)
if !reopen(fwd.first) {
failWrites()
continue
}
}
// Re-encode ObjectIDDelta against the previous *forwarded*
// Object ID on this outbound stream. After a fresh stream
// open hasWritten is false and the first object carries its
// absolute ID as the delta.
out := *fwd.obj
if !hasWritten {
out.ObjectIDDelta = fwd.absID
} else {
out.ObjectIDDelta = fwd.absID - prevID - 1
}
// §8 measures OBJECT_DELIVERY_TIMEOUT from when this object was
// received, not from when this stream opened. Passing enqueuedAt means
// a subscriber that keeps up is never reset however long it stays
// subscribed, while one whose queue is ageing is cut off on the first
// stale object — which is the distinction the timeout exists to draw.
//
// enqueuedAt approximates §8's instant from above: the clause names the
// FIRST payload byte, and this is stamped once the object has been read
// whole, deduped and cached. The gap is the object's inbound transfer
// time, so the error is always lenient and grows with object size —
// widest on exactly the congested upstream the timeout is there for.
// Closing it means recording the instant in the inbound read, which
// enqueuedAt cannot do alone: it is also the MaxFanoutLag measurement
// below, and that window means time spent queued, not object age.
if err := w.out.WriteObjectReceivedAt(fwd.enqueuedAt, &out); err != nil {
// §8 OBJECT_DELIVERY_TIMEOUT: WriteObject has already reset this
// stream with DELIVERY_TIMEOUT (§3.3.4) and that code is
// stream-scoped — the subgroup is abandoned, the subscription is
// not. Resetting again (with INTERNAL_ERROR) would overwrite a
// reason the subscriber acts on, so this returns before the
// generic branch. The subscription-scoped escalation stays where
// it was: the maxLag / TOO_FAR_BEHIND path after the loop.
if errors.Is(err, session.ErrDeliveryTimeout) {
w.log.Debug("fanout: delivery timeout, abandoning subgroup stream",
"sub_id", w.sub.ID, "group", w.hdr.GroupID,
"subgroup", w.hdr.SubgroupID)
w.metrics.SubgroupStreamReset(w.ref, w.hdr.SubgroupID, ResetCauseDeliveryTimeout)
w.out = nil
failWrites()
continue
}
w.log.Debug("fanout: WriteObject failed",
"sub_id", w.sub.ID, "err", err.Error())
w.metrics.SubgroupStreamReset(w.ref, w.hdr.SubgroupID, ResetCauseWriteError)
w.out.Cancel(moqt.StreamResetInternalError)
w.out = nil
failWrites()
continue
}
prevID = fwd.absID
hasWritten = true
// §11.4.3: extend the reliable boundary to include this object so a
// later reset (gap-reopen, inbound-reset propagation) still delivers
// the Objects already forwarded on this stream.
w.out.MarkReliable()
}
w.dropsMu.Lock()
dropCapped := w.maxDropsBeforeReset > 0 && w.drops > w.maxDropsBeforeReset
inboundReset := w.inboundReset
inboundResetCode := w.inboundResetCode
w.dropsMu.Unlock()
if lagExceeded || dropCapped {
// §8 slow-reader escalation: the subscriber fell too far behind the
// live edge (lag window) or hit the optional drop cap. Reset the
// outbound subgroup stream and terminate the subscription; the
// subscriber must re-subscribe (likely with a more selective filter or
// lower priority) to resume forwarding.
//
// §3.3.4 reset code: a lag-window breach is precisely TOO_FAR_BEHIND
// (the subscriber can't keep up with the live edge). The cumulative
// drop-cap backstop is server-side resource pressure, so it uses
// EXCESSIVE_LOAD. A lag breach wins if both fired.
resetCode := moqt.StreamResetTooFarBehind
cause := ResetCauseTooFarBehind
if dropCapped && !lagExceeded {
resetCode = moqt.StreamResetExcessiveLoad
cause = ResetCauseExcessiveLoad
}
w.metrics.SubscriptionResetSlowReader(w.ref, cause)
// Refuse further enqueues so contributors stop stamping objects into
// an inbox nobody drains. The channel itself stays open (publish and
// close serialize under sg.Mu; the writer must not close it from
// here). Objects already queued stay pinned until the whole writer
// becomes unreachable after the last contributor's teardown joins
// this goroutine — bounded by the queue size.
w.dropsMu.Lock()
w.closed = true
w.dropsMu.Unlock()
if w.out != nil {
w.out.Cancel(resetCode)
}
w.sub.Terminate()
// Also cancel the subscriber's request stream so the
// handleSubscribe goroutine's readSubscribeUpdates loop returns and
// its defer removes this registry.DownstreamSub from the registry.TrackRegistry.
// Without this the sub would linger in registry.SubTerminated state in
// entry.Downstream until the subscriber's session itself
// dies — runFanout would skip it (because !IsEstablished()),
// but the registry entry would stay around.
if w.sub.Stream != nil {
w.sub.Stream.CancelRead(uint64(resetCode))
w.sub.Stream.CancelWrite(uint64(resetCode))
}
return
}
if writeFailed {
// Outbound stream is already cancelled (or never opened).
return
}
if w.out == nil {
// Either every object was filtered before the lazy first open (no
// outbound stream ever existed) or a reopen failed; nothing to close.
return
}
if inboundReset {
// Inbound reset/error propagation per §11.4.3 ("Processing a
// reset means that there might be other objects in the
// Subgroup beyond the last one received. A relay might
// immediately reset the corresponding downstream stream...").
// inboundResetCode carries the §3.3.4 reason (CANCELLED for an
// upstream reset / ctx-cancel, MALFORMED_TRACK for a §11.4.3
// post-terminal-object violation).
w.metrics.SubgroupStreamReset(w.ref, w.hdr.SubgroupID, ResetCauseInboundReset)
w.out.Cancel(inboundResetCode)
return
}
// Clean inbound FIN propagation: every forwarded object that this
// subscription wanted was delivered, so we FIN the outbound stream
// per §11.4.3.
_ = w.out.Close()
}
// applyPriority pushes the §7.2 effective priority for this writer's current
// outbound stream into the transport. It is called on stream open and §11.4.3
// reopen, so a mid-stream SUBSCRIBER_PRIORITY change takes effect on the next
// (re)open rather than in-flight. The key combines the publisher-priority,
// Group ID and Subgroup ID from the inbound header with the subscriber-priority
// and group-order from the subscription (§7.2 rules 1–4).
func (w *subgroupWriter) applyPriority() {
if w.out == nil {
return
}
w.out.SetSendPriority(w.sub.EffectiveStreamPriority(
w.hdr.PublisherPriority, w.hdr.GroupID, w.hdr.SubgroupID,
))
}
// close is idempotent. The reset argument is recorded so the writer
// goroutine's post-drain path can decide between FIN and Cancel on its
// current outbound stream; code is the §3.3.4 reason used when reset is true.
// Multiple callers may race to close; the first to enter the sync.Once wins,
// which matches the §11.4.3 intent: once an outbound stream's fate is decided,
// later changes don't apply.
//
// close never interrupts in-flight stream I/O — even in reset mode the
// writer first drains the objects already queued (they arrived before the
// inbound stream's fate was known and the subscriber is entitled to them).
// A writer that cannot finish because a write is wedged on the subscriber's
// flow control is bounded by [subgroupWriter.join].
func (w *subgroupWriter) close(reset bool, code moqt.StreamResetCode) {
w.closeOnce.Do(func() {
w.dropsMu.Lock()
w.closed = true
w.inboundReset = reset
w.inboundResetCode = code
w.dropsMu.Unlock()
close(w.inbox)
})
}
// defaultWriterJoinTimeout bounds [joinWriters] when no MaxFanoutLag is
// configured. It only matters for a writer wedged in a blocking stream
// write (subscriber alive but not reading), so it can be generous.
const defaultWriterJoinTimeout = 5 * time.Second
// joinTimeout is the escalation deadline for [joinWriters]: a healthy
// writer either finishes its drain within the §8 lag window or terminates
// itself via the lag check, so MaxFanoutLag (when configured) also bounds
// how long a drain can legitimately take.
func (w *subgroupWriter) joinTimeout() time.Duration {
if w.maxLag > 0 {
return w.maxLag
}
return defaultWriterJoinTimeout
}
// joinWriters waits for every writer goroutine to finish after close. A
// writer wedged inside a blocking stream write (open, header, or object —
// the subscriber is alive but not reading) never dequeues again, so neither
// the closed inbox nor the §8 lag check can end it; without a bound the
// caller (the subgroup's last inbound contributor) would be held hostage
// until the subscriber's session dies. All writers share ONE escalation
// deadline: when it expires, every still-running writer's stream I/O is
// cancelled at once — unblocking the wedged writes — so N stalled
// subscribers cost one timeout, not N.
func joinWriters(ws []*subgroupWriter) {
if len(ws) == 0 {
return
}
t := time.NewTimer(ws[0].joinTimeout()) // same handler config across ws
defer t.Stop()
for i, w := range ws {
select {
case <-w.done:
continue
case <-t.C:
for _, u := range ws[i:] {
select {
case <-u.done:
continue
default:
}
u.log.Warn("fanout: writer did not finish draining, cancelling its stream I/O",
"sub_id", u.sub.ID)
u.cancelIO()
}
for _, u := range ws[i:] {
<-u.done
}
return
}
}
}
package relay
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"math"
"slices"
"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/cache"
"github.com/floatdrop/moq-go/pkg/relay/internal/registry"
)
// defaultUpstreamFetchTimeout bounds an upstream stitch FETCH when the
// downstream supplied no FILL_TIMEOUT. It keeps a fetch-capable upstream that
// nonetheless stalls (or never answers FETCH) from wedging the downstream
// handler: the stitch degrades to cache-only once it elapses.
const defaultUpstreamFetchTimeout = 5 * time.Second
// handleFetch implements FETCH (§9.4, §10.13): validate the requested range,
// reply FETCH_OK, open a FETCH_HEADER uni-stream, and serialise the cached
// objects in the requested group order. Gaps in the response stream are how
// the spec signals "objects do not exist" (§11.4.4).
//
// The below-floor portion of the range — objects the relay evicted or never
// cached — is stitched from an upstream FETCH when one is reachable; see
// [sessionHandler.stitchedFetchObjects]. Whatever no source could vouch for
// is covered by §11.4.4.2 End of Unknown Range markers, so a gap always means
// authoritative non-existence.
// trackKnown reports whether entry stands for a track the relay actually knows
// of. Bare existence does not say so: subscribeUpstreamOnSession creates the
// entry before the upstream round trip that would confirm the track, because
// it must be in place before the §11.1 Track Alias in SUBSCRIBE_OK can route
// (#85). Between those two points the entry describes a track nobody has
// vouched for yet.
//
// The distinction is visible on the wire. Answering a FETCH from such an entry
// falls through to the §10.13 "no Objects have been published" rule and
// returns INVALID_RANGE — "the range you asked for cannot be satisfied" —
// where §10.6 DOES_NOT_EXIST, "the track or namespace is not available at the
// publisher", is the truthful answer. A client deciding whether to retry, and
// with what, needs them kept apart.
//
// A watermark means a publisher has vouched for the track even if the
// subscription that carried it has since gone; a registered subscription means
// one is vouching for it now.
func trackKnown(entry *registry.TrackEntry) bool {
if _, ok := entry.GetLargest(); ok {
return true
}
// FETCH is not a hot path (see GetRange), so the copies are fine.
return len(entry.CopyUpstream()) > 0 || len(entry.CopyDownstream()) > 0
}
func (h *sessionHandler) handleFetch(ctx context.Context, req *session.Request, msg *message.Fetch) {
if err := h.auth.AuthorizeFetch(ctx, h.sess, msg); err != nil {
h.rejectAuth(ctx, req, "Fetch", err)
return
}
fullName := track.FullTrackName{Namespace: msg.Namespace, Name: msg.Name}
entry, ok := h.tracks.Get(fullName.Key())
if !ok || !trackKnown(entry) {
_ = req.RejectError(moqt.RequestDoesNotExist, "relay: track not known")
return
}
largest, hasLargest := entry.GetLargest()
if !hasLargest {
// §10.13: "If no Objects have been published for the track or Start
// Location is greater than the Largest Object the publisher MUST
// return REQUEST_ERROR with error code INVALID_RANGE."
_ = req.RejectError(moqt.RequestInvalidRange, "relay: no objects published")
return
}
// draft-20 moved the FETCH range out of the message and into the
// LOCATION_FILTER parameter (§5.1.2), inclusive at both ends. An absent
// filter fetches the whole track up to Largest Object.
filter, err := message.LocationFilterFromParam(msg.Parameters)
if err != nil {
_ = req.RejectError(moqt.RequestInvalidFilter, "relay: malformed LOCATION_FILTER")
return
}
if filter == nil {
filter = &message.LocationFilter{}
}
start := filter.Start(largest, hasLargest)
// §10.13: Start > Largest is INVALID_RANGE.
if largest.Less(start) {
_ = req.RejectError(moqt.RequestInvalidRange, "relay: start beyond largest object")
return
}
// A 4-field filter can name an end below its own start (EndGroupDelta 0 with
// EndObject < StartObject), which §5.1.2 does not itself forbid. Answering it
// would put us in violation of §10.14 — "If End Location is smaller than the
// Start Location in the corresponding FETCH the receiver MUST close the
// session with a PROTOCOL_VIOLATION" — so one malformed FETCH would tear down
// every other subscription on the session. Reject the request instead.
if end, ok := filter.End(); ok && end.Less(start) {
_ = req.RejectError(moqt.RequestInvalidRange, "relay: end before start")
return
}
order := fetchGroupOrder(msg.Parameters)
fillTimeout := resolveFillBudget(msg.Parameters)
rangeFilters, ok := h.fetchRangeFilters(ctx, req, msg.Parameters)
if !ok {
return
}
// The response EndLocation is fixed by the watermark (§10.14) and is
// independent of which objects we end up streaming, so reply FETCH_OK
// before doing any (possibly slow) upstream stitching.
endLocation := capFetchEndLocation(filter, largest)
if err := req.Reply(&message.FetchOK{
EndLocation: endLocation,
TrackProperties: entry.GetProperties(),
}); err != nil {
h.log.LogAttrs(ctx, slog.LevelDebug, "FETCH_OK reply failed",
slog.String("err", err.Error()))
return
}
// Serve (and account for) only the range FETCH_OK announced: everything
// past the capped EndLocation is outside the response by definition, so
// neither objects nor §11.4.4.2 unknown markers may reference it.
h.serveFetchObjects(ctx, req, "fetch", msg.RequestID, entry, fullName,
start, endLocation, order, fillTimeout, rangeFilters)
}
// resolveFillBudget reads FILL_TIMEOUT (§10.2.5) and resolves the "absent"
// case to the local default, so downstream a zero means only what §10.2.5 says
// it means: do not wait for upstream at all.
func resolveFillBudget(ps message.Parameters) time.Duration {
if d, ok := message.FillTimeoutFromParamOK(ps); ok {
return d
}
return defaultUpstreamFetchTimeout
}
// fetchRangeFilters parses and validates the §5.1.4 Range Filters on a FETCH's
// parameters against the negotiated MAX_FILTER_RANGES. On an invalid or
// over-limit filter it answers REQUEST_ERROR INVALID_FILTER (§10.6) and returns
// ok=false, so the caller aborts before replying FETCH_OK.
func (h *sessionHandler) fetchRangeFilters(
ctx context.Context, req *session.Request, ps message.Parameters,
) (*message.RangeFilterSet, bool) {
rf, err := message.RangeFiltersFromParams(ps)
if err == nil && rf != nil {
err = rf.Validate(h.sess.MaxFilterRanges())
}
if err != nil {
h.log.LogAttrs(ctx, slog.LevelDebug, "FETCH range filter rejected",
slog.String("err", err.Error()))
_ = req.RejectError(moqt.RequestInvalidFilter, err.Error())
return nil, false
}
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. Scaffolding lives in [readRequestStream].
func (h *sessionHandler) readFetchUpdates(ctx context.Context, req *session.Request, out *session.OutgoingFetchStream) {
updates := h.sess.NewRequestUpdateLimiter()
readRequestStream(ctx, req.Stream, func(m message.Message) bool {
if upd, ok := m.(*message.RequestUpdate); ok {
// §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 !h.handleFollowupTokens(ctx, upd) {
return false
}
h.handleFetchUpdate(ctx, req, out, upd)
updates.Responded()
}
return true
})
}
// handleFetchUpdate applies a REQUEST_UPDATE (§10.9) to an in-flight FETCH.
// A FETCH response is a finished snapshot by the time the data stream is
// FIN'd, so the relay has no live parameters to mutate — but it must still
// validate the update and answer with the single mandated REQUEST_OK /
// REQUEST_ERROR. Per §10.9, a FETCH whose REQUEST_UPDATE fails differs from
// a SUBSCRIBE: there is no PUBLISH_DONE for a FETCH, so the relay resets the
// FETCH data stream instead.
func (h *sessionHandler) handleFetchUpdate(
ctx context.Context,
req *session.Request,
out *session.OutgoingFetchStream,
upd *message.RequestUpdate,
) {
if err := validateFetchUpdateParams(upd.Parameters); err != nil {
h.log.LogAttrs(ctx, slog.LevelDebug, "FETCH REQUEST_UPDATE parameter parse failed",
slog.String("err", err.Error()))
_ = req.Reply(&message.RequestError{
ErrorCode: moqt.RequestMalformedTrack,
ErrorReason: err.Error(),
})
// §10.9: a failed FETCH update resets the FETCH data stream.
out.Cancel(moqt.StreamResetInternalError)
return
}
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()))
}
}
// validateFetchUpdateParams checks the parameters of a FETCH REQUEST_UPDATE.
// FETCH does not carry a Forward State (its response is a finished snapshot),
// so the only thing the relay validates here is the GROUP_ORDER enum (§10.2.8).
//
// TODO(draft-19): §10.2.8 mandates a session-level PROTOCOL_VIOLATION for an
// out-of-range GROUP_ORDER; the SUBSCRIBE / SUBSCRIBE_TRACKS paths were
// promoted to close the session (see [checkGroupOrderParam]), but the FETCH
// paths (this one and the initial standalone/joining FETCH) still scope it to
// a REQUEST_ERROR pending the same promotion.
func validateFetchUpdateParams(ps message.Parameters) error {
if p, ok := ps.Find(message.ParamGroupOrder); ok {
switch message.GroupOrder(p.Byte) {
case message.GroupOrderAscending, message.GroupOrderDescending:
default:
return fmt.Errorf("invalid GROUP_ORDER value 0x%X (§10.2.8)", p.Byte)
}
}
return nil
}
// fetchGroupOrder pulls the GROUP_ORDER parameter (§10.2.8) out of a
// FETCH's Parameters list. Defaults to ascending when omitted; the
// FETCH responder uses this to choose between ascending and descending
// traversal through the cache.
func fetchGroupOrder(ps message.Parameters) message.GroupOrder {
p, ok := ps.Find(message.ParamGroupOrder)
if !ok {
return message.GroupOrderAscending
}
g := message.GroupOrder(p.Byte)
if g == message.GroupOrderDescending {
return g
}
return message.GroupOrderAscending
}
// capFetchEndLocation resolves a FETCH's end from its Location filter and
// caps it at Largest Object per §10.14: "This is the End Location from the
// FETCH request Location Filter parameter unless the requested range extends
// beyond Largest Object at the time the request was processed."
//
// draft-20 made both the request range and FETCH_OK's End Location inclusive
// (§5.1.2), so — unlike draft-19's "last Object plus 1, or 0 for the whole
// group" encoding — no exclusive/inclusive conversion is involved.
func capFetchEndLocation(filter *message.LocationFilter, largest message.Location) message.Location {
end, ok := filter.End()
if !ok || largest.Less(end) {
// §5.1.2: "When they are omitted from a Fetch, the EndGroup and
// EndObject are Largest Object."
return largest
}
return end
}
// stitchedFetchObjects answers a FETCH range from the relay's cache, filling
// the below-floor portion the relay does not hold from an upstream FETCH when
// one is reachable (§9.4 upstream stitching).
//
// Everything below the cache's eviction floor (see
// [cache.ObjectCache.OldestRetained]) was evicted or never cached, so a gap
// there might still exist upstream whereas a gap at/above the floor is
// ground-truth non-existence. The handler splits the request at the floor,
// fetches [requestStart, floor) from an established upstream, and concatenates
// it with the cached part — the two are disjoint by Location, so the result is
// correctly ordered. With no FETCH-able upstream (or on error/timeout) it
// serves what the cache has and covers the below-floor remainder with a
// §11.4.4.2 End of Unknown Range marker, since a plain gap would falsely
// assert non-existence (§11.4.4). Upstream-fetched objects are NOT cached
// back: the FIFO ring is keyed by arrival, so old backfill would evict live
// objects.
func (h *sessionHandler) stitchedFetchObjects(
ctx context.Context,
entry *registry.TrackEntry,
fullName track.FullTrackName,
requestStart message.Location,
requestEndIncl message.Location,
order message.GroupOrder,
fillTimeout time.Duration,
) []*cache.CachedObject {
cacheObjs := entry.Cache.GetRange(requestStart, requestEndIncl, order)
// Determine the inclusive upper bound of the below-floor sub-range the
// relay cannot answer from cache.
upEndIncl := requestEndIncl
if floor, hasFloor := entry.Cache.OldestRetained(); hasFloor {
pred, ok := fetchPredecessor(floor)
if !ok {
return cacheObjs // floor == {0,0}: nothing exists below it
}
if pred.Less(upEndIncl) {
upEndIncl = pred
}
}
if upEndIncl.Less(requestStart) {
return cacheObjs // the request starts at/above the floor — no gap
}
// GetRange and OldestRetained are two separate cache reads: an eviction
// or TTL expiry between them can raise the floor above snapshot entries,
// making the upstream sub-range [requestStart, upEndIncl] overlap the
// snapshot. mergeFetchObjects relies on the two sources being disjoint
// by Location (a duplicate would serialize a non-ascending Object ID),
// so clip the snapshot to strictly above the sub-range.
cacheObjs = slices.DeleteFunc(cacheObjs, func(o *cache.CachedObject) bool {
return !upEndIncl.Less(message.Location{Group: o.GroupID, Object: o.ObjectID})
})
up := h.pickFetchUpstream(entry)
if up == nil {
// No reachable upstream: the below-floor sub-range has unknown
// status, not ground-truth non-existence. A plain gap in a
// FIN-terminated response asserts the latter (§11.4.4), so cover
// the sub-range with an End of Unknown Range marker instead. This
// is the unknown-status case, not the §10.2.5 budget case — nothing
// timed out, we simply have no source to ask.
return mergeFetchObjects(order,
unknownWholeRange(requestStart, upEndIncl, order), cacheObjs)
}
upstreamObjs := h.fetchUpstreamRange(
ctx, up, fullName, requestStart, upEndIncl, order, fillTimeout,
)
if len(upstreamObjs) == 0 {
// A clean-FIN, uncapped, empty upstream response: the upstream
// authoritatively asserted the whole sub-range non-existent, which
// a plain gap encodes exactly. (Every unknown outcome returns at
// least a marker element.)
return cacheObjs
}
return mergeFetchObjects(order, upstreamObjs, cacheObjs)
}
// pickFetchUpstream returns an Established, fetch-capable upstream on a
// different session 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).
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 {
return u
}
}
return nil
}
// fetchUpstreamRange issues a standalone FETCH for the inclusive range
// [start, endIncl] on the upstream's session, awaits the response stream via
// the relay's fetch router, and returns the decoded objects in the requested
// group order (the upstream FETCH carries the same GROUP_ORDER parameter).
//
// The returned slice preserves what the upstream did and did not vouch for,
// so the downstream response stays truthful under §11.4.4's gap rule (a gap
// in a FIN-terminated response asserts non-existence):
//
// - Upstream End of Unknown Range markers (§11.4.4.2, 0x10C) are kept as
// [cache.CachedObject] marker elements and re-emitted downstream.
// - End of Non-Existent Range markers (0x8C) are dropped: a plain gap in
// our FIN-terminated response is the semantically equivalent encoding
// (§9.1 lets relays re-represent missing ranges), and §11.4.4.2 prefers
// it outside known/unknown splits.
// - When the upstream vouches for less than the whole sub-range — FETCH
// rejected, response timeout, a mid-stream error (no FIN, so its gaps
// assert nothing), or a clean FIN whose FETCH_OK EndLocation was capped
// below endIncl — the unvouched-for remainder is covered by an unknown
// marker. The mid-stream-error and descending capped cases collapse to
// "whole sub-range unknown": exact per-gap markers are inexpressible in
// §11.4.4's delta encoding wherever the element after a marker would be
// a same-group, lower-Object-ID transition.
func (h *sessionHandler) fetchUpstreamRange(
ctx context.Context,
up *registry.UpstreamSub,
fullName track.FullTrackName,
start, endIncl message.Location,
order message.GroupOrder,
fillTimeout time.Duration,
) []*cache.CachedObject {
unknownWhole := unknownWholeRange(start, endIncl, order)
timedOutWhole := timedOutWholeRange(start, endIncl, order)
// §10.2.5: "A value of 0 indicates the relay MUST NOT wait for upstream
// delivery and MUST report any unavailable Objects as Timed-Out gaps."
// fillTimeout arrives already resolved (see [resolveFillBudget]), so a zero
// here is the subscriber's explicit 0, not an absent parameter.
if fillTimeout == 0 {
return timedOutWhole
}
params := message.Parameters{}
if order == message.GroupOrderDescending {
params = append(params, message.GroupOrderParam(message.GroupOrderDescending))
}
// §5.1.2: the range rides in LOCATION_FILTER. EndGroupDelta is delta-encoded
// from the start group, and EndObject makes the end Object-precise.
params = append(params, message.AbsoluteRangeObjectFilter(
start, endIncl.Group-start.Group, endIncl.Object))
fmsg := &message.Fetch{
Namespace: fullName.Namespace,
Name: fullName.Name,
Parameters: params,
}
// Bound the upstream round-trip so a silent or non-FETCH-answering
// upstream degrades to cache-plus-unknown-gap instead of wedging the
// downstream handler. FILL_TIMEOUT, when present, is the subscriber's
// explicit budget; otherwise fall back to a default.
fctx, cancel := context.WithTimeout(ctx, fillTimeout)
defer cancel()
fr, err := up.Session.Fetch(fctx, fmsg)
if err != nil {
h.log.LogAttrs(ctx, slog.LevelDebug, "upstream FETCH failed",
slog.String("err", err.Error()))
if fctx.Err() != nil {
return timedOutWhole
}
return unknownWhole
}
defer fr.Close()
// The upstream echoes our Request ID in the response's FETCH_HEADER, so
// the body stream lands on the upstream session's data loop keyed by
// fmsg.RequestID. Register after Fetch (the ID is only assigned there);
// the router tolerates a response that races ahead of registration.
ch, cleanup := h.fetch.Register(up.Session, fmsg.RequestID)
defer cleanup()
var fs *session.IncomingFetchStream
select {
case fs = <-ch:
case <-fctx.Done():
h.log.LogAttrs(ctx, slog.LevelDebug, "upstream FETCH response timed out")
return timedOutWhole
}
if fs == nil {
return unknownWhole
}
// ReadDecoded needs the response's group order to resolve cross-group
// deltas (§11.4.4.1); the upstream serves in the order our FETCH asked
// for.
fs.GroupOrder = order
var (
out []*cache.CachedObject
prevLoc message.Location
havePrev bool
)
for {
obj, err := fs.ReadDecoded()
if errors.Is(err, io.EOF) {
break // clean FIN: the upstream's gaps are authoritative (§11.4.4)
}
if err != nil {
// No FIN (or a FIN mid-object), so the gaps in what arrived
// assert nothing; declare the whole sub-range unknown rather
// than serve partial objects whose gaps would read as
// non-existence.
h.log.LogAttrs(ctx, slog.LevelDebug, "upstream FETCH stream failed mid-read",
slog.String("err", err.Error()))
return unknownWhole
}
if obj.EndOfNonExistentRange {
// Dropped: a plain gap in our FIN-terminated response is the
// semantically equivalent encoding (§9.1).
continue
}
loc := message.Location{Group: obj.GroupID, Object: obj.ObjectID}
if !upstreamFetchElemOK(loc, prevLoc, havePrev, start, endIncl, order,
obj.EndOfUnknownRange || obj.EndOfTimedOutRange) {
h.log.LogAttrs(ctx, slog.LevelDebug, "upstream FETCH element out of range or order",
slog.Uint64("group", loc.Group), slog.Uint64("object", loc.Object))
return unknownWhole
}
prevLoc, havePrev = loc, true
if obj.EndOfUnknownRange {
out = append(out, unknownRangeMarker(loc))
continue
}
if obj.EndOfTimedOutRange {
out = append(out, timedOutRangeMarker(loc))
continue
}
// The §11.4.4.1 Datagram bit carries the original wire shape
// across this relay hop, so the object is re-emitted downstream
// with the same forwarding preference it was published with.
// (Stitched objects are merged into the response only — they are
// not written back into the cache.)
pref := cache.ForwardingSubgroup
if obj.Datagram {
pref = cache.ForwardingDatagram
}
out = append(out, &cache.CachedObject{
GroupID: obj.GroupID,
ObjectID: obj.ObjectID,
SubgroupID: obj.SubgroupID,
PublisherPriority: obj.PublisherPriority,
ForwardingPref: pref,
Properties: obj.Properties,
Payload: obj.Payload,
})
}
// A clean FIN asserts gaps only up to the FETCH_OK EndLocation (§11.4.4).
// If the upstream capped it below our sub-range end (§10.13: End beyond
// its Largest), the remainder has unknown status.
if authEnd := fr.OK.EndLocation; authEnd.Less(endIncl) {
if order == message.GroupOrderDescending {
// The unknown remainder precedes every object in descending
// stream order, and a leading marker cannot in general be
// followed by a same-group object with a lower ID (see the
// doc comment) — fall back to whole-sub-range unknown.
return unknownWhole
}
out = append(out, unknownRangeMarker(endIncl))
}
return out
}
// upstreamFetchElemOK validates one kept element of an upstream FETCH
// response before it is re-serialized downstream. Every element must lie
// inside the requested sub-range [start, endIncl] — the merge with the
// cached part relies on Location disjointness — and an object must advance
// from the previous kept element the way §11.4.4's delta encoding can
// express: within a group, Object IDs strictly ascend; across groups, the
// Group ID moves in the response's order direction. Unknown-range markers
// carry absolute IDs and merely re-anchor the encoding, so only the range
// check applies to them. A violation means the upstream is nonconformant;
// trusting the element would corrupt the downstream delta stream (e.g. flip
// its group-direction inference), so the caller discards the response.
func upstreamFetchElemOK(
loc, prev message.Location,
havePrev bool,
start, endIncl message.Location,
order message.GroupOrder,
isMarker bool,
) bool {
if loc.Less(start) || endIncl.Less(loc) {
return false
}
if isMarker || !havePrev {
return true
}
if loc.Group == prev.Group {
return prev.Object < loc.Object
}
if order == message.GroupOrderDescending {
return loc.Group < prev.Group
}
return prev.Group < loc.Group
}
// unknownRangeMarker returns the serve-path element that streamFetchObjects
// serializes as a §11.4.4.2 End of Unknown Range (0x10C) marker at loc.
func unknownRangeMarker(loc message.Location) *cache.CachedObject {
return &cache.CachedObject{
GroupID: loc.Group,
ObjectID: loc.Object,
EndOfUnknownRange: true,
}
}
// timedOutRangeMarker is [unknownRangeMarker] for the §10.2.5 case: the
// FILL_TIMEOUT budget ran out, so the Objects are reported as Timed-Out rather
// than unknown-status gaps.
func timedOutRangeMarker(loc message.Location) *cache.CachedObject {
return &cache.CachedObject{
GroupID: loc.Group,
ObjectID: loc.Object,
EndOfTimedOutRange: true,
}
}
// unknownWholeRange declares the whole inclusive sub-range [start, endIncl]
// unknown with a single marker, positioned for the response's stream order.
// The marker Location is the range's far end in stream direction (endIncl
// when ascending, start when descending), so §11.4.4.2's "between the last
// serialized Object, if any, and this Location, inclusive" coverage spans
// the sub-range.
func unknownWholeRange(start, endIncl message.Location, order message.GroupOrder) []*cache.CachedObject {
return wholeRange(unknownRangeMarker, start, endIncl, order)
}
// timedOutWholeRange is [unknownWholeRange] with the §11.4.4.2 End of
// Timed-Out Range marker, for when the FILL_TIMEOUT budget is what stopped us
// (§10.2.5) rather than an unreachable or unhelpful upstream.
func timedOutWholeRange(start, endIncl message.Location, order message.GroupOrder) []*cache.CachedObject {
return wholeRange(timedOutRangeMarker, start, endIncl, order)
}
// wholeRange covers [start, endIncl] with a single marker built by mark. The
// marker names the far end of the range in delivery order, since §11.4.4.2
// markers cover everything from the previous element up to their own Location.
func wholeRange(
mark func(message.Location) *cache.CachedObject,
start, endIncl message.Location,
order message.GroupOrder,
) []*cache.CachedObject {
if order == message.GroupOrderDescending {
return []*cache.CachedObject{mark(start)}
}
return []*cache.CachedObject{mark(endIncl)}
}
// mergeFetchObjects merges the below-floor (upstream) and at/above-floor
// (cache) slices in group order. The two are disjoint by Location and each is
// already sorted in order, so for ascending the lower range leads and for
// descending the higher (cache) range leads.
//
// Descending needs one more step: within a group, Object IDs always ascend
// (§11.4.3), and §11.4.4's delta encoding cannot express a same-group
// transition to a lower Object ID — so when the eviction floor splits a
// group across the two sources, the seam group's runs must be spliced into
// one contiguous ascending run, upstream part (lower Object IDs) first.
// Plain concatenation would put the cache's high-object run before the
// upstream's low-object run of the same group and serialize a wrapped
// delta. Unknown-range markers interleaved with the seam run's objects move
// with them (their coverage and delta re-anchoring stay as the upstream
// meant them); a marker-only prefix — the whole-sub-range unknown marker,
// whose coverage spans everything below the cache — stays after it.
func mergeFetchObjects(order message.GroupOrder, lower, upper []*cache.CachedObject) []*cache.CachedObject {
switch {
case len(lower) == 0:
return upper
case len(upper) == 0:
return lower
}
out := make([]*cache.CachedObject, 0, len(lower)+len(upper))
if order != message.GroupOrderDescending {
out = append(out, lower...)
out = append(out, upper...)
return out
}
// The only group the two sources can share is the cache's lowest
// (upper's last element) — the floor group. splice is the length of
// lower's leading seam-group run, markers included: an interleaved
// upstream 0x10C marker belongs with its neighbouring objects (its
// coverage and the delta re-anchoring stay exactly as the upstream
// meant them, and every spliced Location is below the cache's seam
// objects). A prefix with no objects at all is NOT spliced — that is
// the whole-sub-range unknown marker, whose coverage spans everything
// below the cache and must stay after it.
seamG := upper[len(upper)-1].GroupID
splice, seamHasObject := 0, false
for splice < len(lower) && lower[splice].GroupID == seamG {
seamHasObject = seamHasObject || !lower[splice].IsRangeMarker()
splice++
}
if !seamHasObject {
splice = 0
}
// cut is where upper's trailing seam-group run starts (the cache never
// holds unknown-range markers, so a plain group comparison suffices).
cut := len(upper)
for cut > 0 && upper[cut-1].GroupID == seamG {
cut--
}
out = append(out, upper[:cut]...)
out = append(out, lower[:splice]...)
out = append(out, upper[cut:]...)
out = append(out, lower[splice:]...)
return out
}
// fetchPredecessor returns the Location immediately below loc in (group,
// object) order, and false when loc is {0, 0} (nothing precedes it). The
// object-underflow case rolls back to the end of the previous group.
func fetchPredecessor(loc message.Location) (message.Location, bool) {
switch {
case loc.Object > 0:
return message.Location{Group: loc.Group, Object: loc.Object - 1}, true
case loc.Group > 0:
return message.Location{Group: loc.Group - 1, Object: math.MaxUint64}, true
default:
return message.Location{}, false
}
}
// streamFetchObjects writes the cached objects to the FETCH response
// stream with §11.4.4 delta encoding:
//
// - The first object includes both GroupIDDelta and ObjectIDDelta
// flags; the values are absolute (§11.4.4.1).
// - Subsequent objects in the same group use ObjectIDDelta only when
// the gap is > 0 (a consecutive object omits the flag, the
// subscriber reconstructs ObjectID = prior + 1).
// - Subsequent objects in a different group set GroupIDDelta:
// ascending → newGroup - priorGroup - 1, descending →
// priorGroup - newGroup - 1 (§11.4.4.1). ObjectIDDelta is then the
// absolute Object ID in the new group.
// - Datagram-flavoured objects set bit 0x40 (§11.4.4.1); subscriber
// ignores the subgroup bits.
// - [cache.CachedObject.EndOfUnknownRange] elements serialize as §11.4.4.2
// End of Unknown Range markers (0x10C) with absolute Group/Object IDs,
// and become the prior Location for the delta encoding of what follows.
//
// The returned count is the number of real objects written (markers are
// serialized but not counted — they carry no payload).
func streamFetchObjects(out *session.OutgoingFetchStream, objs []*cache.CachedObject) (int, error) {
var (
written int
prevGroup uint64
prevObject uint64
prevPriority uint8
// havePrev: a prior Group/Object ID exists — a real object or a
// §11.4.4.2 End-of-Range marker. haveActual: a real object was
// written — only then do a prior Subgroup ID / Priority exist
// (mirror of ReadDecoded's decHavePrev / decHaveActual).
havePrev bool
haveActual bool
// Inferred from the ordering of the first vs second object.
// Without a second object we don't need the direction.
descending bool
)
for _, o := range objs {
if o.IsRangeMarker() {
// §11.4.4.2 End of Unknown / Timed-Out Range: the Group/Object ID fields
// carry the absolute range boundary, and the marker becomes
// the prior Location for subsequent delta encoding — but not
// a prior *actual* object, so the next object still spells
// out its Priority (and never references the prior Subgroup).
flags := uint64(message.FetchEndOfUnknownRange)
if o.EndOfTimedOutRange {
flags = message.FetchEndOfTimedOutRange
}
if err := out.WriteObject(&message.FetchObject{
SerializationFlags: flags,
GroupIDDelta: o.GroupID,
ObjectIDDelta: o.ObjectID,
}); err != nil {
return written, err
}
prevGroup, prevObject = o.GroupID, o.ObjectID
havePrev = true
continue
}
// §11.2.1.1: the Object Status field "is absent in Objects
// delivered via a FETCH". Cached status markers describe absence,
// so they are simply not serialized — their knowledge still reaches
// the fetcher: the marker bumped the LARGEST_OBJECT watermark on
// ingest, FETCH_OK's EndLocation extends through it
// (capFetchEndLocation), and §11.4.4's gap rule makes the trailing
// gap of a FIN-terminated response authoritative non-existence.
// Emitting End of Non-Existent Range (0x8C) instead would be
// redundant: §11.4.4.2 reserves it for splitting non-serialized
// ranges into known-non-existent and unknown parts.
if o.IsStatusMarker() {
continue
}
fo := &message.FetchObject{}
switch {
case !havePrev:
// §11.4.4.1: first object MUST include both
// GroupIDDelta and ObjectIDDelta flags; values are
// absolute.
fo.SerializationFlags |= message.FetchFlagGroupIDDelta | message.FetchFlagObjectIDDelta
fo.GroupIDDelta = o.GroupID
fo.ObjectIDDelta = o.ObjectID
case o.GroupID != prevGroup:
// Cross-group. Detect direction from the first such
// transition: descending iff new group < prior.
if !descending && o.GroupID < prevGroup {
descending = true
} else if descending && o.GroupID > prevGroup {
// Direction reversed mid-stream — should
// never happen because GetRange returns
// stably sorted output, but if it did the
// safest action is to abandon the optimised
// delta encoding and reset the GroupIDDelta
// using ascending convention.
descending = false
}
fo.SerializationFlags |= message.FetchFlagGroupIDDelta | message.FetchFlagObjectIDDelta
if descending {
fo.GroupIDDelta = prevGroup - o.GroupID - 1
} else {
fo.GroupIDDelta = o.GroupID - prevGroup - 1
}
fo.ObjectIDDelta = o.ObjectID
default:
// Same group. §11.4.4 cannot express a non-ascending Object ID
// here — the delta only ever adds. The inputs are sorted and
// seam-spliced (mergeFetchObjects), so hitting this is an
// internal invariant violation; fail rather than emit a wrapped
// delta the subscriber must treat as a session-fatal overflow.
if o.ObjectID <= prevObject {
return written, fmt.Errorf(
"relay: fetch serialization order violation: {%d,%d} after {%d,%d}",
o.GroupID, o.ObjectID, prevGroup, prevObject)
}
// Omit ObjectIDDelta when consecutive; otherwise include it
// with the gap value.
if o.ObjectID != prevObject+1 {
fo.SerializationFlags |= message.FetchFlagObjectIDDelta
fo.ObjectIDDelta = o.ObjectID - prevObject - 1
}
}
switch o.ForwardingPref {
case cache.ForwardingDatagram:
// §11.4.4.1: bit 0x40 marks the object as a
// Datagram-flavoured object; the subscriber ignores
// the two subgroup bits.
fo.SerializationFlags |= message.FetchFlagDatagram
case cache.ForwardingSubgroup:
// Subgroup: encode the SubgroupID explicitly. The
// "prior + 0/1" subgroup modes are micro-optimisations
// over the explicit form; we always emit explicit for
// simplicity.
fo.SerializationFlags = (fo.SerializationFlags &^ message.FetchFlagSubgroupIDMode) |
uint64(message.FetchSubgroupIDExplicit)
fo.SubgroupID = o.SubgroupID
}
// Publisher priority: emit when it differs from the prior actual
// object's — or when there is none (the first object, and the
// first object after a leading marker, §11.4.4.2).
if !haveActual || o.PublisherPriority != prevPriority {
fo.SerializationFlags |= message.FetchFlagPriority
fo.PublisherPriority = o.PublisherPriority
}
if len(o.Properties) > 0 {
fo.SerializationFlags |= message.FetchFlagProperties
fo.Properties = o.Properties
}
fo.ObjectPayload = o.Payload
if err := out.WriteObject(fo); err != nil {
return written, err
}
written++
prevGroup = o.GroupID
prevObject = o.ObjectID
prevPriority = o.PublisherPriority
havePrev = true
haveActual = true
}
return written, nil
}
package relay
import (
"context"
"log/slog"
"time"
"github.com/floatdrop/moq-go/pkg/moqt"
"github.com/floatdrop/moq-go/pkg/moqt/message"
"github.com/floatdrop/moq-go/pkg/moqt/track"
"github.com/floatdrop/moq-go/pkg/relay/internal/registry"
)
// maybeServeFill opens and serves a fill fetch stream for a subscription when
// the SUBSCRIBE or REQUEST_UPDATE carried FILL_PARAMETERS (§5.1.3), which is
// draft-20's replacement for the Joining FETCH.
//
// requestID is the Request ID of the message that asked for the fill — the
// SUBSCRIBE's for an initial fill, the REQUEST_UPDATE's for a later one — and
// it is what the FETCH_HEADER carries, so a subscription can have several fill
// fetch streams open at once, each named by its own Request ID.
//
// It returns an error only for a malformed FILL_PARAMETERS, which the caller
// MUST turn into a session-level PROTOCOL_VIOLATION (§10.2.15). Everything
// else is best-effort: §5.1.3.1 has no REQUEST_ERROR for a fill, so a failure
// is signalled by resetting the stream, and the subscription itself is
// unaffected either way.
func (h *sessionHandler) maybeServeFill(
ctx context.Context,
sub *registry.DownstreamSub,
entry *registry.TrackEntry,
fullName track.FullTrackName,
requestID uint64,
ps message.Parameters,
) error {
inner, requested, err := message.FillParametersFromParam(ps)
if err != nil {
return err
}
if !requested {
return nil
}
// From here the peer has asked for a fill, so §5.1.3.1's failure signal
// applies to everything that can still go wrong: "Because there is no
// REQUEST_ERROR associated with a fill fetch stream, the publisher signals a
// fill failure by resetting the stream; it MUST open a fill fetch stream and
// reset it immediately after the FETCH_HEADER if necessary."
fail := func(err error) error {
h.resetFillStream(ctx, requestID)
return err
}
// §5.1.3.1: "A publisher opens a fill fetch stream when it processes a
// SUBSCRIBE or REQUEST_UPDATE that carries FILL_PARAMETERS while Forward
// State is 1." FILL_PARAMETERS arriving while paused opens nothing, and a
// later unpause does not retroactively open one.
if sub.ForwardState() != 1 {
return nil
}
// 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()
if !hasLargest {
return nil
}
// §5.1.3: the fill range comes from the LOCATION_FILTER inside
// FILL_PARAMETERS, falling back to the subscription's own filter, and to
// the whole track when neither is present.
filter, err := message.LocationFilterFromParam(inner)
if err != nil {
return fail(err)
}
if filter == nil {
filter = sub.GetFilter()
}
if filter == nil {
filter = &message.LocationFilter{}
}
start := filter.Start(largest, hasLargest)
end := capFetchEndLocation(filter, largest)
// §5.1.3: "If the fill range is empty, or starts after Largest Object, the
// publisher does not open a fill fetch stream."
if largest.Less(start) || end.Less(start) {
return nil
}
// §10.2.15: a parameter omitted from FILL_PARAMETERS keeps the value it
// has for the subscription, so the inner list only carries the overrides.
order := message.GroupOrder(sub.GroupOrder)
if p, ok := inner.Find(message.ParamGroupOrder); ok {
order = message.GroupOrder(p.Byte)
}
fillTimeout := resolveFillBudget(inner)
rangeFilters, err := message.RangeFiltersFromParams(inner)
if err == nil && rangeFilters != nil {
err = rangeFilters.Validate(h.sess.MaxFilterRanges())
}
if err != nil {
return fail(err)
}
h.relayGo(func() {
h.serveFill(ctx, requestID, entry, fullName, start, end, order, fillTimeout, rangeFilters)
})
return nil
}
// TODO(draft-20): §5.1.3.1 also requires "When the subscription is cancelled,
// the publisher MUST reset any open fill fetch streams." That needs a watchdog
// resetting the stream on ctx cancellation mid-write, which is a concurrency
// change worth landing with -race coverage — i.e. with the test slice.
// serveFill writes one fill fetch stream and closes it. §5.1.3.1: the FIN is
// what signals the fill is complete, and because a fill has no REQUEST_ERROR
// of its own, a failure is signalled by resetting the stream —
// [sessionHandler.streamFetchRange] does that on a write error.
func (h *sessionHandler) serveFill(
ctx context.Context,
requestID uint64,
entry *registry.TrackEntry,
fullName track.FullTrackName,
start, end message.Location,
order message.GroupOrder,
fillTimeout time.Duration,
rangeFilters *message.RangeFilterSet,
) {
h.log.LogAttrs(ctx, slog.LevelDebug, "serving fill fetch stream",
slog.Uint64("request_id", requestID),
slog.Uint64("start_group", start.Group),
slog.Uint64("end_group", end.Group))
h.streamFetchRange(ctx, "fill", requestID, entry, fullName,
start, end, order, fillTimeout, rangeFilters)
}
// resetFillStream signals a fill failure the only way §5.1.3.1 allows: open the
// fill fetch stream and reset it immediately after the FETCH_HEADER. Without
// it the subscriber cannot tell a failed fill from the legitimate "fill range
// is empty, so no stream" case (§5.1.3), and waits forever.
func (h *sessionHandler) resetFillStream(ctx context.Context, requestID uint64) {
out, err := h.sess.OpenFetchStream(message.FetchHeader{RequestID: requestID})
if err != nil {
h.log.LogAttrs(ctx, slog.LevelDebug, "could not open fill stream to reset it",
slog.Uint64("request_id", requestID), slog.String("err", err.Error()))
return
}
out.Cancel(moqt.StreamResetInternalError)
}
package relay
import (
"context"
"log/slog"
"github.com/floatdrop/moq-go/pkg/moqt"
"github.com/floatdrop/moq-go/pkg/moqt/message"
"github.com/floatdrop/moq-go/pkg/moqt/session"
"github.com/floatdrop/moq-go/pkg/moqt/wire"
"github.com/floatdrop/moq-go/pkg/relay/internal/registry"
)
// handlePublishNamespace implements the PUBLISH_NAMESPACE flow (§6.2, §10.16):
//
// 1. Authorize.
// 2. Register the namespace in [registry.NamespaceRegistry].
// 3. Reply REQUEST_OK on the request stream.
// 4. Forward to every matching downstream SUBSCRIBE_NAMESPACE holder as a
// NAMESPACE message (§9.5).
// 5. Block reading the request stream until the publisher cancels it
// (FIN / RESET_STREAM, §6.2). On exit, unregister from the
// registry.NamespaceRegistry and emit NAMESPACE_DONE to the same subscribers.
//
// The §9.5 "issue upstream SUBSCRIBE for matching downstream subs"
// optimisation is handled by the SUBSCRIBE handler's on-demand
// upstream subscribe path; here we only do forward-direction
// propagation.
func (h *sessionHandler) handlePublishNamespace(
ctx context.Context,
req *session.Request,
msg *message.PublishNamespace,
) {
if err := h.auth.AuthorizePublishNamespace(ctx, h.sess, msg); err != nil {
h.rejectAuth(ctx, req, "PublishNamespace", err)
return
}
entry := h.names.RegisterPublisher(msg.Namespace, h.sess, req.Stream)
defer h.names.UnregisterPublisher(entry)
if err := req.Reply(&message.RequestOK{}); err != nil {
h.log.LogAttrs(ctx, slog.LevelDebug, "PublishNamespace REQUEST_OK write failed",
slog.String("err", err.Error()))
return
}
// Forward to every matching downstream SUBSCRIBE_NAMESPACE holder.
// Per §6.2 the relay MUST send NAMESPACE to subscribers whose
// prefix matches OR is a prefix of the advertised namespace.
subscribers := h.names.MatchSubscribers(msg.Namespace)
notified := make([]*registry.SubscriberEntry, 0, len(subscribers))
for _, sub := range subscribers {
if sub.WantsTracks {
// SUBSCRIBE_TRACKS holders get PUBLISH messages, not
// NAMESPACE messages. They're tracked but not notified
// here; handlePublish handles their PUBLISH forwarding.
continue
}
if err := sub.WriteMessage(namespaceMessageFor(msg.Namespace, sub.Prefix)); err != nil {
h.log.LogAttrs(ctx, slog.LevelDebug, "NAMESPACE forward failed",
slog.String("err", err.Error()))
continue
}
notified = append(notified, sub)
}
// Block until the publisher cancels (request stream FIN/reset) or our
// ctx is cancelled. Per §6.2 the bidi stream is the publisher's
// keepalive for the advertisement; NAMESPACE / NAMESPACE_DONE
// follow-ups from the publisher need no action (the §9.5 fanout keys
// off tracks, not per-namespace sub-announcements), but REQUEST_UPDATEs
// must be validated and answered. This handler goroutine is the only
// writer on the publisher's stream after the REQUEST_OK above, so the
// acks write directly.
h.serveNamespaceFollowups(ctx, req.Stream, func(m message.Message) error {
return message.Marshal(req.Stream, m)
})
// Emit NAMESPACE_DONE to every subscriber we previously notified.
// Use the registry's CopySubscribers to refilter (handles subscribers
// that unregistered while we were running), then intersect with
// `notified` so we don't notify subscribers that never saw the
// initial NAMESPACE.
stillAlive := make(map[*registry.SubscriberEntry]struct{})
for _, s := range h.names.CopySubscribers() {
stillAlive[s] = struct{}{}
}
for _, sub := range notified {
if _, ok := stillAlive[sub]; !ok {
continue
}
if err := sub.WriteMessage(namespaceDoneMessageFor(msg.Namespace, sub.Prefix)); err != nil {
h.log.LogAttrs(ctx, slog.LevelDebug, "NAMESPACE_DONE forward failed",
slog.String("err", err.Error()))
}
}
}
// handleSubscribeNamespace implements SUBSCRIBE_NAMESPACE (§6.1, §10.19):
//
// 1. Authorize.
// 2. Register in [registry.NamespaceRegistry] with WantsTracks=false.
// 3. Reply REQUEST_OK.
// 4. Emit one NAMESPACE for every currently-known publisher whose
// advertised namespace extends our prefix (§6.1: the publisher MUST send
// NAMESPACE for namespaces already known to it that match the prefix).
// 5. Block reading the request stream until the subscriber cancels it.
//
// New publisher arrivals during the subscription's lifetime are handled by
// the publisher's `handlePublishNamespace` (which fans out NAMESPACE).
func (h *sessionHandler) handleSubscribeNamespace(
ctx context.Context,
req *session.Request,
msg *message.SubscribeNamespace,
) {
if err := h.auth.AuthorizeSubscribeNamespace(ctx, h.sess, msg); err != nil {
h.rejectAuth(ctx, req, "SubscribeNamespace", err)
return
}
// Reply REQUEST_OK before registering. Registration makes the entry
// visible to MatchSubscribers, after which a concurrent publisher's
// PUBLISH_NAMESPACE handler (or the Discovery watcher) may write NAMESPACE
// to this stream; sending the OK first keeps it from racing those writes
// (and §6.1 requires the OK to precede any NAMESPACE). The backlog scan
// below still runs after registration, so no advertisement is missed.
if err := req.Reply(&message.RequestOK{}); err != nil {
h.log.LogAttrs(ctx, slog.LevelDebug, "SubscribeNamespace REQUEST_OK write failed",
slog.String("err", err.Error()))
return
}
// forward/groupOrder/rangeFilters are ignored for a SUBSCRIBE_NAMESPACE
// (WantsTracks false), which never triggers PUBLISH — pass the defaults.
entry := h.names.RegisterSubscriber(
msg.TrackNamespacePrefix,
h.sess,
req.Stream,
false, /* wantsTracks */
true,
0,
nil,
)
defer h.names.UnregisterSubscriber(entry)
// Seed the subscriber with every namespace already known under this prefix,
// each announced once. Local PUBLISH_NAMESPACE publishers first (snapshotted
// so we don't hold the registry lock across stream writes); then, if
// Discovery is configured, namespaces advertised by OTHER relays — so a
// subscriber learns cross-relay namespaces advertised before it registered,
// not only those that change afterwards. Own-relay Discovery entries are
// skipped: the local pass already covered them. Writes go through
// entry.WriteMessage so they serialise with concurrent forwards. New
// arrivals during the subscription are handled live by handlePublishNamespace
// and the Discovery watcher.
seeded := make(map[string]struct{})
emit := func(ns wire.TrackNamespace) error {
k := namespaceKey(ns)
if _, dup := seeded[k]; dup {
return nil
}
seeded[k] = struct{}{}
return entry.WriteMessage(namespaceMessageFor(ns, msg.TrackNamespacePrefix))
}
for _, pub := range h.names.CopyPublishers() {
if !pub.Namespace.HasPrefix(msg.TrackNamespacePrefix) {
continue
}
if err := emit(pub.Namespace); err != nil {
h.log.LogAttrs(ctx, slog.LevelDebug, "initial NAMESPACE write failed",
slog.String("err", err.Error()))
return
}
}
if h.discovery != nil {
infos, err := h.discovery.FindNamespacesUnder(ctx, msg.TrackNamespacePrefix)
if err != nil {
h.log.LogAttrs(ctx, slog.LevelDebug, "discovery namespace seed failed",
slog.String("err", err.Error()))
}
for _, info := range infos {
if info.RelayAddr == h.relayAddr {
continue // our own advertisement — already seeded from local publishers
}
if err := emit(info.Prefix); err != nil {
h.log.LogAttrs(ctx, slog.LevelDebug, "initial remote NAMESPACE write failed",
slog.String("err", err.Error()))
return
}
}
}
// REQUEST_OK acks go through entry.WriteMessage so they serialise with
// the NAMESPACE / NAMESPACE_DONE notifications concurrent publisher
// handlers write to this stream.
h.serveNamespaceFollowups(ctx, req.Stream, entry.WriteMessage)
}
// handleSubscribeTracks implements SUBSCRIBE_TRACKS (§6.1, §10.20):
//
// 1. Authorize.
// 2. Register in [registry.NamespaceRegistry] with WantsTracks=true.
// 3. Reply REQUEST_OK.
// 4. Block reading the request stream until the subscriber cancels it.
//
// PUBLISH forwarding (the actual reason SUBSCRIBE_TRACKS exists) is the
// responsibility of `handlePublish` — it queries
// [registry.NamespaceRegistry.MatchSubscribers] on every inbound PUBLISH and routes
// to each WantsTracks=true entry whose prefix matches.
func (h *sessionHandler) handleSubscribeTracks(
ctx context.Context,
req *session.Request,
msg *message.SubscribeTracks,
) {
if err := h.auth.AuthorizeSubscribeTracks(ctx, h.sess, msg); err != nil {
h.rejectAuth(ctx, req, "SubscribeTracks", err)
return
}
// §10.20.1: FORWARD/GROUP_ORDER on the SUBSCRIBE_TRACKS become the
// defaults copied onto every PUBLISH this subscription triggers. Resolve
// (and validate) before acking. An out-of-range value is a §10.2.8 /
// §10.2.18 session-level PROTOCOL_VIOLATION.
forward, groupOrder, err := subscribeTracksForwarding(msg.Parameters)
if err != nil {
h.log.LogAttrs(ctx, slog.LevelDebug, "SubscribeTracks parameter protocol violation",
slog.String("err", err.Error()))
_ = h.sess.Close(moqt.SessionProtocolViolation, err.Error())
return
}
// §5.1.4: TRACK_PROPERTY_FILTER (and any other Range Filters) on the
// SUBSCRIBE_TRACKS gate which PUBLISH messages are forwarded. Parse and
// validate against MAX_FILTER_RANGES; a bad/over-limit set is a §10.6
// INVALID_FILTER (request-scoped).
rangeFilters, err := message.RangeFiltersFromParams(msg.Parameters)
if err == nil && rangeFilters != nil {
err = rangeFilters.Validate(h.sess.MaxFilterRanges())
}
if err != nil {
h.log.LogAttrs(ctx, slog.LevelDebug, "SubscribeTracks range filter rejected",
slog.String("err", err.Error()))
_ = req.RejectError(moqt.RequestInvalidFilter, err.Error())
return
}
// Reply REQUEST_OK before registering, so the OK cannot race a
// PUBLISH_SKIPPED that a concurrent publisher's PUBLISH handler
// (emitPublishSkipped) may write to this stream once the entry is visible.
if err := req.Reply(&message.RequestOK{}); err != nil {
h.log.LogAttrs(ctx, slog.LevelDebug, "SubscribeTracks REQUEST_OK write failed",
slog.String("err", err.Error()))
return
}
entry := h.names.RegisterSubscriber(
msg.TrackNamespacePrefix,
h.sess,
req.Stream,
true, /* wantsTracks */
forward,
groupOrder,
rangeFilters,
)
defer h.names.UnregisterSubscriber(entry)
// REQUEST_OK acks go through entry.WriteMessage so they serialise with
// the PUBLISH_SKIPPED notifications concurrent PUBLISH handlers write
// to this stream (emitPublishSkipped).
h.serveNamespaceFollowups(ctx, req.Stream, entry.WriteMessage)
}
// subscribeTracksForwarding resolves the FORWARD (§10.2.18) and GROUP_ORDER
// (§10.2.8) parameters a SUBSCRIBE_TRACKS carries. §10.20.1 copies both onto
// the PUBLISH messages the subscription triggers: forward defaults to true
// (FORWARD omitted or 1; only 0 means "don't forward"); groupOrder is 0 when
// omitted (the publisher's default applies) or the Ascending/Descending value.
// An out-of-range value is a *paramProtocolViolation (§10.2.8 / §10.2.18: the
// caller MUST close the session), shared with installSubscribeParams.
func subscribeTracksForwarding(ps message.Parameters) (forward bool, groupOrder byte, err error) {
if err := checkForwardParam(ps); err != nil {
return false, 0, err
}
if err := checkGroupOrderParam(ps); err != nil {
return false, 0, err
}
forward = true
if p, ok := ps.Find(message.ParamForward); ok {
forward = p.Byte != 0
}
if p, ok := ps.Find(message.ParamGroupOrder); ok {
groupOrder = p.Byte
}
return forward, groupOrder, nil
}
// serveNamespaceFollowups holds a namespace request stream open (the §6.1 /
// §6.2 keepalive previously provided by session.DrainAndWait) while actually
// parsing the follow-ups: a peer REQUEST_UPDATE consumes a §10.1 Request ID
// (validated; violations are session-fatal), may carry §10.2.2 token
// parameters, and must be answered with the single REQUEST_OK §10.9 mandates
// — the relay keeps no mutable per-namespace-request parameters, so the
// update is acknowledged without further action. write supplies the
// stream's serialized writer (namespace streams are also written by
// concurrent notification fanouts). Other follow-ups (NAMESPACE,
// NAMESPACE_DONE, …) need no response and are ignored here.
func (h *sessionHandler) serveNamespaceFollowups(
ctx context.Context,
stream session.Stream,
write func(message.Message) error,
) {
updates := h.sess.NewRequestUpdateLimiter()
readRequestStream(ctx, stream, func(m message.Message) bool {
upd, ok := m.(*message.RequestUpdate)
if !ok {
return true
}
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
}
if !h.handleFollowupTokens(ctx, upd) {
return false
}
if err := write(&message.RequestOK{}); err != nil {
h.log.LogAttrs(ctx, slog.LevelDebug, "namespace REQUEST_UPDATE_OK write failed",
slog.String("err", err.Error()))
// The handler unregisters the namespace state when this loop
// returns; reset the read side so the peer learns reads
// stopped rather than writing follow-ups into a void. (The
// send side is left to the stream's owner — closing it here
// could race a concurrent notification fanout write.)
stream.CancelRead(uint64(moqt.StreamResetInternalError))
return false
}
updates.Responded()
return true
})
}
// namespaceKey returns a canonical map key for a namespace tuple (its wire
// encoding), so the SUBSCRIBE_NAMESPACE seed can announce each namespace once
// across its local-publisher and Discovery passes.
func namespaceKey(ns wire.TrackNamespace) string {
w := wire.NewWriter(nil)
w.TrackNamespace(ns)
return string(w.Bytes())
}
// namespaceMessageFor constructs a NAMESPACE wire message announcing the
// publisher's namespace under the subscriber's prefix. §10.17 carries only
// the suffix (the bytes beyond the prefix), so the relay strips the prefix
// portion before emitting.
//
// Example: publisher PUBLISH_NAMESPACE ("video", "cam1") + subscriber
// SUBSCRIBE_NAMESPACE ("video",) → NAMESPACE suffix ("cam1",).
func namespaceMessageFor(publisherNS, subscriberPrefix wire.TrackNamespace) *message.Namespace {
suffix := publisherNS[len(subscriberPrefix):]
return &message.Namespace{TrackNamespaceSuffix: append(wire.TrackNamespace(nil), suffix...)}
}
// namespaceDoneMessageFor constructs the NAMESPACE_DONE counterpart of
// [namespaceMessageFor]. Same suffix-stripping rule.
func namespaceDoneMessageFor(publisherNS, subscriberPrefix wire.TrackNamespace) *message.NamespaceDone {
suffix := publisherNS[len(subscriberPrefix):]
return &message.NamespaceDone{TrackNamespaceSuffix: append(wire.TrackNamespace(nil), suffix...)}
}
package relay
import (
"context"
"errors"
"fmt"
"log/slog"
"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/moqt/wire"
"github.com/floatdrop/moq-go/pkg/relay/internal/registry"
)
// handlePublish implements PUBLISH (§9.5, §10.11):
//
// 1. Authorize.
// 2. Register an [registry.UpstreamSub] in the registry.TrackRegistry (born
// [registry.SubEstablished]).
// 3. Capture the publisher's Track Properties on the entry (§9.6).
// 4. Reply REQUEST_OK.
// 5. Forward the PUBLISH to every downstream SUBSCRIBE_TRACKS holder
// whose prefix matches the track's namespace (§9.5: relay MUST send
// PUBLISH to each matching SUBSCRIBE_TRACKS holder).
// 6. Register the publisher's Track Alias as an inbound alias so the
// fanout path can map it back to the track.
// 7. Block reading the request stream until the publisher cancels;
// unregister on exit.
//
// testHookAfterAliasRegistered, when set, runs at the moment a Track Alias
// becomes routable and before the track entry is registered, so a test can
// hold open a window that is otherwise a few statements wide. Never set in
// production. atomic.Pointer because the relay reads it from per-session
// goroutines while a test writes it; the track argument lets a test scope
// itself to its own track rather than perturbing the package's parallel tests.
var testHookAfterAliasRegistered atomic.Pointer[func(track.FullTrackName)]
func (h *sessionHandler) handlePublish(ctx context.Context, req *session.Request, msg *message.Publish) {
h.log.LogAttrs(ctx, slog.LevelDebug, "PUBLISH received",
slog.String("namespace", fmt.Sprintf("%v", msg.Namespace)),
slog.String("name", string(msg.Name)),
slog.Uint64("alias", msg.TrackAlias))
if err := h.auth.AuthorizePublish(ctx, h.sess, msg); err != nil {
h.rejectAuth(ctx, req, "Publish", err)
return
}
fullName := track.FullTrackName{Namespace: msg.Namespace, Name: msg.Name}
// Create the entry before the alias below becomes routable. §10.11: if
// FORWARD "is omitted or equal to 1, the publisher will start
// transmitting objects immediately, possibly before PUBLISH_OK" — i.e.
// before AddUpstream runs down in WriteMessageAfterSetup. Without an
// entry to route to, runFanout resets those streams and the track's
// first Group is lost from the cache and from live fanout alike. Same
// window the on-demand SUBSCRIBE path closes; see #85.
_, createdEntry := h.tracks.GetOrCreateNew(fullName)
// §11.1: register the publisher's chosen alias so the fanout path can map
// it back to the track and duplicates are detected. A duplicate alias is a
// session-level error per spec, but we scope the failure to this request.
if err := h.sess.RegisterInboundTrackAlias(msg.TrackAlias, fullName.Key()); err != nil {
h.log.LogAttrs(ctx, slog.LevelDebug, "PUBLISH alias registration failed",
slog.String("err", err.Error()))
if createdEntry {
h.tracks.DeleteIfUnused(fullName)
}
_ = req.RejectError(moqt.RequestMalformedTrack, err.Error())
return
}
if hook := testHookAfterAliasRegistered.Load(); hook != nil {
(*hook)(fullName)
}
// A later upstream REQUEST_UPDATE rides this PUBLISH stream (§10.9),
// consuming a fresh Request ID from the relay's own space (§10.1);
// the PUBLISH's ID is recorded for identity/diagnostics.
sub := registry.NewUpstreamSub(h.allocSubID(), h.sess, req.Stream, msg.TrackAlias, msg.RequestID)
// Register the upstream and reply REQUEST_OK atomically under the
// stream's broker write lock. Both orderings matter:
//
// - Registration must complete before the peer can observe the OK: a
// publisher that received its OK may immediately be subscribed to
// via another session, and that SUBSCRIBE must find the track (the
// pre-broker code replied first, leaving a visibility window that
// rejected prompt subscribers with DOES_NOT_EXIST).
// - The OK must still be the stream's next message: registration
// makes the sub reachable by §9.2 / §10.2.19 propagation, whose
// REQUEST_UPDATE writes serialize behind the OK on the same lock.
// entry is hoisted out of the closure because the PUBLISH forwarded to each
// subscriber below has to read the track's own watermark back off it.
var entry *registry.TrackEntry
if err := sub.Broker.WriteMessageAfterSetup(func() error {
entry, _ = h.tracks.AddUpstream(fullName, sub, registry.WithProperties(msg.TrackProperties))
// §10.2.17 item 1 names PUBLISH alongside SUBSCRIBE_OK: a publisher
// offering a track that already has content reports its largest
// Location here, before any object arrives to establish one.
saveLargestLocation(entry, msg.Parameters)
return nil
}, &message.RequestOK{}); err != nil {
h.log.LogAttrs(ctx, slog.LevelDebug, "PUBLISH REQUEST_OK write failed",
slog.String("err", err.Error()))
h.tracks.RemoveUpstream(fullName, sub.ID)
h.sess.UnregisterInboundTrackAlias(msg.TrackAlias)
return
}
defer func() {
h.log.LogAttrs(ctx, slog.LevelDebug, "PUBLISH stream ended, removing upstream",
slog.String("name", string(msg.Name)))
h.tracks.RemoveUpstream(fullName, sub.ID)
h.sess.UnregisterInboundTrackAlias(msg.TrackAlias)
}()
h.log.LogAttrs(ctx, slog.LevelDebug, "PUBLISH accepted, waiting for publisher",
slog.String("name", string(msg.Name)))
// Forward to every SUBSCRIBE_TRACKS holder whose prefix matches.
// Per §6.1 / §9.5 the relay sends a PUBLISH for the track to each such
// subscriber on its OWN new bidirectional stream (NOT multiplexed onto
// the SUBSCRIBE_TRACKS request stream). We snapshot the subscriber list
// so we don't hold the registry lock across stream opens.
var forwarded []session.Stream
for _, sub := range h.names.MatchSubscribers(msg.Namespace) {
if !sub.WantsTracks {
// SUBSCRIBE_NAMESPACE holders get NAMESPACE messages
// emitted by handlePublishNamespace; PUBLISH targets only
// SUBSCRIBE_TRACKS holders.
continue
}
// §5.1.4: a TRACK_PROPERTY_FILTER on the SUBSCRIBE_TRACKS gates which
// PUBLISH messages are forwarded — "PUBLISH messages which pass the
// filter will be forwarded while those which do not pass it will not be
// forwarded nor will any Objects." MatchesTrack is vacuously true when
// the subscription carries no track-property filter.
if sub.RangeFilters != nil && !sub.RangeFilters.MatchesTrack(msg.TrackProperties) {
h.log.LogAttrs(ctx, slog.LevelDebug, "PUBLISH forward suppressed: TRACK_PROPERTY_FILTER",
slog.String("name", string(msg.Name)))
continue
}
// §6.1 (draft-19): a PUBLISH_SKIPPED prohibition is scoped to the single
// PUBLISH that could not be forwarded, not sticky across re-PUBLISHes —
// so every inbound PUBLISH is a fresh forwarding attempt, and a track we
// skipped earlier is retried here.
fwd := &message.Publish{
Namespace: msg.Namespace,
Name: msg.Name,
TrackAlias: msg.TrackAlias, // not yet remapped per-session
Parameters: publishParamsForSubscriber(msg.Parameters, sub, entry),
TrackProperties: msg.TrackProperties,
}
// OpenPublish is non-blocking (§6.1): if the subscriber's stream
// limit is exhausted it returns ErrNoStreamCredit — the PUBLISH_SKIPPED
// trigger handled below.
pubStream, err := sub.Session.OpenPublish(fwd)
if err != nil {
if errors.Is(err, session.ErrNoStreamCredit) {
// §6.1 / §10.21: no bidi-stream credit to open the PUBLISH
// stream — tell the subscriber with PUBLISH_SKIPPED on its
// SUBSCRIBE_TRACKS stream. The prohibition is scoped to this
// PUBLISH (draft-19); a later re-PUBLISH is retried above.
h.emitPublishSkipped(ctx, sub, fullName)
continue
}
h.log.LogAttrs(ctx, slog.LevelDebug, "PUBLISH forward failed",
slog.String("err", err.Error()))
continue
}
forwarded = append(forwarded, pubStream)
}
// Block until the publisher tears the stream down, routing §10.9
// responses to any upstream REQUEST_UPDATE the relay sends meanwhile
// (e.g. NEW_GROUP_REQUEST propagation).
h.serveUpstreamStream(ctx, sub)
// The publication ended (publisher FIN/reset). FIN every forwarded
// PUBLISH stream so each subscriber sees the publication terminate.
for _, s := range forwarded {
_ = s.Close()
}
}
// publishParamsForSubscriber builds the Parameters for a PUBLISH the relay
// sends to sub as a result of its SUBSCRIBE_TRACKS. Per §10.20.1, FORWARD
// (§10.2.18) and GROUP_ORDER (§10.2.8) on that PUBLISH derive from the
// SUBSCRIBE_TRACKS, not the upstream PUBLISH: any inherited from upstream are
// dropped, FORWARD=0 is set only when the subscriber asked not to forward
// (otherwise omitted → the default 1), and GROUP_ORDER is copied from the
// subscriber's request when it specified one (otherwise omitted, so the
// publisher's default applies).
//
// LARGEST_OBJECT is likewise not copied through. §10.2.17 requires a relay to
// send the largest of every value it has observed, and the upstream's own figure
// is only one of those: with a second upstream on the track, or with objects
// already received, forwarding it verbatim would advertise a watermark below the
// relay's own — so it is re-derived from the entry, which
// [saveLargestLocation] has already folded this PUBLISH's value into.
// §10.2.17 reserves omission for "no objects observed", which is what an entry
// with no watermark means.
func publishParamsForSubscriber(
upstream message.Parameters,
sub *registry.SubscriberEntry,
entry *registry.TrackEntry,
) message.Parameters {
out := make(message.Parameters, 0, len(upstream)+3)
for _, p := range upstream {
if p.Type == message.ParamForward || p.Type == message.ParamGroupOrder ||
p.Type == message.ParamLargestObject {
continue
}
out = append(out, p)
}
if !sub.Forward {
out = append(out, message.ForwardParam(false))
}
if sub.GroupOrder != 0 {
out = append(out, message.GroupOrderParam(message.GroupOrder(sub.GroupOrder)))
}
if largest, ok := entry.GetLargest(); ok {
out = append(out, message.LargestObjectParam(largest.Group, largest.Object))
}
return out
}
// emitPublishSkipped sends a PUBLISH_SKIPPED (§10.21) to sub for the track
// fullName. It is the §6.1 response to an exhausted bidi-stream limit: the
// relay cannot open the PUBLISH stream for this PUBLISH, so it tells the
// subscriber on its SUBSCRIBE_TRACKS response stream. Per draft-19 §6.1 the
// prohibition is scoped to this single PUBLISH — a later re-PUBLISH for the
// track is a fresh forwarding attempt — so nothing is recorded here.
//
// Per §10.21 the message carries only the namespace suffix beyond the
// subscriber's SUBSCRIBE_TRACKS prefix; we strip the prefix the same way
// [namespaceMessageFor] does.
func (h *sessionHandler) emitPublishSkipped(
ctx context.Context,
sub *registry.SubscriberEntry,
fullName track.FullTrackName,
) {
suffix := fullName.Namespace[len(sub.Prefix):]
skipped := &message.PublishSkipped{
TrackNamespaceSuffix: append(wire.TrackNamespace(nil), suffix...),
TrackName: fullName.Name,
}
if err := sub.WriteMessage(skipped); err != nil {
h.log.LogAttrs(ctx, slog.LevelDebug, "PUBLISH_SKIPPED write failed",
slog.String("err", err.Error()))
return
}
h.log.LogAttrs(ctx, slog.LevelDebug, "PUBLISH_SKIPPED sent",
slog.String("name", string(fullName.Name)))
}
package relay
import (
"context"
"errors"
"fmt"
"log/slog"
"slices"
"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"
)
// handleSubscribe implements the SUBSCRIBE flow (§9.4, §10.7):
//
// 1. Authorize.
// 2. Look up the track. If an Established upstream exists, serve from it
// (the §9.4 aggregation path).
// 3. Otherwise look for a matching local publisher in the
// [registry.NamespaceRegistry] (§9.5 prefix matching). If one is found, issue an
// upstream SUBSCRIBE on its session with the Largest Object filter
// (§9.4 "relays that aggregate upstream subscriptions can subscribe
// using the Largest Object filter to avoid churn") and on SUBSCRIBE_OK
// register the resulting registry.UpstreamSub.
// 4. If no local publisher is available either, reject with
// [moqt.RequestDoesNotExist]. Discovery-driven cross-relay lookup
// plugs in here.
// 5. Allocate an outbound Track Alias, register a [registry.DownstreamSub] in
// [registry.SubEstablished], reply SUBSCRIBE_OK, and block reading the request
// stream until the subscriber cancels.
func (h *sessionHandler) handleSubscribe(ctx context.Context, req *session.Request, msg *message.Subscribe) {
h.log.LogAttrs(ctx, slog.LevelDebug, "SUBSCRIBE received",
slog.String("namespace", fmt.Sprintf("%v", msg.Namespace)),
slog.String("name", string(msg.Name)))
if err := h.auth.AuthorizeSubscribe(ctx, h.sess, msg); err != nil {
h.rejectAuth(ctx, req, "Subscribe", err)
return
}
fullName := track.FullTrackName{Namespace: msg.Namespace, Name: msg.Name}
// §10.2.19: a NEW_GROUP_REQUEST on the SUBSCRIBE either rides the upstream
// SUBSCRIBE we are about to open (rule 1, no Established upstream) or, when
// an upstream already exists, is evaluated against it as an Established
// subscription below.
newGroupReqParam, hasNewGroupReq := msg.Parameters.Find(message.ParamNewGroupRequest)
// Allocate the Track Alias the relay uses when publishing this track
// downstream. Per §11.1 the outbound alias space is independent of the
// inbound aliases the peer chose for its own PUBLISHes.
alias := h.sess.AllocOutboundTrackAlias()
sub := registry.NewDownstreamSub(h.allocSubID(), h.sess, req.Stream, alias)
if err := installSubscribeParams(sub, msg.Parameters); err != nil {
if _, ok := errors.AsType[*paramProtocolViolation](err); ok {
// §10.2.8 / §10.2.18: an out-of-range GROUP_ORDER/FORWARD is a
// session-level PROTOCOL_VIOLATION.
h.log.LogAttrs(ctx, slog.LevelDebug, "SUBSCRIBE parameter protocol violation",
slog.String("err", err.Error()))
_ = h.sess.Close(moqt.SessionProtocolViolation, err.Error())
return
}
// §5.1.4 / §10.6: a malformed or over-limit Range Filter is INVALID_FILTER.
if errors.Is(err, message.ErrInvalidFilter) {
h.log.LogAttrs(ctx, slog.LevelDebug, "SUBSCRIBE range filter rejected",
slog.String("err", err.Error()))
_ = req.RejectError(moqt.RequestInvalidFilter, err.Error())
return
}
// §5.1.2 says a malformed LOCATION_FILTER is also a session-level
// PROTOCOL_VIOLATION. We scope that one to this request for now —
// unrelated subscriptions on the same session shouldn't die because
// one peer sent a bad filter.
h.log.LogAttrs(ctx, slog.LevelDebug, "SUBSCRIBE parameter parse failed",
slog.String("err", err.Error()))
_ = req.RejectError(moqt.RequestMalformedTrack, err.Error())
return
}
// Establish (or reuse) an upstream and register the downstream on it.
// Two attempts: AddDownstreamSnapshotLargest refuses to register on an
// entry whose last upstream vanished between the establish check and
// the registration (the §9.4 TOCTOU) — one retry re-runs the on-demand
// establish against the fresh state.
var (
entry *registry.TrackEntry
snapshotLargest message.Location
snapshotHas bool
reusedUpstream bool
added bool
)
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))
// Try to establish an upstream subscription against a local
// publisher that has advertised the namespace. The established
// entry is fetched again below via AddDownstreamSnapshotLargest,
// so only the side effect (registering the upstream) and the
// established check matter here.
var extra message.Parameters
if hasNewGroupReq {
extra = message.Parameters{message.NewGroupRequestParam(newGroupReqParam.Varint)}
}
// §9.2: the upstream Forward value is MUST=1 only if a downstream
// subscriber wants forwarding. The triggering sub is not yet on the
// entry (AddDownstreamSnapshotLargest runs below), so consult it
// directly alongside any downstream already registered on e.
wantForward := sub.ForwardState() == 1 || anyDownstreamForwards(e)
_, established, err := h.subscribeUpstream(ctx, fullName, extra, wantForward)
if err != nil {
// A candidate existed but every establish attempt errored. Log
// at Info with the underlying error so this transient failure is
// distinguishable in production from a genuine "nobody serves it".
h.log.LogAttrs(ctx, slog.LevelInfo, "SUBSCRIBE rejected: upstream subscribe failed",
slog.String("namespace", fmt.Sprintf("%v", msg.Namespace)),
slog.String("name", string(msg.Name)),
slog.Uint64("request_id", msg.RequestID),
slog.String("err", err.Error()))
_ = req.RejectError(moqt.RequestDoesNotExist, "relay: no upstream for track: "+err.Error())
return
}
if !established {
// No local publisher and no remote advertiser — a genuine miss
// or an advertise/subscribe race. Log at Info with the track
// identity + RequestID so it correlates with the client-side
// error (default level hides Debug).
h.log.LogAttrs(ctx, slog.LevelInfo, "SUBSCRIBE rejected: no publisher for namespace",
slog.String("namespace", fmt.Sprintf("%v", msg.Namespace)),
slog.String("name", string(msg.Name)),
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")
}
// Atomically append sub to the entry's Downstream AND snapshot the
// current LargestObject under one entry.mu acquisition. The atomic
// pairing closes the race where a publisher write between separate
// Add + GetLargest calls would update LargestObject + cache the
// object without delivering it to us via live fanout — leaving a
// gap that neither live nor Joining FETCH covers.
entry, snapshotLargest, snapshotHas, added = h.tracks.AddDownstreamSnapshotLargest(fullName, sub)
if added {
break
}
}
if !added {
h.log.LogAttrs(ctx, slog.LevelDebug, "SUBSCRIBE rejected: upstream vanished during registration")
_ = req.RejectError(moqt.RequestDoesNotExist, "relay: upstream vanished")
return
}
sub.SetLargestAtSubscribe(snapshotLargest, snapshotHas)
subRef := h.trackRef(fullName)
h.metrics.SubscriptionOpened(subRef)
defer h.metrics.SubscriptionClosed(subRef)
defer h.tracks.RemoveDownstream(fullName, sub.ID)
// §10.2.17: "If Objects have been published on this Track the
// Publisher MUST include this parameter." LARGEST_OBJECT in
// SUBSCRIBE_OK tells the subscriber where the live edge was when the
// subscription was accepted, which §5.1.6 has it use to size a fill.
var okParams message.Parameters
if sub.HasLargestAtSubscribe {
okParams = message.Parameters{
message.LargestObjectParam(
sub.LargestAtSubscribe.Group,
sub.LargestAtSubscribe.Object,
),
}
}
properties := entry.GetProperties()
// sub.WriteSubscribeOK, not req.Reply: the sub is registered, so a
// registry teardown goroutine can already reach it — every write on this
// stream must go through the sub's write lock from here on, and the
// OK/termination race must resolve to exactly one §10.7 response
// (a terminator that wins answers with REQUEST_ERROR and this write is
// skipped; see [registry.DownstreamSub.WriteSubscribeOK]).
if err := sub.WriteSubscribeOK(&message.SubscribeOK{
TrackAlias: alias,
Parameters: okParams,
TrackProperties: properties,
}); err != nil {
h.log.LogAttrs(ctx, slog.LevelDebug, "SUBSCRIBE_OK write failed",
slog.String("err", err.Error()))
return
}
h.log.LogAttrs(ctx, slog.LevelDebug, "SUBSCRIBE_OK sent, waiting for subscriber",
slog.String("name", string(msg.Name)),
slog.Uint64("alias", alias))
// §5.1.3: FILL_PARAMETERS on the SUBSCRIBE asks for a fill fetch stream,
// draft-20's replacement for the Joining FETCH. installSubscribeParams
// already rejected a malformed one, so an error here cannot be a protocol
// violation the peer has not been told about.
if err := h.maybeServeFill(ctx, sub, entry, fullName, msg.RequestID, msg.Parameters); err != nil {
h.log.LogAttrs(ctx, slog.LevelDebug, "fill fetch stream not opened",
slog.String("err", err.Error()))
}
// §10.2.19: when the SUBSCRIBE carried a NEW_GROUP_REQUEST and we are
// serving from an already-Established upstream (so the request did not ride
// a fresh upstream SUBSCRIBE), evaluate it against the upstream as an
// Established subscription and forward it if the rules call for it.
if hasNewGroupReq && reusedUpstream {
h.propagateNewGroupUpstream(ctx, fullName, newGroupReqParam.Varint)
}
// §9.2: a Forward=1 subscriber reusing an existing upstream that was paused
// (Forward=0, established when earlier downstreams didn't forward) MUST
// resume it. A freshly established upstream already reflects this subscriber
// via wantForward, so only the reuse path needs it; propagateForwardUpstream
// skips upstreams already forwarding, making this a no-op otherwise.
if reusedUpstream && sub.ForwardState() == 1 {
h.propagateForwardUpstream(ctx, fullName)
}
// Read follow-ups (§10.9 REQUEST_UPDATE, peer FIN/reset) on the bidi stream
// until the subscriber tears it down or ctx is cancelled, dispatching
// REQUEST_UPDATE so Forward / priority / filter can change mid-flight.
h.readSubscribeUpdates(ctx, req, sub, fullName)
h.log.LogAttrs(ctx, slog.LevelDebug, "SUBSCRIBE stream ended",
slog.String("name", string(msg.Name)))
}
// readSubscribeUpdates is the follow-up dispatch loop for an established
// downstream SUBSCRIBE. It parses messages off the bidi request stream and
// routes REQUEST_UPDATE (§10.9) to [sessionHandler.handleSubscribeUpdate];
// any other DECODABLE follow-up is ignored. An undecodable one ends the
// loop and resets the read side (see [readRequestStream]) — the eviction
// that follows is the same as on FIN/reset. The loop exits on io.EOF /
// reset (subscriber tore the stream down) or ctx cancellation (session
// shutdown), at which point the deferred cleanup in handleSubscribe evicts
// the subscription.
func (h *sessionHandler) readSubscribeUpdates(
ctx context.Context,
req *session.Request,
sub *registry.DownstreamSub,
fullName track.FullTrackName,
) {
updates := h.sess.NewRequestUpdateLimiter()
readRequestStream(ctx, req.Stream, func(m message.Message) bool {
if upd, ok := m.(*message.RequestUpdate); ok {
// §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 !h.handleFollowupTokens(ctx, upd) {
return false
}
h.handleSubscribeUpdate(ctx, sub, fullName, upd)
updates.Responded()
}
return true
})
}
// handleSubscribeUpdate applies a REQUEST_UPDATE (§10.9) to an established
// downstream subscription. Per §10.9 only the parameters present in the
// update change; omitted ones keep their prior value — which is exactly the
// "override present" behaviour of [installSubscribeParams]. On success it
// records whether the Forward State flipped 0→1 (so it can propagate Forward
// upstream per §9.2) and replies with the single mandated REQUEST_OK. On a
// malformed update it replies REQUEST_ERROR and terminates the subscription
// with PUBLISH_DONE / UPDATE_FAILED.
func (h *sessionHandler) handleSubscribeUpdate(
ctx context.Context,
sub *registry.DownstreamSub,
fullName track.FullTrackName,
upd *message.RequestUpdate,
) {
prevForward := sub.ForwardState()
if err := installSubscribeParams(sub, upd.Parameters); err != nil {
if _, ok := errors.AsType[*paramProtocolViolation](err); ok {
// §10.2.8 / §10.2.18: an out-of-range GROUP_ORDER/FORWARD is a
// session-level PROTOCOL_VIOLATION even in a REQUEST_UPDATE — the
// wire-level value is invalid, so it supersedes §10.9's
// request-scoped update-failure path.
h.log.LogAttrs(ctx, slog.LevelDebug, "REQUEST_UPDATE parameter protocol violation",
slog.String("err", err.Error()))
_ = h.sess.Close(moqt.SessionProtocolViolation, err.Error())
return
}
h.log.LogAttrs(ctx, slog.LevelDebug, "REQUEST_UPDATE parameter parse failed",
slog.String("err", err.Error()))
// §10.9: a failed subscription update is answered with REQUEST_ERROR
// and the publisher MUST also terminate the subscription with
// PUBLISH_DONE / UPDATE_FAILED. A bad Range Filter uses INVALID_FILTER
// (§10.6); other malformed params use MALFORMED_TRACK. Writes go through
// the sub's lock — see [registry.DownstreamSub.WriteMessage].
code := moqt.RequestMalformedTrack
if errors.Is(err, message.ErrInvalidFilter) {
code = moqt.RequestInvalidFilter
}
_ = sub.WriteMessage(&message.RequestError{
ErrorCode: code,
ErrorReason: err.Error(),
})
sub.TerminateWithPublishDone(moqt.PublishDoneUpdateFailed, err.Error(), 0)
return
}
if err := sub.WriteMessage(&message.RequestOK{}); err != nil {
h.log.LogAttrs(ctx, slog.LevelDebug, "REQUEST_UPDATE_OK write failed",
slog.String("err", err.Error()))
return
}
// §9.2: if this update flipped the downstream Forward State 0→1 and the
// relay's upstream subscriptions are paused (Forward 0), the relay MUST
// send REQUEST_UPDATE with Forward=1 to its publishers.
if prevForward == 0 && sub.ForwardState() == 1 {
h.propagateForwardUpstream(ctx, fullName)
}
// §10.2.19: a NEW_GROUP_REQUEST on a REQUEST_UPDATE for an Established
// subscription is forwarded upstream when the relay rules call for it.
if p, ok := upd.Parameters.Find(message.ParamNewGroupRequest); ok {
h.propagateNewGroupUpstream(ctx, fullName, p.Varint)
}
// §5.1.3: FILL_PARAMETERS on a REQUEST_UPDATE opens a further fill fetch
// stream, named by the REQUEST_UPDATE's own Request ID. It does not cancel
// any fill already in flight — a subscription can have several open at once.
if entry, ok := h.tracks.Get(fullName.Key()); ok {
if err := h.maybeServeFill(ctx, sub, entry, fullName, upd.RequestID, upd.Parameters); err != nil {
h.log.LogAttrs(ctx, slog.LevelDebug, "fill fetch stream not opened",
slog.String("err", err.Error()))
}
}
}
// propagateNewGroupUpstream implements the §10.2.19 relay handling for a
// NEW_GROUP_REQUEST received on an Established downstream subscription: when the
// track supports dynamic Groups and the request is not already covered, the
// relay sends a REQUEST_UPDATE carrying NEW_GROUP_REQUEST on each upstream
// subscription's stream. [registry.TrackEntry.ConsiderNewGroupRequest] encapsulates the
// decision and outstanding-request bookkeeping.
func (h *sessionHandler) propagateNewGroupUpstream(
ctx context.Context,
fullName track.FullTrackName,
value uint64,
) {
entry, ok := h.tracks.Get(fullName.Key())
if !ok {
return
}
dynamic, err := entry.DynamicGroups()
if err != nil {
// §12.6: a DYNAMIC_GROUPS value > 1 is a protocol violation by the
// upstream publisher. Scope the failure to declining the request
// rather than tearing the session down.
h.log.LogAttrs(ctx, slog.LevelDebug, "NEW_GROUP_REQUEST: bad DYNAMIC_GROUPS property",
slog.String("err", err.Error()))
return
}
if !entry.ConsiderNewGroupRequest(value, dynamic) {
return
}
for _, up := range entry.CopyUpstream() {
if !up.IsEstablished() {
continue
}
resp, err := up.Update(ctx, message.Parameters{message.NewGroupRequestParam(value)})
if err != nil {
h.log.LogAttrs(ctx, slog.LevelDebug, "upstream NEW_GROUP_REQUEST REQUEST_UPDATE failed",
slog.String("err", err.Error()))
continue
}
// §10.2.17 item 1 names REQUEST_UPDATE_OK too, and this is one: the
// response was previously discarded, so a watermark the upstream
// reported here never reached the entry.
saveLargestLocation(entry, resp.Parameters)
}
}
// propagateForwardUpstream implements the §9.2 relay obligation: when a
// downstream subscription becomes Forward=1 while the upstream subscriptions
// feeding its track are Forward=0, the relay re-emits REQUEST_UPDATE with
// Forward=1 on each upstream subscription's stream. The upstream's
// REQUEST_UPDATE_OK may carry LARGEST_OBJECT (the new Joining Location); we
// fold it into the track entry's largest watermark so a subsequent Joining
// FETCH is contiguous.
func (h *sessionHandler) propagateForwardUpstream(ctx context.Context, fullName track.FullTrackName) {
entry, ok := h.tracks.Get(fullName.Key())
if !ok {
return
}
for _, up := range entry.CopyUpstream() {
if up.ForwardState() == 1 || !up.IsEstablished() {
continue
}
resp, err := up.Update(ctx, message.Parameters{message.ForwardParam(true)})
if err != nil {
h.log.LogAttrs(ctx, slog.LevelDebug, "upstream REQUEST_UPDATE failed",
slog.String("err", err.Error()))
continue
}
up.SetForwardState(1)
saveLargestLocation(entry, resp.Parameters)
}
}
// subscribeUpstream establishes upstream SUBSCRIBEs for fullName. Per §9.5 it
// subscribes to EVERY matching source for fault tolerance — every local
// publisher advertising a covering namespace and every remote relay Discovery
// resolves (§9.4 cross-relay aggregation) — deduping already-subscribed
// sessions and fanning the rest into one track. The remote-relay branch honors
// an opt-in Config.UpstreamFanIn cap (see [upstreamPool.resolveUpstreams]); the
// local-publisher branch always takes every match. A failed candidate does not
// abort the others. Returns (entry, true, nil) when at least one upstream was
// established, (nil, false, nil) when none is available anywhere, and
// (nil, false, err) when every candidate failed with the last a hard error.
//
// extra carries parameters folded into each upstream SUBSCRIBE alongside the
// §9.4 Largest Object filter — currently the NEW_GROUP_REQUEST a downstream
// SUBSCRIBE arrived with (§10.2.19 rule 1).
func (h *sessionHandler) subscribeUpstream(
ctx context.Context,
fullName track.FullTrackName,
extra message.Parameters,
wantForward bool,
) (*registry.TrackEntry, bool, error) {
// Source dedup: never open a second upstream to a session this track is
// already subscribed on (or to ourselves — a publisher session also owns its
// PUBLISH_NAMESPACE, so subscribing on it would self-loop).
subscribed := map[*session.Session]bool{h.sess: true}
if entry, ok := h.tracks.Get(fullName.Key()); ok {
for _, u := range entry.CopyUpstream() {
subscribed[u.Session] = true
}
}
var (
resultEntry *registry.TrackEntry
anyEstab bool
lastErr error
)
establish := func(sess *session.Session, src string) {
if subscribed[sess] {
return
}
subscribed[sess] = true // even on failure: don't retry the same source here
h.log.LogAttrs(ctx, slog.LevelDebug, "subscribeUpstream: issuing upstream SUBSCRIBE",
slog.String("source", src))
entry, established, err := h.subscribeUpstreamOnSession(ctx, sess, fullName, extra, wantForward)
if err != nil {
// A candidate that fails (session dying, rejection) must not mask the
// other publishers or the Discovery fallback. Remember the error and
// keep going; surface it only if nothing else works out.
lastErr = err
h.log.LogAttrs(ctx, slog.LevelDebug, "subscribeUpstream: candidate failed, continuing",
slog.String("source", src), slog.String("err", err.Error()))
return
}
if established {
anyEstab = true
if resultEntry == nil {
resultEntry = entry
}
}
}
// 1. Every local publisher that advertised a namespace covering fullName.
publishers := h.names.MatchPublishers(fullName.Namespace)
h.log.LogAttrs(ctx, slog.LevelDebug, "subscribeUpstream: namespace registry lookup",
slog.String("namespace", fmt.Sprintf("%v", fullName.Namespace)),
slog.Int("publishers_found", len(publishers)))
for _, pub := range publishers {
establish(pub.Session, "local-publisher")
}
// 2. Remote relays Discovery resolves for this namespace. §9.5 fans into
// every advertiser by default; a positive Config.UpstreamFanIn instead
// caps this to the top rendezvous-ranked few. The pool dials + reuses one
// session per RelayAddr; resolveUpstreams returns nil when no other relay
// (besides ourselves) serves the namespace.
remotes := h.upstreams.resolveUpstreams(ctx, fullName.Namespace)
for _, remote := range remotes {
establish(remote, "discovery-remote")
}
if anyEstab {
return resultEntry, true, nil
}
// No upstream anywhere. Surface a candidate's failure if one occurred
// (a better diagnostic than a bare "no publisher"); otherwise (nil,false,nil)
// drives the §9.4 "does not exist" rejection.
//
// Log a summary at Info so the empty-result case is visible in production
// (default level hides Debug): it separates "no local publisher AND no
// remote advertiser" (a genuine miss or an advertise/subscribe race) from
// "candidates existed but every establish failed" (lastErr set).
logAttrs := []slog.Attr{
slog.String("namespace", fmt.Sprintf("%v", fullName.Namespace)),
slog.String("name", string(fullName.Name)),
slog.Int("local_publishers", len(publishers)),
slog.Int("remote_candidates", len(remotes)),
}
if lastErr != nil {
logAttrs = append(logAttrs, slog.String("last_err", lastErr.Error()))
}
h.log.LogAttrs(ctx, slog.LevelInfo, "subscribeUpstream: no upstream established", logAttrs...)
return nil, false, lastErr
}
// subscribeUpstreamOnSession issues the upstream SUBSCRIBE on sess and registers
// the resulting [registry.UpstreamSub] on the track entry. sess is either a local
// publisher's session or a Discovery-resolved remote relay's session — the body
// is identical, only the source differs.
//
// The §9.4 aggregation rule applies: the upstream SUBSCRIBE always uses the
// Largest Object filter so the upstream subscription's lifetime is decoupled
// from any specific downstream subscriber's filter. The relay can then serve
// many disparate downstream filters from one upstream stream — the fanout
// enforces each downstream filter on the wire.
func (h *sessionHandler) subscribeUpstreamOnSession(
ctx context.Context,
sess *session.Session,
fullName track.FullTrackName,
extra message.Parameters,
wantForward bool,
) (*registry.TrackEntry, bool, error) {
// §9.4 Largest Object filter — keeps the upstream subscription stable
// as downstream subscribers come and go with varying filters.
filter := &message.LocationFilter{Fields: 2}
// Bind the SUBSCRIBE message so we can read back the Request ID the
// session assigned (Subscribe mutates m.RequestID via AllocRequestID).
// The relay reuses that ID when it later sends an upstream
// REQUEST_UPDATE for §9.2 Forward propagation.
params := message.Parameters{message.LocationFilterParam(filter)}
// §9.2: Forward=1 upstream is MUST only when a downstream subscriber wants
// Objects forwarded. When none does, the relay exercises its discretion by
// pausing the upstream with Forward=0 so it doesn't pull Objects nobody is
// consuming; propagateForwardUpstream resumes it when a downstream later
// sets Forward=1. Forward=1 stays implicit (omitted) per §10.2.18.
if !wantForward {
params = append(params, message.ForwardParam(false))
}
params = append(params, extra...)
subMsg := &message.Subscribe{
Namespace: fullName.Namespace,
Name: fullName.Name,
Parameters: params,
}
// Create the track entry before Subscribe, because Subscribe registers
// the SUBSCRIBE_OK's §11.1 Track Alias inside its own response handler —
// from the moment it returns the alias resolves on inbound data streams,
// and the publisher may already be writing. Until an entry exists there is
// nothing for runFanout to route to, so those streams are reset and their
// Objects lost from the cache and from live fanout alike (#85).
//
// Deliberately before rather than inside the round trip: anything done in
// the gap between SUBSCRIBE_OK arriving and the alias being registered
// widens a second window in which the same streams are dropped as unknown
// aliases instead, and allocating an entry (a 1024-slot cache ring) there
// measurably does. Doing it up front costs an entry for a track that may
// turn out not to exist; trackKnown in handleFetch is what keeps that from
// being visible on the wire.
var entryCreated bool
_, entryCreated = h.tracks.GetOrCreateNew(fullName)
upstreamStream, err := sess.Subscribe(ctx, subMsg)
if err != nil {
if entryCreated {
// Nothing vouched for this track after all. Leaving the entry
// would grow the registry without bound on a session that
// SUBSCRIBEs to names that do not resolve.
h.tracks.DeleteIfUnused(fullName)
}
return nil, false, err
}
if hook := testHookAfterAliasRegistered.Load(); hook != nil {
(*hook)(fullName)
}
// Register the upstream subscription on the upstream session as an
// registry.UpstreamSub. The upstream's TrackAlias is the alias the upstream peer
// assigned in SUBSCRIBE_OK; we use it for the fanout's alias remapping.
// The SUBSCRIBE's Request ID (assigned inside sess.Subscribe) is recorded
// for identity; a later upstream REQUEST_UPDATE rides this stream but
// consumes its own fresh ID (§10.1).
upstreamSub := registry.NewUpstreamSub(
h.allocSubID(), sess, upstreamStream, upstreamStream.OK.TrackAlias, subMsg.RequestID)
upstreamSub.SetFilter(filter)
if !wantForward {
// Match the local ForwardState to the Forward=0 we sent upstream, so a
// later §9.2 resume (propagateForwardUpstream) transitions 0→1 rather
// than treating the upstream as already forwarding. NewUpstreamSub
// seeds 1 (the omitted-FORWARD default).
upstreamSub.SetForwardState(0)
}
// This upstream is a relay/origin we SUBSCRIBE'd on demand, so it is
// expected to answer FETCH — eligible for §9.4 stitch backfill.
upstreamSub.FetchCapable = true
// Mark it on-demand so the registry tears it down when its last
// downstream leaves (see [registry.TrackRegistry.RemoveDownstream]).
upstreamSub.OnDemand = true
entry, _ := h.tracks.AddUpstream(fullName, upstreamSub, registry.WithProperties(upstreamStream.OK.TrackProperties))
// §10.2.17 item 1: a LARGEST_OBJECT in this SUBSCRIBE_OK is one of the
// values the relay's own watermark MUST be the largest of. Unconditional on
// purpose — see [saveLargestLocation] for why the §5.1 Forward-State
// qualifier must not be applied here.
saveLargestLocation(entry, upstreamStream.OK.Parameters)
// The reader's lifetime is tied to the UPSTREAM stream, not to the
// downstream subscriber whose SUBSCRIBE happened to trigger this
// subscription — other sessions' subscribers share it (§9.4), so it
// must survive this handler's teardown. It runs relay-scoped (joined
// by Relay.Stop) with the stream's own context: the ctx dies when the
// upstream stream or session ends, and Stop force-closes sessions,
// which errors the reader's Parse either way.
h.relayGo(func() {
h.serveUpstreamStream(upstreamStream.Context(), upstreamSub)
h.tracks.RemoveUpstream(fullName, upstreamSub.ID)
// session.Subscribe registered the SUBSCRIBE_OK's Track Alias for
// inbound routing; drop it with the subscription so subscriber
// churn on a long-lived (pooled) upstream session doesn't accrete
// aliases (§11.1) — a peer reusing a retired alias would otherwise
// trip the duplicate-alias session error.
sess.UnregisterInboundTrackAlias(upstreamStream.OK.TrackAlias)
})
return entry, true, nil
}
// serveUpstreamStream owns ALL reads on an upstream request stream (the
// relay's on-demand SUBSCRIBE to a publisher, or an accepted PUBLISH) via
// the sub's [session.RequestBroker]: §10.9 responses route to in-flight
// [registry.UpstreamSub.Update] calls, peer REQUEST_UPDATEs are answered
// with the single mandated REQUEST_OK, and AUTHORIZATION_TOKEN parameters
// go through the session token cache (§10.2.2) — all inside Serve. Other
// follow-ups need no action (PUBLISH_DONE precedes the FIN that ends the
// loop); unsolicited responses are logged. It returns when the publisher
// tears the stream down (EOF / reset) or ctx is cancelled.
//
// Do NOT read the stream anywhere else (e.g. [session.DrainAndWait] or
// [session.Session.UpdateRequest]) while this runs: a second reader races
// the broker for the §10.9 responses.
func (h *sessionHandler) serveUpstreamStream(ctx context.Context, up *registry.UpstreamSub) {
err := up.Broker.Serve(ctx, func(m message.Message) bool {
switch m.(type) {
case *message.RequestOK, *message.RequestError:
// Serve only hands responses here when no Update was pending.
h.log.LogAttrs(ctx, slog.LevelDebug,
"unsolicited response on upstream request stream",
slog.Uint64("sub_id", up.ID))
default:
// PUBLISH_DONE and other follow-ups: the publisher FINs
// the stream afterwards, which ends the loop and lets
// the caller unregister the upstream.
}
return true
})
if err != nil && ctx.Err() == nil {
h.log.LogAttrs(ctx, slog.LevelDebug, "upstream request stream reader ended",
slog.Uint64("sub_id", up.ID), slog.String("err", err.Error()))
}
}
// hasEstablishedUpstream reports whether the entry has at least one upstream
// subscription in [registry.SubEstablished]. The §9.4 SUBSCRIBE handler uses this as
// the test for "can we serve a new downstream subscription from existing
// upstream state?".
func hasEstablishedUpstream(entry *registry.TrackEntry) bool {
for _, u := range entry.CopyUpstream() {
if u.IsEstablished() {
return true
}
}
return false
}
// anyDownstreamForwards reports whether any downstream subscriber already
// registered on entry wants Objects forwarded (Forward=1). §9.2 uses it (with
// the not-yet-registered triggering sub checked separately) to decide the
// upstream Forward value. A nil entry (no track state yet) reports false.
func anyDownstreamForwards(entry *registry.TrackEntry) bool {
return entry != nil && slices.ContainsFunc(entry.CopyDownstream(),
func(d *registry.DownstreamSub) bool { return d.ForwardState() == 1 })
}
// installSubscribeParams extracts the per-subscription policy fields from the
// SUBSCRIBE parameters (§10.2) and records them on sub: LOCATION_FILTER
// (§10.2.9), SUBSCRIBER_PRIORITY (§10.2.7, advisory), GROUP_ORDER (§10.2.8),
// OBJECT_DELIVERY_TIMEOUT (§10.2.4) and SUBGROUP_DELIVERY_TIMEOUT (§10.2.3).
//
// The §5.1.2 / §9.4 LargestObject snapshot is intentionally NOT taken here — the
// caller captures it atomically via
// [registry.TrackRegistry.AddDownstreamSnapshotLargest] and applies it with
// [registry.DownstreamSub.SetLargestAtSubscribe].
func installSubscribeParams(sub *registry.DownstreamSub, ps message.Parameters) error {
filter, err := message.LocationFilterFromParam(ps)
if err != nil {
return fmt.Errorf("location filter: %w", err)
}
if filter != nil {
if err := filter.Validate(); err != nil {
return err
}
sub.SetFilter(filter)
}
// §10.2.15: a parameter inside FILL_PARAMETERS that is not in Table 6 is a
// session-level PROTOCOL_VIOLATION. Parse it here so a malformed fill is
// rejected before the subscription is answered; the stream itself is opened
// after SUBSCRIBE_OK.
if _, _, err := message.FillParametersFromParam(ps); err != nil {
return ¶mProtocolViolation{err.Error()}
}
// §10.2.21: INCLUDE_PROPERTIES outside {0,1} is likewise a violation.
if _, err := message.IncludePropertiesFromParam(ps); err != nil {
return ¶mProtocolViolation{err.Error()}
}
if err := checkForwardParam(ps); err != nil {
return err
}
if p, ok := ps.Find(message.ParamForward); ok {
sub.SetForwardState(int(p.Byte))
}
if p, ok := ps.Find(message.ParamSubscriberPriority); ok {
sub.SetPriority(p.Byte)
}
if err := checkGroupOrderParam(ps); err != nil {
return err
}
if p, ok := ps.Find(message.ParamGroupOrder); ok {
sub.SetGroupOrder(p.Byte)
}
// §10.2.3 / §10.2.4 delivery timeouts, overridden per parameter — the same
// "override present" behaviour as the fields above, applied to each
// dimension separately rather than to the pair. They are independent values
// that merely travel together: an absent parameter decodes to zero and §8
// gives zero the meaning "no timeout", so installing the decoded pair
// whenever either is present would let a REQUEST_UPDATE that adjusts one
// timeout silently disable the other.
timeouts := sub.GetDeliveryTimeouts()
if p, ok := ps.Find(message.ParamObjectDeliveryTimeout); ok {
timeouts.Object = message.MillisecondTimeout(p.Varint)
}
if p, ok := ps.Find(message.ParamSubgroupDeliveryTimeout); ok {
timeouts.Subgroup = message.MillisecondTimeout(p.Varint)
}
sub.SetDeliveryTimeouts(timeouts)
// §5.1.4 Range Filters. They are installed only when present, so an update
// carrying none leaves the existing set unchanged. A malformed/over-limit
// set is a §10.6 INVALID_FILTER (request-scoped) — the caller maps
// message.ErrInvalidFilter to REQUEST_ERROR INVALID_FILTER.
//
// LIMITATION (draft-19 §5.1.4 REQUEST_UPDATE semantics not fully done): an
// update carrying any range-filter param replaces the WHOLE set, rather than
// the spec's per-parameter-type replace (non-zero Length) / remove (Length 0)
// with untouched types preserved. So a partial update wipes other filter
// types, and a Length-0 "remove" param is rejected as INVALID_FILTER instead
// of removing that type. Initial SUBSCRIBE and add-on-update work correctly;
// see the tracked follow-up for the per-type merge.
rf, err := message.RangeFiltersFromParams(ps)
if err != nil {
return err
}
if rf != nil {
if err := rf.Validate(sub.Session.MaxFilterRanges()); err != nil {
return err
}
sub.SetRangeFilters(rf)
}
return nil
}
// paramProtocolViolation marks a parameter value that draft-19 requires the
// receiver answer with a session-level PROTOCOL_VIOLATION — an out-of-range
// GROUP_ORDER (§10.2.8) or FORWARD (§10.2.18) — as opposed to a request-scoped
// REQUEST_ERROR. Callers detect it with errors.AsType and close the session
// with [moqt.SessionProtocolViolation].
type paramProtocolViolation struct{ reason string }
func (e *paramProtocolViolation) Error() string { return e.reason }
// checkForwardParam enforces the §10.2.18 FORWARD value range (0 or 1). An
// out-of-range value is a *paramProtocolViolation; absent or valid → nil.
func checkForwardParam(ps message.Parameters) error {
if p, ok := ps.Find(message.ParamForward); ok && p.Byte > 1 {
return ¶mProtocolViolation{fmt.Sprintf("invalid FORWARD value 0x%X (§10.2.18)", p.Byte)}
}
return nil
}
// checkGroupOrderParam enforces the §10.2.8 GROUP_ORDER value range (Ascending
// 0x1 or Descending 0x2). An out-of-range value is a *paramProtocolViolation;
// absent or valid → nil.
func checkGroupOrderParam(ps message.Parameters) error {
if p, ok := ps.Find(message.ParamGroupOrder); ok {
switch message.GroupOrder(p.Byte) {
case message.GroupOrderAscending, message.GroupOrderDescending:
default:
return ¶mProtocolViolation{fmt.Sprintf("invalid GROUP_ORDER value 0x%X (§10.2.8)", p.Byte)}
}
}
return nil
}
package relay
import (
"context"
"errors"
"log/slog"
"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"
)
// handleTrackStatus implements TRACK_STATUS (§10.15): a metadata-only query for
// a track's Properties and existence, answered without creating a subscription
// or round-tripping upstream. The reply is REQUEST_OK (aliased as
// [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].
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)
return
}
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.
var (
largest message.Location
hasLargest bool
)
if known {
largest, hasLargest = entry.GetLargest()
}
hasProperties := known && len(entry.GetProperties()) > 0
if known && (hasProperties || hasLargest) {
reply := &message.TrackStatusOK{}
if hasProperties {
reply.TrackProperties = entry.GetProperties()
}
if hasLargest {
// §10.2.17: omit LARGEST_OBJECT when no objects have
// been observed; emit it (and the watermark) otherwise.
reply.Parameters = append(reply.Parameters,
message.LargestObjectParam(largest.Group, largest.Object))
}
if err := req.Reply(reply); err != nil {
h.log.LogAttrs(ctx, slog.LevelDebug, "TRACK_STATUS_OK write failed",
slog.String("err", err.Error()))
}
// TRACK_STATUS is a one-shot RPC; the spec does not keep the
// stream open for further messages (cf. §10.15). FIN the send
// side now.
_ = req.Stream.Close()
return
}
// No local track entry with properties. Check the namespace
// registry — if a publisher has advertised the namespace, the track
// at least *might* exist, so we reply with an empty Properties block.
// No LARGEST_OBJECT either: nothing has been observed.
if len(h.names.MatchPublishers(msg.Namespace)) > 0 {
if err := req.Reply(&message.TrackStatusOK{}); err != nil {
h.log.LogAttrs(ctx, slog.LevelDebug, "TRACK_STATUS_OK (empty) write failed",
slog.String("err", err.Error()))
}
_ = req.Stream.Close()
return
}
if err := req.RejectError(moqt.RequestDoesNotExist, "relay: track not known"); err != nil &&
!errors.Is(err, context.Canceled) {
h.log.LogAttrs(ctx, slog.LevelDebug, "TRACK_STATUS reject write failed",
slog.String("err", err.Error()))
}
}
package registry
import (
"sync"
"time"
"github.com/floatdrop/moq-go/pkg/moqt"
"github.com/floatdrop/moq-go/pkg/moqt/session"
)
// fetchResponseGrace bounds how long an upstream FETCH response stream waits
// for its requesting reader to register before the router gives up and resets
// it. It only matters when the response data stream is dispatched by the
// upstream session's data loop before the downstream handler has registered:
// the Request ID is known only after [session.Session.Fetch] returns, so a
// fast upstream can race the registration. Generous relative to the in-process
// and LAN round-trips it guards against.
const fetchResponseGrace = 5 * time.Second
// fetchKey identifies an in-flight upstream FETCH by the session it was issued
// on and the Request ID the session assigned.
type fetchKey struct {
sess *session.Session
reqID uint64
}
// FetchRouter rendezvouses upstream FETCH response streams with the downstream
// handler that issued the FETCH. The two sides run on different goroutines —
// the requester (a downstream FETCH handler) calls [FetchRouter.Register] and
// awaits, while the upstream session's data loop calls [FetchRouter.Deliver] —
// and they may arrive in either order, so each side get-or-creates the
// rendezvous and a buffered slot holds the stream until the reader takes it.
//
// One FetchRouter is shared per [Relay] and injected into every session
// handler.
type FetchRouter struct {
mu sync.Mutex
pending map[fetchKey]chan *session.IncomingFetchStream
}
func NewFetchRouter() *FetchRouter {
return &FetchRouter{pending: make(map[fetchKey]chan *session.IncomingFetchStream)}
}
// chanLocked returns the rendezvous channel for key, creating it if absent.
// created reports whether this call created the entry. The caller holds r.mu.
func (r *FetchRouter) chanLocked(key fetchKey) (ch chan *session.IncomingFetchStream, created bool) {
ch, ok := r.pending[key]
if !ok {
ch = make(chan *session.IncomingFetchStream, 1)
r.pending[key] = ch
created = true
}
return ch, created
}
// Register reserves the rendezvous for an upstream FETCH the caller is about
// to issue (or just issued) on sess with the assigned reqID. It returns the
// channel the response stream will arrive on and a cleanup func the caller
// MUST defer: cleanup removes the entry and resets any stream that arrived but
// was never consumed (e.g. the caller timed out waiting).
func (r *FetchRouter) Register(
sess *session.Session,
reqID uint64,
) (<-chan *session.IncomingFetchStream, func()) {
key := fetchKey{sess: sess, reqID: reqID}
r.mu.Lock()
ch, _ := r.chanLocked(key)
r.mu.Unlock()
cleanup := func() {
r.mu.Lock()
if cur, ok := r.pending[key]; ok && cur == ch {
delete(r.pending, key)
}
r.mu.Unlock()
// Reset a stream that landed after the reader gave up.
select {
case s := <-ch:
if s != nil {
s.Cancel(moqt.StreamResetInternalError)
}
default:
}
}
return ch, cleanup
}
// Deliver hands an upstream FETCH response stream to its waiting reader. It
// reports whether the stream was accepted into the rendezvous. When Deliver
// creates the rendezvous (the response arrived before the reader registered),
// it schedules a grace timer that resets the stream if no reader claims it, so
// a stray response can't leak. It returns false only when a stream is already
// parked for the same key (a duplicate or unexpected response); the caller
// resets the incoming stream in that case.
func (r *FetchRouter) Deliver(sess *session.Session, reqID uint64, stream *session.IncomingFetchStream) bool {
key := fetchKey{sess: sess, reqID: reqID}
r.mu.Lock()
ch, created := r.chanLocked(key)
r.mu.Unlock()
select {
case ch <- stream:
default:
return false // a stream is already parked for this key
}
if created {
time.AfterFunc(fetchResponseGrace, func() {
r.mu.Lock()
cur, ok := r.pending[key]
if !ok || cur != ch {
r.mu.Unlock()
return
}
delete(r.pending, key)
r.mu.Unlock()
select {
case s := <-ch:
if s != nil {
s.Cancel(moqt.StreamResetInternalError)
}
default:
}
})
}
return true
}
package registry
import (
"context"
"errors"
"log/slog"
"slices"
"sync"
"github.com/floatdrop/moq-go/pkg/moqt/message"
"github.com/floatdrop/moq-go/pkg/moqt/session"
"github.com/floatdrop/moq-go/pkg/moqt/wire"
"github.com/floatdrop/moq-go/pkg/relay/discovery"
)
// PublisherEntry records a single PUBLISH_NAMESPACE advertisement received
// from a publisher (or upstream relay). The relay holds onto the bidi
// Stream because §6.2 / §10.16 require the same stream to stay open for the
// lifetime of the advertisement — that's also where REQUEST_OK / REQUEST_ERROR
// and the eventual cancellation FIN flow.
type PublisherEntry struct {
// Namespace is the exact tuple the publisher advertised (§2.4.1).
Namespace wire.TrackNamespace
// Session is the MOQT session that owns the PUBLISH_NAMESPACE.
Session *session.Session
// Stream is the bidi request stream the PUBLISH_NAMESPACE arrived on.
// The session handler reads further control messages from it and is
// the owner that closes/cancels it on teardown.
Stream session.Stream
}
// SubscriberEntry records a single SUBSCRIBE_NAMESPACE or SUBSCRIBE_TRACKS
// announcement received from a subscriber (or downstream relay). §6.1 says
// these are open-ended subscriptions to a *prefix*: the relay must echo any
// matching PUBLISH_NAMESPACE / PUBLISH back to the subscriber as long as the
// subscription is alive.
type SubscriberEntry struct {
// Prefix is the namespace prefix the subscriber asked to be notified
// about. A zero-field prefix means "all namespaces" (§6.1).
Prefix wire.TrackNamespace
// Session is the MOQT session that owns the SUBSCRIBE_NAMESPACE /
// SUBSCRIBE_TRACKS.
Session *session.Session
// Stream is the bidi request stream the subscription arrived on. The
// session handler writes NAMESPACE / NAMESPACE_DONE / PUBLISH
// messages back through this stream when matching publishers appear
// or vanish. All such writes MUST go through [SubscriberEntry.WriteMessage]
// (guarded by writeMu) — the stream is written from several goroutines
// (every publisher's PUBLISH_NAMESPACE / PUBLISH handler and the
// relay-level Discovery namespace watcher), and the underlying
// session.Stream does not serialise concurrent Writes.
Stream session.Stream
// writeMu serialises concurrent control-message writes to Stream. A
// single control message is several stream Writes (frame header + body),
// so without this two interleaving Marshal calls would corrupt the wire
// framing (and race the underlying QUIC stream). It guards only writes —
// it is independent of the owning [NamespaceRegistry]'s mutex.
writeMu sync.Mutex
// WantsTracks distinguishes SUBSCRIBE_TRACKS (true: forward PUBLISH
// messages for matching tracks) from SUBSCRIBE_NAMESPACE (false: only
// NAMESPACE / NAMESPACE_DONE). The two share a registry entry because
// they share the prefix-matching semantics — the session handler
// dispatches on this flag.
WantsTracks bool
// Forward and GroupOrder carry the FORWARD (§10.2.18) and GROUP_ORDER
// (§10.2.8) parameters from the SUBSCRIBE_TRACKS, which §10.20.1 copies
// onto every PUBLISH the subscription triggers. Set once at registration
// (never mutated), so reads in the PUBLISH fanout need no lock. They are
// meaningful only when WantsTracks: Forward defaults to true (FORWARD
// omitted or 1); GroupOrder is 0 when omitted (the publisher's default
// applies) or the validated Ascending/Descending value.
Forward bool
GroupOrder byte
// RangeFilters holds the §5.1.4 Range Filters on the SUBSCRIBE_TRACKS. The
// PUBLISH forwarding loop evaluates TRACK_PROPERTY_FILTER (§10.2.14) against
// each PUBLISH's Track Properties via MatchesTrack; a PUBLISH that fails is
// not forwarded (§5.1.4). Set once at registration. nil = no restriction.
RangeFilters *message.RangeFilterSet
}
// WriteMessage serialises one control message onto the subscriber's request
// stream. NAMESPACE / NAMESPACE_DONE / PUBLISH_SKIPPED are written to a single
// SubscriberEntry from multiple goroutines — the subscriber's own session
// handler, every publisher's PUBLISH_NAMESPACE / PUBLISH handler, and the
// relay-level Discovery namespace watcher — so the write is taken under writeMu
// to keep one message's frames contiguous on the wire.
func (e *SubscriberEntry) WriteMessage(m message.Message) error {
e.writeMu.Lock()
defer e.writeMu.Unlock()
return message.Marshal(e.Stream, m)
}
// NamespaceRegistry maintains the relay's view of who advertises which
// namespaces and who has subscribed to which prefixes (§9.5 / §6.1).
//
// Two slices, two queries:
//
// - publishers, populated by [Register…] / drained by [Unregister…],
// queried by [MatchPublishers] when an inbound SUBSCRIBE arrives and
// the relay needs to find upstream(s) for its track.
// - subscribers, queried by [MatchSubscribers] when an inbound
// PUBLISH_NAMESPACE / PUBLISH arrives and the relay needs to forward
// notifications downstream.
//
// Linear scans for both queries are intentional: namespace cardinality is
// far lower than per-track cardinality and these matches are not on the
// object fanout hot path. If profiling later shows otherwise, swapping in a
// trie behind the same API is a contained change.
//
// Cross-instance namespace advertisement is delegated to the optional
// [discovery.DiscoveryStore]; when configured, the registry mirrors
// publish / unpublish events into it.
type NamespaceRegistry struct {
mu sync.RWMutex
publishers []*PublisherEntry
subscribers []*SubscriberEntry
// pubCount refs each distinct namespace by its wire-encoded key.
// Used to coalesce Discovery PublishNamespace / UnpublishNamespace
// calls: only the 0→1 and 1→0 transitions fire events, so two
// publishers advertising the same namespace from the same relay
// produce one Discovery entry, not two.
pubCount map[string]int
// discovery / relayAddr / log mirror [TrackRegistry] — see those
// docs. nil discovery means "do not advertise"; failures log at
// Warn and are not propagated.
discovery discovery.DiscoveryStore
relayAddr string
log *slog.Logger
}
// NamespaceRegistryOption tweaks a [NamespaceRegistry] at construction.
type NamespaceRegistryOption func(*NamespaceRegistry)
// WithNamespaceDiscovery installs a [discovery.DiscoveryStore] for
// cross-instance namespace advertisement. relayAddr is stamped into
// every [discovery.NamespaceInfo] this registry emits.
func WithNamespaceDiscovery(d discovery.DiscoveryStore, relayAddr string) NamespaceRegistryOption {
return func(r *NamespaceRegistry) {
r.discovery = d
r.relayAddr = relayAddr
}
}
// WithNamespaceRegistryLogger sets the logger used for Discovery
// warnings.
func WithNamespaceRegistryLogger(l *slog.Logger) NamespaceRegistryOption {
return func(r *NamespaceRegistry) { r.log = l }
}
// NewNamespaceRegistry constructs an empty registry.
func NewNamespaceRegistry(opts ...NamespaceRegistryOption) *NamespaceRegistry {
r := &NamespaceRegistry{
pubCount: make(map[string]int),
log: slog.Default(),
}
for _, opt := range opts {
opt(r)
}
return r
}
// RegisterPublisher records a publisher's PUBLISH_NAMESPACE. The returned
// pointer is the canonical record; callers should keep it for the eventual
// [NamespaceRegistry.UnregisterPublisher] call rather than rebuilding it.
//
// Duplicate registrations from the same session for the same namespace are
// not deduplicated — §9.3 explicitly permits a relay to see PUBLISH_NAMESPACE
// for the same namespace from multiple publishers, and even a single session
// can in principle re-advertise after withdrawing. Callers that want
// duplicate-suppression policy enforce it at the request-handler layer.
func (r *NamespaceRegistry) RegisterPublisher(
ns wire.TrackNamespace,
sess *session.Session,
stream session.Stream,
) *PublisherEntry {
entry := &PublisherEntry{Namespace: ns, Session: sess, Stream: stream}
key := namespaceWireKey(ns)
r.mu.Lock()
r.publishers = append(r.publishers, entry)
r.pubCount[key]++
if r.pubCount[key] == 1 {
// Under r.mu, so publish/unpublish reach the Discovery store in
// exactly the order pubCount crossed the 0 boundary — see
// [NamespaceRegistry.unpublishNamespaceFromDiscovery].
r.publishNamespaceToDiscovery(ns)
}
r.mu.Unlock()
return entry
}
// UnregisterPublisher removes a previously registered publisher entry. The
// caller passes the exact pointer that RegisterPublisher returned — this
// avoids any ambiguity when the same (session, namespace) pair has multiple
// concurrent registrations.
//
// Returns true if the entry was found and removed.
func (r *NamespaceRegistry) UnregisterPublisher(entry *PublisherEntry) bool {
r.mu.Lock()
before := len(r.publishers)
r.publishers = slices.DeleteFunc(r.publishers, func(e *PublisherEntry) bool {
return e == entry
})
removed := len(r.publishers) < before
if removed {
key := namespaceWireKey(entry.Namespace)
r.pubCount[key]--
if r.pubCount[key] <= 0 {
delete(r.pubCount, key)
// Under r.mu — see
// [NamespaceRegistry.unpublishNamespaceFromDiscovery].
r.unpublishNamespaceFromDiscovery(entry.Namespace)
}
}
r.mu.Unlock()
return removed
}
// RegisterSubscriber records a subscriber's SUBSCRIBE_NAMESPACE (when
// wantsTracks is false) or SUBSCRIBE_TRACKS (when true). forward, groupOrder,
// and rangeFilters carry the SUBSCRIBE_TRACKS FORWARD/GROUP_ORDER passthrough
// (§10.20.1) and §5.1.4 Range Filters, and are ignored unless wantsTracks.
// Returns the canonical pointer for use with
// [NamespaceRegistry.UnregisterSubscriber].
func (r *NamespaceRegistry) RegisterSubscriber(
prefix wire.TrackNamespace,
sess *session.Session,
stream session.Stream,
wantsTracks bool,
forward bool,
groupOrder byte,
rangeFilters *message.RangeFilterSet,
) *SubscriberEntry {
entry := &SubscriberEntry{
Prefix: prefix,
Session: sess,
Stream: stream,
WantsTracks: wantsTracks,
Forward: forward,
GroupOrder: groupOrder,
RangeFilters: rangeFilters,
}
r.mu.Lock()
r.subscribers = append(r.subscribers, entry)
r.mu.Unlock()
return entry
}
// UnregisterSubscriber removes a previously registered subscriber entry.
// Returns true if the entry was found and removed.
func (r *NamespaceRegistry) UnregisterSubscriber(entry *SubscriberEntry) bool {
r.mu.Lock()
defer r.mu.Unlock()
before := len(r.subscribers)
r.subscribers = slices.DeleteFunc(r.subscribers, func(e *SubscriberEntry) bool {
return e == entry
})
return len(r.subscribers) < before
}
// RemoveSession removes every publisher and subscriber entry owned by sess.
// This is the bulk-cleanup path session handlers take when the underlying
// transport dies — they cannot iterate the registry themselves without
// risking a stale view, so the registry does it under its own lock.
//
// Returns the number of publisher entries and subscriber entries removed,
// in that order. The split lets callers distinguish what Discovery
// unpublish calls were warranted by the cleanup.
func (r *NamespaceRegistry) RemoveSession(sess *session.Session) (publishers, subscribers int) {
r.mu.Lock()
beforeP := len(r.publishers)
// Collect the namespaces this session owned so we can decrement
// pubCount under the same lock — without it we'd lose the
// "last publisher leaving the relay" signal that Discovery needs.
var toUnadvertise []wire.TrackNamespace
for _, e := range r.publishers {
if e.Session != sess {
continue
}
key := namespaceWireKey(e.Namespace)
r.pubCount[key]--
if r.pubCount[key] <= 0 {
delete(r.pubCount, key)
toUnadvertise = append(toUnadvertise, e.Namespace)
}
}
r.publishers = slices.DeleteFunc(r.publishers, func(e *PublisherEntry) bool {
return e.Session == sess
})
beforeS := len(r.subscribers)
r.subscribers = slices.DeleteFunc(r.subscribers, func(e *SubscriberEntry) bool {
return e.Session == sess
})
// Capture the final lengths under the lock; reading them after
// Unlock races with concurrent RemoveSession calls.
pubsRemoved := beforeP - len(r.publishers)
subsRemoved := beforeS - len(r.subscribers)
for _, ns := range toUnadvertise {
// Under r.mu — see
// [NamespaceRegistry.unpublishNamespaceFromDiscovery].
r.unpublishNamespaceFromDiscovery(ns)
}
r.mu.Unlock()
return pubsRemoved, subsRemoved
}
// publishNamespaceToDiscovery advertises ns to the Discovery store
// (best-effort). See [TrackRegistry.publishTrackToDiscovery] for the
// rationale around synchronous calls + log-and-swallow errors.
func (r *NamespaceRegistry) publishNamespaceToDiscovery(ns wire.TrackNamespace) {
if r.discovery == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), discoveryCallTimeout)
defer cancel()
if err := r.discovery.PublishNamespace(ctx, discovery.NamespaceInfo{
Prefix: ns,
RelayAddr: r.relayAddr,
}); err != nil && !errors.Is(err, discovery.ErrWithdrawn) {
// See [TrackRegistry.publishTrackToDiscovery]: a withdrawn store is
// shutting down, not broken.
r.log.Warn("discovery: PublishNamespace failed", "err", err.Error(), "namespace", ns)
}
}
// unpublishNamespaceFromDiscovery is the counterpart called when the last
// publisher of a namespace leaves.
//
// The caller MUST hold r.mu. Both this and [publishNamespaceToDiscovery]
// run under the registry lock so the store receives publish/unpublish in
// exactly the order pubCount crossed 0 — a late unpublish issued after
// releasing r.mu could race a concurrent RegisterPublisher's publish and
// erase the re-advertised namespace's record. The Discovery call is bounded
// by [discoveryCallTimeout] — and the interface requires backends to honor
// ctx deadlines — so the lock hold is bounded too.
func (r *NamespaceRegistry) unpublishNamespaceFromDiscovery(ns wire.TrackNamespace) {
if r.discovery == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), discoveryCallTimeout)
defer cancel()
if err := r.discovery.UnpublishNamespace(ctx, ns, r.relayAddr); err != nil {
r.log.Warn("discovery: UnpublishNamespace failed", "err", err.Error(), "namespace", ns)
}
}
// namespaceWireKey serialises a wire.TrackNamespace into a canonical
// byte string suitable for use as a map key. The same trick the
// discovery package uses; we re-implement here to keep the relay
// package free of an internal dependency in case the discovery
// package's helper ever becomes test-only.
func namespaceWireKey(ns wire.TrackNamespace) string {
w := wire.NewWriter(nil)
w.TrackNamespace(ns)
return string(w.Bytes())
}
// MatchPublishers returns every publisher entry whose advertised namespace
// is a prefix of (or equal to) ns. This implements the §9.5 rule the
// SUBSCRIBE handler uses:
//
// "the Relay MUST send a SUBSCRIBE request to each publisher that has
// published the subscription's namespace or prefix thereof."
//
// Example: a publisher that advertised PUBLISH_NAMESPACE ("video",) matches
// a SUBSCRIBE for ("video", "cam1"). A publisher that advertised
// ("video", "cam1") matches a SUBSCRIBE for ("video", "cam1") but NOT a
// SUBSCRIBE for ("video",) — the stored namespace must be a prefix of, or
// equal to, the queried namespace.
//
// The returned slice is a fresh allocation; callers may iterate it without
// holding the registry lock.
func (r *NamespaceRegistry) MatchPublishers(ns wire.TrackNamespace) []*PublisherEntry {
r.mu.RLock()
defer r.mu.RUnlock()
var out []*PublisherEntry
for _, e := range r.publishers {
if ns.HasPrefix(e.Namespace) {
out = append(out, e)
}
}
return out
}
// MatchSubscribers returns every subscriber entry whose stored prefix is a
// prefix of (or equal to) ns. This implements the §6.2 / §6.1 forwarding
// rules the PUBLISH_NAMESPACE and PUBLISH handlers use to find which
// downstream subscribers want to be notified of a newly-advertised
// namespace or a newly-published track.
//
// Example: a subscriber that sent SUBSCRIBE_NAMESPACE ("video",) matches a
// PUBLISH_NAMESPACE ("video", "cam1"). A subscriber that sent
// SUBSCRIBE_NAMESPACE ("video", "cam1") matches a PUBLISH_NAMESPACE
// ("video", "cam1") but not ("video",).
//
// Note that a SUBSCRIBE_NAMESPACE with zero fields (§6.1: "the sender is
// interested in all namespaces") matches every PUBLISH_NAMESPACE — that
// case falls out of isPrefixOf naturally.
//
// The returned slice is a fresh allocation; callers may iterate it without
// holding the registry lock.
func (r *NamespaceRegistry) MatchSubscribers(ns wire.TrackNamespace) []*SubscriberEntry {
r.mu.RLock()
defer r.mu.RUnlock()
var out []*SubscriberEntry
for _, e := range r.subscribers {
if ns.HasPrefix(e.Prefix) {
out = append(out, e)
}
}
return out
}
// CopyPublishers returns a snapshot of all publisher entries. Intended for
// tests, metrics, and the Stop path (where the registry is being drained
// and the caller wants to iterate without holding the lock across slow
// per-entry work).
func (r *NamespaceRegistry) CopyPublishers() []*PublisherEntry {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]*PublisherEntry, len(r.publishers))
copy(out, r.publishers)
return out
}
// CopySubscribers returns a snapshot of all subscriber entries. See
// [NamespaceRegistry.CopyPublishers].
func (r *NamespaceRegistry) CopySubscribers() []*SubscriberEntry {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]*SubscriberEntry, len(r.subscribers))
copy(out, r.subscribers)
return out
}
package registry
import (
"fmt"
"github.com/floatdrop/moq-go/pkg/moqt/message"
)
// decodedProperties holds the Track Properties the relay acts on, as opposed
// to the raw Properties block it forwards opaquely downstream per §9.6. The
// values are decoded once when an entry's Properties are set (see
// [TrackEntry.setPropertiesLocked]) so the §10.2.19 / §12 hot paths read a
// cached field instead of re-walking the block.
//
// To cache another property: add a field here, a branch in
// [decodeTrackProperties], and an accessor on [TrackEntry]. The raw block is
// still parsed only once, so a new property costs a branch, not a second pass
// over the bytes.
type decodedProperties struct {
// parseErr is a structural failure parsing the raw block (a malformed
// upstream Properties field). It is nil for a well-formed block. When
// set, no field below is meaningful, so every accessor reports it.
parseErr error
// dynamicGroups is DYNAMIC_GROUPS=1 (§12.6). dynamicGroupsErr is a §12.6
// PROTOCOL_VIOLATION (a DYNAMIC_GROUPS value > 1) by the upstream
// publisher.
dynamicGroups bool
dynamicGroupsErr error
// deliveryTimeouts is the publisher's Track-level OBJECT_DELIVERY_TIMEOUT
// (§12.2) and SUBGROUP_DELIVERY_TIMEOUT (§12.1) pair. Per §8 a zero value
// in either dimension means "no timeout", which is also what an absent
// property decodes to — so the zero DeliveryTimeouts is the correct
// reading of a track that declares neither.
deliveryTimeouts message.DeliveryTimeouts
}
// decodeTrackProperties parses the raw Track Properties block once and pulls
// out the fields the relay acts on. A structural parse failure short-circuits
// to a parseErr that every accessor surfaces; per-property value violations
// (e.g. §12.6) are recorded on the matching field's error.
func decodeTrackProperties(raw []byte) decodedProperties {
pairs, err := message.ParseTrackProperties(raw)
if err != nil {
return decodedProperties{parseErr: err}
}
var d decodedProperties
for _, kv := range pairs {
// Dispatch each property the relay acts on to its decoder. Add a
// branch here for each new property.
switch kv.Type {
case message.PropertyDynamicGroups:
d.dynamicGroups, d.dynamicGroupsErr = decodeDynamicGroups(kv.IntVal)
case message.PropertyObjectDeliveryTimeout:
d.deliveryTimeouts.Object = message.MillisecondTimeout(kv.IntVal)
case message.PropertySubgroupDeliveryTimeout:
d.deliveryTimeouts.Subgroup = message.MillisecondTimeout(kv.IntVal)
}
}
return d
}
// decodeDynamicGroups interprets a DYNAMIC_GROUPS value (§12.6): 0 is false,
// 1 is true, and anything greater is a PROTOCOL_VIOLATION so the caller can
// decline to act on it.
func decodeDynamicGroups(v uint64) (bool, error) {
switch v {
case 0:
return false, nil
case 1:
return true, nil
default:
return false, fmt.Errorf(
"relay: DYNAMIC_GROUPS value %d > 1 (§12.6 PROTOCOL_VIOLATION)", v)
}
}
// DynamicGroups reports whether the track advertised DYNAMIC_GROUPS=1 (§12.6),
// using the value decoded once when Properties was set. The error is a §12.6
// PROTOCOL_VIOLATION (a DYNAMIC_GROUPS value > 1), or a structural failure
// parsing the Properties block; either way the §10.2.19 caller declines the
// NEW_GROUP_REQUEST rather than acting on it.
func (e *TrackEntry) DynamicGroups() (bool, error) {
e.mu.RLock()
defer e.mu.RUnlock()
if e.decoded.parseErr != nil {
return false, e.decoded.parseErr
}
return e.decoded.dynamicGroups, e.decoded.dynamicGroupsErr
}
// DeliveryTimeouts returns the publisher's Track-level delivery timeouts (§8),
// using the values decoded once when Properties was set. The fanout resolves
// these against each subscriber's own §10.2.3 / §10.2.4 parameters before
// applying them to the subgroup streams it opens.
//
// A malformed Properties block reports the zero pair — "no timeout" — rather
// than an error: unlike §12.6, where acting on a bad value would mean honouring
// a NEW_GROUP_REQUEST the publisher never authorised, the safe reading of an
// undecodable timeout is not to enforce one.
func (e *TrackEntry) DeliveryTimeouts() message.DeliveryTimeouts {
e.mu.RLock()
defer e.mu.RUnlock()
if e.decoded.parseErr != nil {
return message.DeliveryTimeouts{}
}
return e.decoded.deliveryTimeouts
}
package registry
import (
"context"
"errors"
"fmt"
"sync"
"time"
"github.com/floatdrop/moq-go/pkg/moqt"
"github.com/floatdrop/moq-go/pkg/moqt/message"
"github.com/floatdrop/moq-go/pkg/moqt/session"
)
// SubState is the lifecycle phase of an upstream or downstream subscription
// as managed by the relay. The relay only ever observes two phases, so the
// model is deliberately just those two:
//
// - SubEstablished: peer accepted; objects may flow.
// - SubTerminated: closed cleanly or by error; no further transitions.
//
// The relay constructs an UpstreamSub / DownstreamSub only once the peer has
// already accepted (it sends SUBSCRIBE_OK for a downstream sub; an upstream
// sub is built from the SUBSCRIBE_OK it received), so there is no observable
// "constructed but not yet established" phase to model — subs are born
// Established and the only transition is the one-way move to Terminated (see
// [Subscription.Terminate]).
//
// The state intentionally does NOT track per-object forwarding decisions.
// Those are fanout concerns expressed via the [message.LocationFilter]
// / Forward-state fields on the concrete [UpstreamSub] / [DownstreamSub]
// structs.
type SubState int
const (
// SubTerminated is the absorbing state. Either the peer ended the
// subscription (UNSUBSCRIBE / SUBSCRIBE_DONE / PUBLISH_DONE /
// SUBSCRIBE_ERROR / PUBLISH_ERROR), the underlying request stream
// died, or the relay tore the subscription down (auth failure,
// session close, Stop). Once here, the registry slot can be removed
// safely by the owning goroutine. It is the zero value so a
// bare-struct subscription is never mistaken for live; the
// constructors set SubEstablished explicitly.
SubTerminated SubState = iota
// SubEstablished means the subscription is live: objects can be
// forwarded and REQUEST_UPDATE / UNSUBSCRIBE can be sent.
SubEstablished
)
// String returns "Established" or "Terminated".
func (s SubState) String() string {
switch s {
case SubEstablished:
return "Established"
case SubTerminated:
return "Terminated"
default:
return fmt.Sprintf("SubState(%d)", int(s))
}
}
// Subscription is the embedded common state for [UpstreamSub] and
// [DownstreamSub]. It centralises the mutex, the state field, and the
// terminate latch so the two concrete types only have to add their
// direction-specific fields.
//
// Locking discipline:
//
// - State, ForwardState, and Filter are guarded by mu.
// - The Session and Stream references are set once at construction and
// are read-only thereafter; they are not protected.
// - Callers that read multiple fields together (e.g. State + Filter
// during fanout) should hold the lock themselves rather than reading
// fields individually.
type Subscription struct {
mu sync.RWMutex
// state is the current lifecycle phase. Set to SubEstablished by the
// constructors and moved one-way to SubTerminated via Terminate.
state SubState
// ID is unique within the relay process. It serves as the stable
// removal handle in the Track Registry; see [TrackRegistry.RemoveUpstream].
// Set once at construction; read-only.
ID uint64
// RequestID is the MOQT Request ID (§10.1) of the SUBSCRIBE / PUBLISH
// that opened this subscription's request stream, kept for identity
// and diagnostics. (A REQUEST_UPDATE rides the same stream but consumes
// a fresh ID from the sender's space, §10.1 — the stream, not the ID,
// names the request being updated.) Set once at construction; read-only.
RequestID uint64
// Session is the MOQT session that owns this subscription's request
// stream. Read-only after construction.
Session *session.Session
// Stream is the bidi request stream the SUBSCRIBE / PUBLISH was
// issued on. The owning goroutine (the session handler's request
// loop) is the sole writer; the relay reads from it to observe
// peer-side updates (REQUEST_UPDATE, UNSUBSCRIBE, PUBLISH_DONE,
// etc.). Read-only after construction; the goroutine that owns the
// stream closes it.
Stream session.Stream
// TrackAlias is the alias the relay assigned to this subscription on
// its side of the wire (§11.1). For UpstreamSub it is the alias the
// publisher uses when sending objects to us; for DownstreamSub it is
// the alias we use when sending objects to the subscriber. Aliases
// are per-direction, per-session — the fanout remaps between them.
// Set once at construction; read-only.
TrackAlias uint64
// forwardState is the §9.2 Forward flag the peer most recently
// requested. 1 = deliver objects, 0 = pause delivery. The session
// handler updates it on REQUEST_UPDATE and the fanout consults it to
// decide whether to write objects out.
forwardState int
}
// Terminate moves the subscription to [SubTerminated], returning true on the
// first call and false on every subsequent call. The one-shot latch lets a
// caller run teardown that must happen exactly once (e.g. emitting a single
// PUBLISH_DONE) without coordinating with other goroutines; it is safe to
// call concurrently from any goroutine.
func (s *Subscription) Terminate() bool {
s.mu.Lock()
defer s.mu.Unlock()
if s.state == SubTerminated {
return false
}
s.state = SubTerminated
return true
}
// State returns the current lifecycle phase.
func (s *Subscription) State() SubState {
s.mu.RLock()
defer s.mu.RUnlock()
return s.state
}
// IsEstablished reports whether the subscription is in [SubEstablished].
// Convenience wrapper for fanout / handler code that only cares whether
// objects may flow.
func (s *Subscription) IsEstablished() bool {
return s.State() == SubEstablished
}
// IsTerminated reports whether the subscription is in [SubTerminated].
// Convenience wrapper for cleanup paths.
func (s *Subscription) IsTerminated() bool {
return s.State() == SubTerminated
}
// SetForwardState updates the §9.2 Forward flag. The relay does not validate
// the value here — §10.7's Forward field is canonically 0 or 1, but allowing
// any int keeps the door open for future extensions (e.g. priority-banded
// forwarding) without an API change.
func (s *Subscription) SetForwardState(v int) {
s.mu.Lock()
s.forwardState = v
s.mu.Unlock()
}
// ForwardState returns the most recently set §9.2 Forward flag.
func (s *Subscription) ForwardState() int {
s.mu.RLock()
defer s.mu.RUnlock()
return s.forwardState
}
// ---------------------------------------------------------------------------
// UpstreamSub / DownstreamSub
// ---------------------------------------------------------------------------
// UpstreamSub represents one subscription the relay holds against a
// publisher: the relay issued a SUBSCRIBE upstream after either a local
// downstream SUBSCRIBE or an explicit PUBLISH / PUBLISH_NAMESPACE from a
// publishing peer.
//
// The Filter is the upstream-side §5.1.2 filter the relay chose for this
// subscription. Per §9.4 the relay typically subscribes upstream with the
// "Largest Object" filter so disparate downstream filters don't churn the
// upstream subscription.
//
// Embedding [Subscription] gives UpstreamSub its state machine, ID, Session,
// Stream, TrackAlias, and ForwardState fields for free.
type UpstreamSub struct {
Subscription
// Filter is the §5.1.2 filter the relay used in its upstream
// SUBSCRIBE. nil means "filter unset" (i.e. the subscription has not
// been sent yet); once set, the value is owned by the subscription
// and must not be mutated externally.
Filter *message.LocationFilter
// FetchCapable marks an upstream the relay reached via an on-demand
// SUBSCRIBE (a relay/origin, set in subscribeUpstream) — one expected to
// answer FETCH, so the FETCH responder may stitch evicted ranges from it.
// It stays false for a directly-connected leaf publisher, which pushes
// live objects and does not serve FETCH.
FetchCapable bool
// OnDemand marks an upstream subscription the relay itself opened via
// SUBSCRIBE to serve downstream subscribers (§9.4 aggregation). Such a
// subscription exists only for its downstreams: when the last one
// leaves, the registry tears it down ([UpstreamSub.CloseOnDemand]) so
// the publisher stops streaming into a void. It stays false for
// PUBLISH-fed upstreams, whose stream is owned by the publisher.
OnDemand bool
// Broker owns the request stream's read side (via
// [session.RequestBroker.Serve], run by the relay's per-upstream reader
// goroutine) and serializes every relay write on the stream — §10.9
// REQUEST_UPDATEs via [UpstreamSub.Update] and other control messages
// via [UpstreamSub.WriteMessage] must not interleave. nil only for
// literal-constructed test fixtures; [NewUpstreamSub] always builds one.
Broker *session.RequestBroker
}
// updateResponseTimeout bounds the wait for the §10.9 REQUEST_OK /
// REQUEST_ERROR after Update writes a REQUEST_UPDATE. A conforming peer
// always answers; the bound keeps a peer that never does from wedging the
// dispatch loop the Update call runs on.
const updateResponseTimeout = 5 * time.Second
// Update sends a REQUEST_UPDATE (§10.9) on the upstream request stream and
// awaits the single REQUEST_OK / REQUEST_ERROR the spec mandates, bounded
// by [updateResponseTimeout] (tightened further by any earlier deadline on
// ctx). It delegates to the sub's [session.RequestBroker]; the response is
// delivered by the relay's per-upstream Serve loop. A REQUEST_ERROR is
// surfaced as a [session.RequestRejectedError]; a closed stream as
// [session.ErrRequestStreamClosed].
func (u *UpstreamSub) Update(ctx context.Context, params message.Parameters) (*message.RequestOK, error) {
if u.Broker == nil {
return nil, session.ErrRequestStreamClosed
}
ctx, cancel := context.WithTimeout(ctx, updateResponseTimeout)
defer cancel()
return u.Broker.Update(ctx, params)
}
// WriteMessage marshals a control message onto the upstream request stream
// under the broker's write lock — the same lock that serializes Update's
// REQUEST_UPDATE writes. session.Stream does not serialize concurrent
// writers, so every relay write on this stream after the request is
// accepted must go through here or Update.
func (u *UpstreamSub) WriteMessage(msg message.Message) error {
if u.Broker == nil {
return session.ErrRequestStreamClosed
}
return u.Broker.WriteMessage(msg)
}
// CloseOnDemand tears down an on-demand upstream subscription after its
// last downstream left: pending updates fail fast, the read side is reset,
// and the send side is FIN'd — closing the request stream is how a
// subscriber ends a subscription (§10.7). The broker's Serve loop observes
// the reset and exits, and the publisher stops streaming into a void.
// Idempotent; must be called without registry locks held (stream I/O).
func (u *UpstreamSub) CloseOnDemand() {
u.Terminate()
if u.Broker == nil {
return
}
u.Broker.Close(moqt.StreamResetCancelled)
}
// NewUpstreamSub constructs an UpstreamSub in [SubEstablished] with the given
// identity fields. The relay only builds an UpstreamSub once the upstream
// SUBSCRIBE_OK has arrived (the TrackAlias comes from it), so the
// subscription is live from construction.
//
// requestID is the §10.1 Request ID of the SUBSCRIBE / PUBLISH that opened
// the request stream, recorded for identity and diagnostics.
//
// The Forward State starts at 1: per §10.7 a SUBSCRIBE (or accepted PUBLISH)
// that omits the FORWARD parameter implies Forward State 1, and the relay's
// upstream requests never carry FORWARD. Starting at 0 would make the §9.2
// propagation path emit a spurious REQUEST_UPDATE(Forward=1) on the first
// downstream resume.
func NewUpstreamSub(
id uint64,
sess *session.Session,
stream session.Stream,
trackAlias, requestID uint64,
) *UpstreamSub {
return &UpstreamSub{
state: SubEstablished,
ID: id,
RequestID: requestID,
Session: sess,
Stream: stream,
TrackAlias: trackAlias,
forwardState: 1,
Broker: sess.NewRequestBroker(stream),
}
}
// SetFilter installs the upstream filter. Callers must not mutate the filter
// after handing it over.
func (u *UpstreamSub) SetFilter(f *message.LocationFilter) {
u.mu.Lock()
u.Filter = f
u.mu.Unlock()
}
// GetFilter returns the currently installed filter (or nil).
func (u *UpstreamSub) GetFilter() *message.LocationFilter {
u.mu.RLock()
defer u.mu.RUnlock()
return u.Filter
}
// DownstreamSub represents one subscription the relay holds for a
// subscriber: the relay accepted a SUBSCRIBE from the peer and is now
// responsible for forwarding objects, applying the §5.1.2 filter, honouring
// priority (§7) and group order (§5.2), and respecting the Forward flag
// (§9.2).
type DownstreamSub struct {
Subscription
// writeMu serializes control-message writes on Stream.
// session.Stream does not serialize concurrent writers and one
// Marshal is multiple stream Writes, but two goroutines legitimately
// write here: the subscriber's request handler (SUBSCRIBE_OK,
// REQUEST_OK / REQUEST_ERROR replies — via WriteMessage) and registry
// teardown goroutines (PUBLISH_DONE via TerminateWithPublishDone,
// triggered by a *publisher* leaving). Same rationale as
// SubscriberEntry's writeMu.
writeMu sync.Mutex
// okSent records that the §10.7 SUBSCRIBE_OK response went out on the
// stream (guarded by writeMu). A termination racing the subscribe
// handler consults it to answer the request correctly: the peer must
// receive exactly one SUBSCRIBE_OK / REQUEST_ERROR before any
// PUBLISH_DONE — a PUBLISH_DONE with no prior response leaves the
// request permanently unanswered on the subscriber side.
okSent bool
// Filter is the §5.1.2 filter the subscriber declared. The fanout
// consults it on every object to decide whether to forward. nil
// means "no filter installed" — the relay treats the subscription
// as unfiltered (delivers every object on the track).
Filter *message.LocationFilter
// rangeFilters holds the §5.1.4 Range Filters the subscriber declared
// (Subgroup ID / Object ID / Publisher Priority / Object Property). The
// fanout ANDs them with Filter per object. nil = no range restriction.
// Guarded by mu; access via SetRangeFilters / GetRangeFilters.
rangeFilters *message.RangeFilterSet
// deliveryTimeouts holds the §10.2.3 / §10.2.4 values the subscriber asked
// for. The fanout resolves them against the publisher's Track-level pair
// (§8: the smaller of the two non-zero values) once per subgroup stream it
// opens. Guarded by mu; access via SetDeliveryTimeouts / GetDeliveryTimeouts.
deliveryTimeouts message.DeliveryTimeouts
// LargestAtSubscribe is the largest object the relay had observed on
// this track at the moment the SUBSCRIBE was accepted, per §5.1.2 /
// §9.4 ("a relay handling a SUBSCRIBE acts as the publisher").
// The Next Object and relative-start filters resolve their start
// location against this snapshot — not against the live, ever-advancing
// TrackEntry watermark — so the subscription's start is fixed at
// subscribe time and doesn't drift as new objects arrive.
LargestAtSubscribe message.Location
// HasLargestAtSubscribe is false when no objects had been delivered
// on the track at SUBSCRIBE time. Per §5.1.2, the Next Object and
// relative-start filters fall back to {0,0} in that case.
HasLargestAtSubscribe bool
// Priority is the §7 Subscriber Priority the peer asked for. Lower
// numeric values mean higher delivery priority. Folded into the §7.2
// stream-scheduling key by [DownstreamSub.EffectiveStreamPriority].
// Default per §7 / §10.2.7 is 128 (mid-range), set in NewDownstreamSub.
Priority uint8
// GroupOrder is the §5.2 Group Order preference. Encoded per §10.2.8:
// 0x1 = ascending, 0x2 = descending. It drives the group-order
// tie-breaker in both reorder-capable paths (FETCH responses) and the
// §7.2 rule-3 GroupKey of the subgroup-stream scheduling priority.
// Default per §7.1: the publisher's preference, which the relay does not
// currently track, so an unset GroupOrder is left at zero and treated as
// Ascending.
GroupOrder uint8
}
// NewDownstreamSub constructs a DownstreamSub in [SubEstablished]: the relay
// accepts the subscriber's SUBSCRIBE (replying SUBSCRIBE_OK) before building
// the sub, so it is live from construction.
//
// Forward State defaults to 1: §10.7 specifies that when the FORWARD
// parameter is omitted from SUBSCRIBE the subscription forwards objects.
// installSubscribeParams overrides this to 0 only when the peer explicitly
// sends FORWARD=0, and REQUEST_UPDATE can flip it later (§9.2 / §10.9).
func NewDownstreamSub(id uint64, sess *session.Session, stream session.Stream, trackAlias uint64) *DownstreamSub {
return &DownstreamSub{
state: SubEstablished,
ID: id,
Session: sess,
Stream: stream,
TrackAlias: trackAlias,
forwardState: 1,
// §10.2.7: SUBSCRIBER_PRIORITY defaults to 128 (mid-range) when
// the peer omits the parameter. installSubscribeParams overrides
// this only when the SUBSCRIBE / REQUEST_UPDATE carries an explicit
// value (including an explicit 0, the highest priority).
Priority: 128,
}
}
// SetFilter installs the downstream filter. Callers must not mutate the
// filter after handing it over.
func (d *DownstreamSub) SetFilter(f *message.LocationFilter) {
d.mu.Lock()
d.Filter = f
d.mu.Unlock()
}
// GetFilter returns the currently installed filter (or nil).
func (d *DownstreamSub) GetFilter() *message.LocationFilter {
d.mu.RLock()
defer d.mu.RUnlock()
return d.Filter
}
// SetDeliveryTimeouts records the §10.2.3 / §10.2.4 delivery timeouts the
// subscriber asked for. A zero dimension means "no timeout" per §8.
func (d *DownstreamSub) SetDeliveryTimeouts(t message.DeliveryTimeouts) {
d.mu.Lock()
d.deliveryTimeouts = t
d.mu.Unlock()
}
// GetDeliveryTimeouts returns the delivery timeouts the subscriber asked for.
// The zero value means the subscriber requested none, which leaves the
// publisher's Track-level values to stand on their own (§8).
func (d *DownstreamSub) GetDeliveryTimeouts() message.DeliveryTimeouts {
d.mu.RLock()
defer d.mu.RUnlock()
return d.deliveryTimeouts
}
// SetRangeFilters installs the subscription's Range Filters (§5.1.4), which the
// fanout ANDs with the Location filter and Forward gate per object (read
// directly under mu by ForwardDecision). nil clears them (no range restriction).
func (d *DownstreamSub) SetRangeFilters(f *message.RangeFilterSet) {
d.mu.Lock()
d.rangeFilters = f
d.mu.Unlock()
}
// SetPriority records the §7 Subscriber Priority. Updated when the peer
// sends a REQUEST_UPDATE.
func (d *DownstreamSub) SetPriority(p uint8) {
d.mu.Lock()
d.Priority = p
d.mu.Unlock()
}
// SetGroupOrder records the §5.2 Group Order. Updated when the peer sends a
// REQUEST_UPDATE.
func (d *DownstreamSub) SetGroupOrder(o uint8) {
d.mu.Lock()
d.GroupOrder = o
d.mu.Unlock()
}
// SetLargestAtSubscribe records the largest-object snapshot captured when
// the subscription was accepted. The fanout feeds this into the §5.1.2
// filter evaluator so LargestObject / NextGroupStart filters resolve
// against a stable subscribe-time anchor rather than the live watermark.
func (d *DownstreamSub) SetLargestAtSubscribe(loc message.Location, hasLargest bool) {
d.mu.Lock()
d.LargestAtSubscribe = loc
d.HasLargestAtSubscribe = hasLargest
d.mu.Unlock()
}
// EffectiveStreamPriority builds the composite §7.2 scheduling key for one
// subgroup stream of this subscription, which the relay pushes down to the
// transport via [session.PrioritizedSendStream.SetSendPriority].
//
// All four §7.2 rules are encoded in the returned [session.StreamPriority],
// compared lexicographically (lower is higher priority):
//
// 1. Subscriber: this subscription's SUBSCRIBER_PRIORITY (default 128).
// 2. Publisher: publisherPriority — the byte the subgroup carries
// (SubgroupHeader.PublisherPriority). The caller passes it because the
// relay does not cache the per-track default outside the inbound header.
// 3. GroupKey: groupID with this subscription's GROUP_ORDER applied —
// bitwise-complemented for Descending so a "lower is higher priority"
// comparison sends higher Group IDs first.
// 4. Subgroup: subgroupID — lowest Subgroup ID in a group goes first.
//
// Rules 3+4 only define an ordering between streams of the same request, but
// the transport sees streams from every subscription; §7.2 leaves the
// cross-subscription tie-break implementation-defined, so feeding it the full
// key is conformant and degrades gracefully when the transport projects the
// key onto a coarser knob.
func (d *DownstreamSub) EffectiveStreamPriority(
publisherPriority uint8,
groupID, subgroupID uint64,
) session.StreamPriority {
d.mu.RLock()
sub := d.Priority
order := message.GroupOrder(d.GroupOrder)
d.mu.RUnlock()
// §7.2 rule 3: Descending order means higher Group IDs are scheduled
// first. Complementing the Group ID flips the numeric comparison so the
// same "lower GroupKey is higher priority" rule yields that direction.
// An unset GROUP_ORDER (zero value) defaults to Ascending — §7.1 says
// the publisher's preference applies, which the relay does not track.
groupKey := groupID
if order == message.GroupOrderDescending {
groupKey = ^groupID
}
return session.StreamPriority{
Subscriber: sub,
Publisher: publisherPriority,
GroupKey: groupKey,
Subgroup: subgroupID,
}
}
// ForwardDecision folds the §9.2 Forward-State gate and the §5.1.2 filter
// test the fanout applies to every object into a single lock acquisition.
// The per-object × per-subscriber loop would otherwise take three RLock
// round-trips on the same mutex per object per subscriber.
//
// The §5.1.2 filter is evaluated against the subscribe-time LargestObject
// snapshot, *not* the live TrackEntry watermark. Re-evaluating against the
// live watermark would let a subscription's effective start location drift
// forward as objects arrive, silently dropping the very objects the
// subscriber asked to receive.
//
// forward is true when the object should be enqueued. It ANDs the §9.2 Forward
// State, the §5.1.2 Location filter, and the §5.1.4 Range Filters
// (subgroupID/object/priority/objProps) — §5.1.5 "Pass = Forward AND Location
// AND Range". When forward is false, groupExhausted reports whether the
// Location filter has narrowed so this whole group is permanently out of range
// (§11.4.3), so the caller can reset the stream promptly. A Range-filter miss
// drops only the object (a later object in the group may still match), so it
// never reports groupExhausted; a paused subscription never does either.
func (d *DownstreamSub) ForwardDecision(
group, object, subgroupID uint64, priority uint8, objProps []byte,
) (forward, groupExhausted bool) {
d.mu.RLock()
paused := d.forwardState == 0
f := d.Filter
rf := d.rangeFilters
largest := d.LargestAtSubscribe
has := d.HasLargestAtSubscribe
d.mu.RUnlock()
if paused {
return false, false
}
// Location filter first, so its group-exhaustion signal (§11.4.3) governs.
if f != nil && !f.Matches(message.Location{Group: group, Object: object}, largest, has) {
return false, GroupOutOfRange(group, f)
}
// Range Filters (§5.1.4): per-object AND; a miss drops the object only.
if rf != nil && !rf.MatchesObject(subgroupID, object, priority, objProps) {
return false, false
}
return true, false
}
// GroupOutOfRange reports whether a Subgroup belonging to group is entirely
// outside the subscription's filter range — i.e. no object in that group can
// ever pass — which makes its in-flight stream eligible for a §11.4.3 reset
// (e.g. after a REQUEST_UPDATE narrowed the End Group or raised the Start
// Location to a higher group). Only the absolute filters carry a fixed range;
// the dynamic (Next Object / relative-start) and unset filters never put a
// whole group permanently out of range, so they return false.
//
// A group equal to the Start Location's group is NOT out of range even when
// the Start Location's Object rose — objects at or above it still pass, so the
// stream stays relevant and object-level filtering handles the boundary.
func GroupOutOfRange(group uint64, f *message.LocationFilter) bool {
if f == nil {
return false
}
if f.Unfiltered() || f.RelativeStart() || f.NextObject() {
// Start derived from the largest object (or absent entirely); no fixed
// range that puts a whole group permanently out of range.
return false
}
if group < f.StartGroup {
return true
}
end, ok := f.End()
return ok && group > end.Group
}
// TerminateWithPublishDone gracefully ends this downstream subscription
// per §10.12: the relay writes a PUBLISH_DONE message on the
// subscriber's request stream and FINs the send side. The subscriber
// eventually FINs its side too, the handler's readSubscribeUpdates
// loop sees EOF and exits, and its defer evicts the [DownstreamSub]
// from the [TrackRegistry].
//
// If the SUBSCRIBE_OK never went out — the sub is registered (and thus
// reachable by teardown) before the handler replies, so a terminator can
// win that race — a PUBLISH_DONE would leave the SUBSCRIBE without the
// single SUBSCRIBE_OK / REQUEST_ERROR response §10.7 requires. In that
// case the termination answers the request with REQUEST_ERROR
// (DOES_NOT_EXIST: the track's source vanished before the subscription
// was established) instead, and [DownstreamSub.WriteSubscribeOK] refuses
// to send the stale OK afterwards.
//
// The Terminate latch prevents double-termination: the first caller
// flips the state and writes the message; subsequent calls return
// without I/O. Safe to call concurrently from any goroutine.
//
// streamCount is the §10.12 "Stream Count" field — the number of
// subgroup streams the relay opened for this subscription. Pass 0
// when the exact count isn't tracked; subscribers treat 0 as
// approximate per the spec.
//
// Used by [TrackRegistry] when the last upstream feeding a track
// disappears, so dependent subscribers stop waiting silently.
func (d *DownstreamSub) TerminateWithPublishDone(code moqt.PublishDoneCode, reason string, streamCount uint64) {
if !d.Terminate() {
return // already terminated
}
if d.Stream == nil {
return
}
d.writeMu.Lock()
defer d.writeMu.Unlock()
if !d.okSent {
_ = message.Marshal(d.Stream, &message.RequestError{
ErrorCode: moqt.RequestDoesNotExist,
ErrorReason: reason,
})
// Mirror [session.Request.RejectError]: the losing subscribe
// handler returns without ever entering its follow-up read loop,
// so cancel the read side too — otherwise bytes the peer sends
// before seeing the rejection queue in the transport forever.
d.Stream.CancelRead(uint64(moqt.StreamResetInternalError))
} else {
_ = message.Marshal(d.Stream, &message.PublishDone{
StatusCode: code,
StreamCount: streamCount,
ErrorReason: reason,
})
}
_ = d.Stream.Close()
}
// WriteSubscribeOK writes the §10.7 SUBSCRIBE_OK response under the write
// lock and records that the request now has its response, so a later
// termination emits PUBLISH_DONE (§10.12) rather than a second response.
// If a termination won the race first, it returns
// [ErrSubscriptionTerminated] without writing — the terminator already
// answered the request with REQUEST_ERROR.
func (d *DownstreamSub) WriteSubscribeOK(msg *message.SubscribeOK) error {
d.writeMu.Lock()
defer d.writeMu.Unlock()
if d.IsTerminated() {
return ErrSubscriptionTerminated
}
if err := message.Marshal(d.Stream, msg); err != nil {
return err
}
d.okSent = true
return nil
}
// WriteMessage marshals a control message onto the downstream request stream
// under the same lock TerminateWithPublishDone uses. Every relay write on
// this stream after the DownstreamSub is registered must go through here —
// registration makes the sub reachable by registry teardown goroutines, so
// even the SUBSCRIBE_OK reply can otherwise interleave with a PUBLISH_DONE.
//
// A write after termination fails with ErrSubscriptionTerminated on every
// transport: PUBLISH_DONE + FIN already went out under this same lock, so
// the message could only land after the FIN (real QUIC rejects that; the
// in-process test transport would silently deliver it).
func (d *DownstreamSub) WriteMessage(msg message.Message) error {
d.writeMu.Lock()
defer d.writeMu.Unlock()
if d.IsTerminated() {
return ErrSubscriptionTerminated
}
return message.Marshal(d.Stream, msg)
}
// ErrSubscriptionTerminated is returned by [DownstreamSub.WriteMessage] when
// the subscription was already ended with PUBLISH_DONE (its stream is FIN'd).
var ErrSubscriptionTerminated = errors.New("registry: subscription terminated")
package registry
import (
"sync"
"github.com/floatdrop/moq-go/pkg/moqt/message"
"github.com/floatdrop/moq-go/pkg/moqt/track"
"github.com/floatdrop/moq-go/pkg/relay/cache"
)
// TrackEntry is the central per-track control block (§9 of
// draft-ietf-moq-transport-20). One entry exists for every track the relay
// currently knows about — created on the first SUBSCRIBE or
// PUBLISH/PUBLISH_NAMESPACE for that track, destroyed when the last upstream
// and the last downstream subscription have both gone.
//
// The Upstream slice is intentionally a list (not a single value) so the
// relay can represent the three cases §9.3 / §9.5.1 explicitly allow:
//
// - multiple independent publishers claiming the same Full Track Name,
// - graceful publisher relay switchover where a publisher holds two
// overlapping sessions while migrating WiFi → cellular,
// - redundant origins (N-redundant encoders) used for live-media
// reliability — the relay deduplicates objects by {GroupID, ObjectID}
// (§2.1) via [TrackEntry.ClaimDelivered] so each object is forwarded
// downstream exactly once.
//
// Concurrency:
//
// - TrackEntry.mu is held in read mode for the fanout hot path (every
// incoming object reads Downstream to dispatch), and in write mode for
// the rare mutations that add or remove subscriptions or update the
// largest-object watermark.
// - The registry-level lock ([TrackRegistry.mu]) protects only the
// track map; per-entry state lives behind TrackEntry.mu so fanouts on
// different tracks can run fully in parallel.
type TrackEntry struct {
mu sync.RWMutex
// Key is the canonical map identity for this track (§2.4.1).
Key track.Key
// FullName retains the unhashed {namespace, name} tuple because some
// outgoing messages (PUBLISH, SUBSCRIBE_OK, TRACK_STATUS_OK, FETCH_OK)
// must echo it back verbatim — the Key alone cannot reproduce it.
FullName track.FullTrackName
// Properties are the raw Track Properties the relay learned from the
// upstream publisher (in SUBSCRIBE_OK / PUBLISH / TRACK_STATUS_OK /
// FETCH_OK). §9.6 requires the relay to forward them on every reply
// it generates downstream, so they are captured once and replayed.
// The bytes are the on-the-wire encoding of the Track Properties
// block; the relay treats them opaquely.
Properties []byte
// decoded holds the Track Properties the relay acts on, extracted once
// from the raw Properties block (which is otherwise forwarded opaquely
// per §9.6). Properties are immutable for the entry's lifetime (§9.6,
// first-setter-wins), so decoding happens once when Properties is set —
// see [decodeTrackProperties] for how to add a field. Set together with
// Properties by setPropertiesLocked.
decoded decodedProperties
// LargestObject is the (Group, Object) high-water mark observed for
// this track, updated by the fanout path on every incoming object and
// by upstream control messages that carry a LARGEST_OBJECT value. §10.2.17
// requires the relay to advertise the *maximum* of these in any
// outbound message that includes LARGEST_OBJECT.
//
// The companion HasLargestObject flag distinguishes "no objects
// observed yet" from "the first object was published at Location
// {0, 0}" — §10.2.17 reserves wire-level omission for the former
// and the in-memory mirror needs the same distinction. Callers
// SHOULD read via [TrackEntry.GetLargest] rather than touching
// these fields directly so the lock is honoured.
LargestObject message.Location
HasLargestObject bool
// Upstream is the set of publisher subscriptions feeding this track.
// See the type-level comment above for why this is a slice.
Upstream []*UpstreamSub
// Downstream is the set of subscriber subscriptions to fan out to.
Downstream []*DownstreamSub
// 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
// object whose generation is unchanged — joiners are rare, so the common
// case becomes a watermark bump with no scan. Bumped only on append
// (removals introduce no joiner to detect). Guarded by mu.
downstreamGen uint64
// Cache is the per-track [cache.ObjectCache] the fanout writes every
// forwarded object into. It is constructed eagerly when the entry is
// created (via [TrackRegistry.getOrCreateLocked]) so the fanout
// never has to nil-check.
Cache *cache.ObjectCache
// newGroupOutstanding records whether the relay has a NEW_GROUP_REQUEST
// (§10.2.19) in flight upstream for this track. It stays outstanding until
// the Largest Group advances past newGroupReqGroup, at which point the
// publisher is deemed to have honoured the request. Guarded by mu; see
// [TrackEntry.ConsiderNewGroupRequest].
newGroupOutstanding bool
newGroupReqValue uint64 // the value last forwarded upstream
newGroupReqGroup uint64 // Largest Group at the moment we forwarded
// sgMu guards subgroups. It is a separate, finer-grained lock than mu so
// the per-(group, subgroup) fan-out bookkeeping (Acquire/Release on every
// inbound subgroup stream) does not contend with the mu-guarded control
// mutations or the per-object UpdateLargestAndDetectNew hot path.
sgMu sync.Mutex
// deliveredMu guards the §2.1 dedup ledger below. It is separate from mu so
// the per-object dedup claim on the fanout hot path does not contend with the
// mu-guarded control mutations.
deliveredMu sync.Mutex
// delivered is the dedup ledger across multiple upstream publishers (§9.5):
// GroupID → set of Object IDs already forwarded downstream. The first upstream
// to reach a {GroupID, ObjectID} forwards it; later copies from redundant or
// lagging peers are dropped (§2.1 — SubgroupID is not part of object
// identity). It lives on the entry (not on a SharedSubgroup) so peers whose
// streams do not temporally overlap — e.g. one origin's subgroup FINs before
// the redundant origin's arrives — still dedup. Memory is bounded by
// [deliveredGroupWindow]: state for a group more than that many groups behind
// the largest seen group is pruned, and a stray object from such an aged-out
// group is treated as already-delivered (a peer lagging by that many groups
// is beyond any useful reorder window). deliveredMax/HasMax track the largest
// group seen, for the pruning window.
delivered map[uint64]map[uint64]struct{}
deliveredMax uint64
deliveredHasMax bool
// subgroups holds the shared outbound fan-out state for each
// (GroupID, SubgroupID) currently being produced by one or more upstreams.
// §9.5 lets N redundant upstreams feed one track; §2.2 requires that the
// objects of a single Subgroup go out on exactly ONE downstream stream per
// subscriber. Sharing this state across every inbound runFanout goroutine
// (each of which carries one (group, subgroup)) is what lets the relay merge
// the upstreams into one clean outbound subgroup stream per subscriber
// instead of one stream per (upstream × subscriber). Created lazily on the
// first contributor and removed when the last contributor leaves
// ([TrackEntry.AcquireSubgroup] / [TrackEntry.ReleaseSubgroup]). The payload
// is parent-managed and opaque here, keeping the fanout's writer type out of
// the registry layer (same one-way dependency rule as the seen predicate in
// [TrackEntry.UpdateLargestAndDetectNew]).
subgroups map[SubgroupKey]*SharedSubgroup
}
// SubgroupKey identifies a Subgroup within a track by its (GroupID, SubgroupID)
// pair (§2.2). It is the merge key for fanning multiple upstream publishers into
// a single downstream stream per subscriber.
type SubgroupKey struct {
Group uint64
Subgroup uint64
}
// SharedSubgroup is the per-(group, subgroup) fan-out state shared across every
// inbound runFanout goroutine producing that Subgroup for a track. The Set field
// is the parent package's writer set (opaque here); Mu guards the parent's
// manipulation of it. refs counts the live inbound contributors and is guarded
// by the owning entry's sgMu, not Mu.
type SharedSubgroup struct {
// Mu guards the parent-managed Set during writer open/close/deliver. Held
// across outbound stream I/O, so it is deliberately distinct from the
// entry's sgMu (which is only ever held for O(1) map/refcount edits).
Mu sync.Mutex
// Set is the parent package's writer set for this Subgroup
// (a *subgroupWriterSet in pkg/relay). Opaque to the registry.
Set any
refs int
}
// AcquireSubgroup registers the caller as a contributor to (group, subgroup) on
// this entry, creating the shared state via newSet on the first contributor.
// Returns the shared state and whether this call created it (so the creator can
// open the initial downstream writers; later contributors reuse the existing
// writer set). Every successful Acquire must be balanced by a
// [TrackEntry.ReleaseSubgroup].
func (e *TrackEntry) AcquireSubgroup(key SubgroupKey, newSet func() any) (sg *SharedSubgroup, created bool) {
e.sgMu.Lock()
defer e.sgMu.Unlock()
if e.subgroups == nil {
e.subgroups = make(map[SubgroupKey]*SharedSubgroup)
}
if sg, ok := e.subgroups[key]; ok {
sg.refs++
return sg, false
}
sg = &SharedSubgroup{Set: newSet(), refs: 1}
e.subgroups[key] = sg
return sg, true
}
// deliveredGroupWindow bounds the §2.1 dedup ledger ([TrackEntry.delivered]):
// dedup state is retained for the most recent deliveredGroupWindow groups. An
// object whose group is more than this many groups behind the largest group
// seen is assumed already delivered. The window must comfortably exceed any
// realistic inter-publisher group lag (a redundant origin or relay running a
// few groups behind) while keeping per-track dedup memory bounded.
const deliveredGroupWindow = 32
// ClaimDelivered is the §2.1 dedup gate across multiple upstream publishers. It
// records (group, object) as forwarded and reports whether the caller is the
// first to do so (true → forward it) or it was already forwarded by a peer
// upstream (false → drop it). The ledger persists on the entry (not on a
// per-Subgroup structure) and is independent of the size-bounded Object Cache,
// so redundant streams that do not temporally overlap, or peers lagging by more
// than the cache capacity, still dedup correctly. Memory is bounded to the most
// recent [deliveredGroupWindow] groups.
func (e *TrackEntry) ClaimDelivered(group, object uint64) bool {
e.deliveredMu.Lock()
defer e.deliveredMu.Unlock()
if e.delivered == nil {
e.delivered = make(map[uint64]map[uint64]struct{})
}
// Advance the window when a newer group appears, pruning groups that have
// fallen out of it.
if !e.deliveredHasMax || group > e.deliveredMax {
e.deliveredMax = group
e.deliveredHasMax = true
for g := range e.delivered {
if e.deliveredMax-g >= deliveredGroupWindow {
delete(e.delivered, g)
}
}
}
// An object from a group already aged out of the window is treated as
// already delivered — a peer lagging that far behind is past any useful
// reorder window, and re-forwarding it would be a large out-of-order break.
if group <= e.deliveredMax && e.deliveredMax-group >= deliveredGroupWindow {
return false
}
set := e.delivered[group]
if set == nil {
set = make(map[uint64]struct{})
e.delivered[group] = set
}
if _, ok := set[object]; ok {
return false
}
set[object] = struct{}{}
return true
}
// ReleaseSubgroup drops one contributor from (group, subgroup) and reports
// whether that was the last one (in which case the shared state has been removed
// from the entry and the caller owns tearing down its downstream writers).
func (e *TrackEntry) ReleaseSubgroup(key SubgroupKey) (last bool) {
e.sgMu.Lock()
defer e.sgMu.Unlock()
sg, ok := e.subgroups[key]
if !ok {
return false
}
sg.refs--
if sg.refs <= 0 {
delete(e.subgroups, key)
return true
}
return false
}
// UpdateLargest moves the entry's LargestObject forward. The very first
// call flips the "has any object been observed" bit regardless of value,
// so that a publisher whose first Object is at Location {0, 0} is still
// distinguishable from "no objects observed yet" — §10.2.17 reserves the
// wire-level omission of LARGEST_OBJECT for the latter, and the
// in-memory mirror needs the same distinction. Subsequent calls advance
// the watermark only when loc is strictly greater than the current
// value.
//
// Returns true when the watermark changed (advanced or first-set);
// callers can use it to avoid redundant LARGEST_OBJECT-property
// emission downstream.
func (e *TrackEntry) UpdateLargest(loc message.Location) bool {
e.mu.Lock()
defer e.mu.Unlock()
if !e.HasLargestObject || e.LargestObject.Less(loc) {
e.LargestObject = loc
e.HasLargestObject = true
return true
}
return false
}
// GetLargest returns the current largest-object watermark and a bool that is
// true iff at least one object has been observed on this track. The bool
// distinguishes "no objects observed yet" from "first object was published at
// Location {0, 0}" — §10.2.17 reserves wire-level omission of LARGEST_OBJECT
// for the former, so the in-memory mirror needs the same distinction.
func (e *TrackEntry) GetLargest() (message.Location, bool) {
e.mu.RLock()
defer e.mu.RUnlock()
return e.LargestObject, e.HasLargestObject
}
// ConsiderNewGroupRequest applies the §10.2.19 relay rules for a
// NEW_GROUP_REQUEST received on an Established subscription and reports whether
// the relay must forward it upstream (via an upstream REQUEST_UPDATE). When it
// returns true the request is recorded as outstanding.
//
// value is the downstream NEW_GROUP_REQUEST (largest known Group + 1, or 0 for
// "no Group information"). dynamicGroups reports whether the track advertised
// DYNAMIC_GROUPS=1 (§12.6). The rules:
//
// - The Track must support dynamic Groups (unless-clause 1).
// - The request is forwarded only when value is 0 or larger than the current
// Largest Group; a non-zero value at or below the Largest Group is not
// forwarded.
// - An outstanding request with a value greater than or equal to this one
// already covers it (unless-clause 2). An outstanding request is cleared
// once the Largest Group advances past where it was sent.
func (e *TrackEntry) ConsiderNewGroupRequest(value uint64, dynamicGroups bool) bool {
if !dynamicGroups {
return false
}
e.mu.Lock()
defer e.mu.Unlock()
var largest uint64
if e.HasLargestObject {
largest = e.LargestObject.Group
}
// "After sending a NEW_GROUP_REQUEST upstream, the request is considered
// outstanding until the Largest Group increases."
if e.newGroupOutstanding && largest > e.newGroupReqGroup {
e.newGroupOutstanding = false
}
// A non-zero value at or below the Largest Group needs no new Group.
if value != 0 && value <= largest {
return false
}
// An outstanding request of equal or greater value already covers this.
if e.newGroupOutstanding && e.newGroupReqValue >= value {
return false
}
e.newGroupOutstanding = true
e.newGroupReqValue = value
e.newGroupReqGroup = largest
return true
}
// UpdateLargestAndDetectNew advances LargestObject and, under the same
// e.mu acquisition, returns any Downstream subs for which seen reports
// false. seen is consulted (the fanout passes a membership test over the
// writers it has already opened); the entry never mutates it. Used by
// runFanout per-object so a downstream sub that joined the entry's
// Downstream after the initial CopyDownstream is detected and given a
// writer for the current (and subsequent) objects on the in-flight
// subgroup stream.
//
// Atomic with [TrackRegistry.AddDownstreamSnapshotLargest]: a new sub
// either snapshots the pre-update LargestObject AND appears in newSubs
// (delivered live), or snapshots the post-update LargestObject (covered
// by its Joining FETCH). The lock pair guarantees no in-between.
// lastGen is the downstreamGen the caller observed on its previous call (or
// at its initial CopyDownstreamWithGen snapshot). When the generation is
// unchanged no sub has joined since, so the joiner scan is skipped entirely;
// gen (returned) should be fed back as lastGen on the next call.
//
// seen is a predicate rather than a concrete map so this (registry) layer
// need not know the fanout's writer type, keeping the dependency edge
// pointing one way (fanout → registry).
func (e *TrackEntry) UpdateLargestAndDetectNew(
loc message.Location,
seen func(*DownstreamSub) bool,
lastGen uint64,
) (newSubs []*DownstreamSub, gen uint64) {
e.mu.Lock()
defer e.mu.Unlock()
if !e.HasLargestObject || e.LargestObject.Less(loc) {
e.LargestObject = loc
e.HasLargestObject = true
}
if e.downstreamGen == lastGen {
return nil, e.downstreamGen
}
for _, sub := range e.Downstream {
if !seen(sub) {
newSubs = append(newSubs, sub)
}
}
return newSubs, e.downstreamGen
}
// SetProperties stores the Track Properties learned from the upstream. The
// caller hands over ownership of props; callers MUST NOT mutate props after
// this call. (Properties are immutable once captured — §9.6 expects them to
// be replayed verbatim.)
func (e *TrackEntry) SetProperties(props []byte) {
e.mu.Lock()
e.setPropertiesLocked(props)
e.mu.Unlock()
}
// setPropertiesLocked stores the raw Properties bytes and decodes the fields
// the relay acts on in the same step, so the decoded values never drift from
// the raw bytes. Callers must hold e.mu.
func (e *TrackEntry) setPropertiesLocked(raw []byte) {
e.Properties = raw
e.decoded = decodeTrackProperties(raw)
}
// GetProperties returns the raw Track Properties captured from the upstream
// publisher. The returned slice is the same byte buffer stored on the entry;
// callers MUST NOT mutate it.
func (e *TrackEntry) GetProperties() []byte {
e.mu.RLock()
defer e.mu.RUnlock()
return e.Properties
}
// 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.
func (e *TrackEntry) CopyUpstream() []*UpstreamSub {
e.mu.RLock()
defer e.mu.RUnlock()
out := make([]*UpstreamSub, len(e.Upstream))
copy(out, e.Upstream)
return out
}
// CopyDownstream returns a snapshot of the current downstream slice. See
// [TrackEntry.CopyUpstream] for rationale.
func (e *TrackEntry) CopyDownstream() []*DownstreamSub {
e.mu.RLock()
defer e.mu.RUnlock()
out := make([]*DownstreamSub, len(e.Downstream))
copy(out, e.Downstream)
return out
}
// CopyDownstreamWithGen is [TrackEntry.CopyDownstream] plus the matching
// downstreamGen, captured under the same lock so the fanout can seed its
// joiner-scan skip with a generation that is exactly consistent with the
// snapshot (a sub joining after this returns bumps the generation and so is
// still detected on the next per-object call).
func (e *TrackEntry) CopyDownstreamWithGen() ([]*DownstreamSub, uint64) {
e.mu.RLock()
defer e.mu.RUnlock()
out := make([]*DownstreamSub, len(e.Downstream))
copy(out, e.Downstream)
return out, e.downstreamGen
}
// Package registry holds the relay's process-wide shared state: the track
// registry (object routing + per-track cache), the namespace registry
// (PUBLISH_NAMESPACE / SUBSCRIBE_NAMESPACE bookkeeping), the fetch router
// (rendezvous for upstream FETCH response streams), and the subscription
// state machine (UpstreamSub / DownstreamSub).
//
// It is the bottom layer of the relay: the parent pkg/relay session handlers
// depend on it, but it never imports the parent — the dependency edge only
// ever points handler → registry. Living under internal/ also keeps these
// types out of pkg/relay's public API; they are exported for the package's own
// white-box tests, not for external consumers. See the pkg/relay package doc
// for the full layer map.
package registry
import (
"context"
"errors"
"log/slog"
"slices"
"sync"
"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/cache"
"github.com/floatdrop/moq-go/pkg/relay/discovery"
)
// Default per-track object-cache bounds. Used by [NewTrackRegistry] when
// the caller does not supply [WithCacheConfig]. The relay's Config overrides
// these (relay.New reads them to fill unset Config fields); tests that
// construct registries directly inherit the defaults.
const (
DefaultCacheMaxSize = 1024
DefaultCacheMaxDuration = 30 * time.Second
)
// discoveryCallTimeout bounds each best-effort call into the DiscoveryStore
// (publish/unpublish of tracks and namespaces). Discovery is off the critical
// path, so a short timeout keeps a slow store from stalling registry
// bookkeeping; failures are logged and swallowed.
const discoveryCallTimeout = 100 * time.Millisecond
// CacheTTLPolicy is the registry's view of the per-track Object Cache TTL
// override: given a track's Full Track Name, return the TTL to use. It is
// the structural twin of the public relay.CacheTTLPolicy; relay converts
// its exported type to this one at the registry boundary, which keeps the
// dependency pointing one way (registry never imports its parent) while
// still giving each layer a named, self-documenting type rather than a
// bare function signature. [resolveCacheTTL] documents the return-value
// contract (negative disables eviction, 0 falls through to the default).
type CacheTTLPolicy func(name track.FullTrackName) time.Duration
// TrackRegistry indexes [TrackEntry] values by [track.Key]. It is the single
// rendezvous point for everything in the relay that needs to address a track
// — request handlers, fanout, the cache, and the discovery store.
//
// Locking strategy: the registry-level RWMutex protects only the tracks map.
// All entry mutation happens under TrackEntry.mu, which the helpers below
// acquire in the appropriate mode. This keeps the registry-level critical
// sections O(1) and lets per-track work proceed in parallel.
type TrackRegistry struct {
mu sync.RWMutex
tracks map[track.Key]*TrackEntry
// cacheMaxSize / cacheMaxDuration are the per-track Object Cache
// bounds applied to every entry created by this registry.
cacheMaxSize int
cacheMaxDuration time.Duration
// cacheTTLPolicy, when non-nil, may override cacheMaxDuration on a
// per-track basis. See [CacheTTLPolicy] for the contract and
// [resolveCacheTTL] for how its result is interpreted.
cacheTTLPolicy CacheTTLPolicy
// discovery is the cross-instance track advertisement fabric. nil
// means "do not advertise" — the relay still works as a local
// single-instance setup. When non-nil, the registry publishes a
// [discovery.TrackInfo] on the first AddUpstream for a track and
// unpublishes on the last RemoveUpstream.
discovery discovery.DiscoveryStore
// relayAddr is the address the relay registers itself as in
// Discovery entries. Empty for single-relay deployments.
relayAddr string
// log is used for warn-level reports when a Discovery call fails.
// Discovery failures are NOT propagated to the caller — the
// registry is the source of truth for local state, Discovery is
// best-effort.
log *slog.Logger
}
// TrackRegistryOption tweaks a [TrackRegistry] at construction time.
type TrackRegistryOption func(*TrackRegistry)
// WithCacheConfig sets the per-track object-cache bounds applied to every
// new entry the registry constructs. maxSize is the per-track upper bound
// on stored objects; maxDuration is the maximum age before time-based
// eviction. Values <= 0 fall back to the package defaults
// ([DefaultCacheMaxSize], [DefaultCacheMaxDuration]).
func WithCacheConfig(maxSize int, maxDuration time.Duration) TrackRegistryOption {
return func(r *TrackRegistry) {
if maxSize > 0 {
r.cacheMaxSize = maxSize
}
if maxDuration > 0 {
r.cacheMaxDuration = maxDuration
}
}
}
// WithCacheTTLPolicy installs a per-track TTL override hook. See
// [CacheTTLPolicy] for the contract and [resolveCacheTTL] for how the
// returned duration is interpreted. Passing a nil policy is allowed and
// equivalent to not calling this option — every track uses the default
// TTL from [WithCacheConfig].
//
// Typical use is to give one well-known track (e.g. an MSF catalog
// track) infinite retention while every other track keeps the default
// 30-second bound — the operator wires the rule into the policy at the
// binary layer so the relay stays protocol-agnostic.
func WithCacheTTLPolicy(policy CacheTTLPolicy) TrackRegistryOption {
return func(r *TrackRegistry) {
r.cacheTTLPolicy = policy
}
}
// WithTrackDiscovery installs a [discovery.DiscoveryStore] for
// cross-instance track advertisement. relayAddr is the value stamped
// into every [discovery.TrackInfo] this registry emits.
func WithTrackDiscovery(d discovery.DiscoveryStore, relayAddr string) TrackRegistryOption {
return func(r *TrackRegistry) {
r.discovery = d
r.relayAddr = relayAddr
}
}
// WithTrackRegistryLogger sets the logger used for Discovery warnings.
func WithTrackRegistryLogger(l *slog.Logger) TrackRegistryOption {
return func(r *TrackRegistry) { r.log = l }
}
// NewTrackRegistry constructs an empty registry. Default per-track cache
// bounds are [DefaultCacheMaxSize] / [DefaultCacheMaxDuration]; callers
// override them with [WithCacheConfig].
func NewTrackRegistry(opts ...TrackRegistryOption) *TrackRegistry {
r := &TrackRegistry{
tracks: make(map[track.Key]*TrackEntry),
cacheMaxSize: DefaultCacheMaxSize,
cacheMaxDuration: DefaultCacheMaxDuration,
log: slog.Default(),
}
for _, opt := range opts {
opt(r)
}
return r
}
// Get returns the entry for key, or (nil, false) if no such track is known.
// The returned pointer is valid until the entry is destroyed (last Remove*
// call) — readers that want to keep it across long operations should
// nevertheless cope with a stale pointer by re-querying.
func (r *TrackRegistry) Get(key track.Key) (*TrackEntry, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
e, ok := r.tracks[key]
return e, ok
}
// GetOrCreateNew is [TrackRegistry.GetOrCreate] that also reports whether this
// call created the entry. Callers that create one speculatively — before the
// request that will populate it is known to succeed — need to know, because
// only the creator may take it back again (see [TrackRegistry.DeleteIfUnused]).
func (r *TrackRegistry) GetOrCreateNew(fullName track.FullTrackName) (entry *TrackEntry, created bool) {
key := fullName.Key()
r.mu.Lock()
defer r.mu.Unlock()
if e, ok := r.tracks[key]; ok {
return e, false
}
return r.getOrCreateLocked(fullName), true
}
// DeleteIfUnused removes fullName's entry if nothing has happened to it since
// it was created: no upstream, no downstream, no watermark and nothing cached.
// It is the counterpart to a speculative [TrackRegistry.GetOrCreateNew] — the
// PUBLISH path creates the entry before the request is known to succeed, so an
// inbound data stream cannot arrive against a routable Track Alias with no
// entry to route it to (§11.1, §10.11). When the request then fails, the entry
// must not linger: [TrackRegistry.Get] answering for it is read as "track
// known" by the FETCH path, which turns a DOES_NOT_EXIST into an INVALID_RANGE.
//
// The watermark and cache are checked as well as the two slices because a
// DIFFERENT session may have adopted this entry in the meantime and be part
// way through its own §10.11 window — publishing objects into a track whose
// AddUpstream has not run yet. Deleting it there would strand exactly the
// streams this mechanism exists to protect. Callers must additionally only
// call this when GetOrCreateNew reported created, so an entry another session
// created is never a candidate.
//
// Idempotent, and a no-op once anything has registered against the entry.
func (r *TrackRegistry) DeleteIfUnused(fullName track.FullTrackName) {
key := fullName.Key()
r.mu.Lock()
defer r.mu.Unlock()
entry, ok := r.tracks[key]
if !ok {
return
}
// r.mu then entry.mu throughout, matching AddUpstream / RemoveSession.
// GetLargest takes entry.mu itself, so it runs before the block below
// rather than inside it.
if _, hasLargest := entry.GetLargest(); hasLargest {
return
}
if entry.Cache.Len() > 0 {
return
}
entry.mu.Lock()
unused := len(entry.Upstream) == 0 && len(entry.Downstream) == 0
entry.mu.Unlock()
if unused {
delete(r.tracks, key)
}
}
// GetOrCreate returns the existing entry for fullName, or creates and inserts
// a new one if none exists yet. The fullName argument (rather than just a
// Key) is required so a freshly-created entry can be populated with the
// {namespace, name} tuple §9.6 needs to echo back on outbound replies.
//
// NOTE: GetOrCreate by itself is not sufficient to protect against the
// resurrection race — between this call returning and the caller acquiring
// the entry's own lock, a concurrent Remove may delete the entry from the
// registry map even though the returned pointer remains valid. Callers that
// mutate Upstream/Downstream MUST go through [TrackRegistry.AddUpstream] /
// [TrackRegistry.AddDownstream] (which hold the registry lock for the whole
// add operation) rather than calling GetOrCreate themselves. GetOrCreate is
// exported because read-only callers (tests, metrics) legitimately want a
// "find me an entry, create if missing" primitive.
func (r *TrackRegistry) GetOrCreate(fullName track.FullTrackName) *TrackEntry {
r.mu.Lock()
defer r.mu.Unlock()
return r.getOrCreateLocked(fullName)
}
// getOrCreateLocked is the inner helper used by AddUpstream / AddDownstream.
// The caller must hold r.mu for writing.
func (r *TrackRegistry) getOrCreateLocked(fullName track.FullTrackName) *TrackEntry {
key := fullName.Key()
if e, ok := r.tracks[key]; ok {
return e
}
e := &TrackEntry{
Key: key,
FullName: fullName,
Cache: cache.NewObjectCache(r.cacheMaxSize, r.resolveCacheTTL(fullName)),
}
r.tracks[key] = e
return e
}
// resolveCacheTTL picks the per-track Object Cache TTL for fullName,
// consulting [TrackRegistry.cacheTTLPolicy] if one was installed. It
// maps the policy's return value onto the [cache.ObjectCache]
// convention (where a non-positive TTL means "no time-based eviction"):
//
// - a negative duration (the public relay.CacheTTLInfinite sentinel)
// becomes 0, disabling time-based eviction for the track;
// - a positive duration is used as-is;
// - 0, or no policy at all, falls through to the registry-wide
// default from [WithCacheConfig].
//
// Keeping this translation inside the registry means policy authors only
// ever deal with the public relay.CacheTTLPolicy vocabulary.
func (r *TrackRegistry) resolveCacheTTL(fullName track.FullTrackName) time.Duration {
if r.cacheTTLPolicy == nil {
return r.cacheMaxDuration
}
switch d := r.cacheTTLPolicy(fullName); {
case d < 0:
return 0 // cache.ObjectCache: <=0 means "no TTL filtering"
case d > 0:
return d
default:
return r.cacheMaxDuration
}
}
// Len returns the number of tracks currently held. Primarily useful for
// tests and metrics; not part of the relay's hot path.
func (r *TrackRegistry) Len() int {
r.mu.RLock()
defer r.mu.RUnlock()
return len(r.tracks)
}
// AddUpstream appends sub to the entry for fullName (creating the entry if
// necessary) and returns the entry.
//
// Returns (entry, becameNonEmpty). becameNonEmpty is true when this call
// installed the first upstream subscription on the entry, which is the
// signal the registry uses to publish the track to the Discovery Store.
// The boolean also lets tests assert the "first publisher" transition.
//
// The whole add operation runs under the registry write lock. This is
// stricter than strictly necessary, but it eliminates a subtle race where
// a Remove on another goroutine could delete the entry from the map after
// GetOrCreate returns but before the caller locks the entry — leaving the
// caller mutating a [TrackEntry] that no future Get can reach. Add/Remove
// frequency is dwarfed by fanout (which uses [TrackRegistry.Get] +
// [TrackEntry.CopyDownstream] and only takes the registry RLock), so the
// extra serialisation does not affect the hot path.
func (r *TrackRegistry) AddUpstream(
fullName track.FullTrackName,
sub *UpstreamSub,
opts ...AddUpstreamOption,
) (entry *TrackEntry, becameNonEmpty bool) {
var conf addUpstreamConfig
for _, opt := range opts {
opt(&conf)
}
r.mu.Lock()
defer r.mu.Unlock()
entry = r.getOrCreateLocked(fullName)
entry.mu.Lock()
defer entry.mu.Unlock()
becameNonEmpty = len(entry.Upstream) == 0
entry.Upstream = append(entry.Upstream, sub)
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
// captured by a prior caller — §9.6 expects them to be
// stable for the lifetime of the track entry, so the first
// setter wins.
entry.setPropertiesLocked(conf.properties)
}
if becameNonEmpty {
r.publishTrackToDiscovery(entry)
}
return entry, becameNonEmpty
}
// AddUpstreamOption tweaks an [TrackRegistry.AddUpstream] call.
type AddUpstreamOption func(*addUpstreamConfig)
type addUpstreamConfig struct {
setProperties bool
properties []byte
}
// WithProperties attaches Track Properties (§9.6) to the entry
// atomically with the first upstream-sub insertion, so the Discovery
// publish triggered by the same call sees them. Without this, a
// caller that sets Properties after AddUpstream returns sees an
// initial Discovery event with empty Properties followed by no
// update — the §9.6 properties end up missing from the
// cross-relay record. Passing them through AddUpstream avoids the gap.
func WithProperties(props []byte) AddUpstreamOption {
return func(c *addUpstreamConfig) {
c.setProperties = true
c.properties = props
}
}
// AddDownstream appends sub to the entry for fullName and returns the entry.
// Concurrency rules match [TrackRegistry.AddUpstream].
func (r *TrackRegistry) AddDownstream(fullName track.FullTrackName, sub *DownstreamSub) *TrackEntry {
r.mu.Lock()
defer r.mu.Unlock()
entry := r.getOrCreateLocked(fullName)
entry.mu.Lock()
defer entry.mu.Unlock()
entry.Downstream = append(entry.Downstream, sub)
entry.downstreamGen++
return entry
}
// AddDownstreamSnapshotLargest atomically appends sub to the entry's
// Downstream slice AND captures the current LargestObject watermark,
// both under a single entry.mu.Lock acquisition.
//
// Why atomic: handleSubscribe needs a [DownstreamSub.LargestAtSubscribe]
// snapshot that is consistent with the moment the sub becomes eligible
// for live fanout delivery. If the snapshot and append happen in
// separate lock cycles, a publisher write between them can:
// - Run the fanout's UpdateLargest (under entry.mu) → advances Largest
// - Cache the object
// - Not deliver to this sub via live (the fanout's CopyDownstream
// snapshot pre-dates our append)
//
// resulting in an object whose Location is > our snapshot AND was never
// pushed to us via live — a gap that the Joining FETCH can't cover
// (FETCH end = JoiningLocation = our snapshot, which doesn't include
// the missed object).
//
// Holding entry.mu across both operations serialises with
// [TrackEntry.UpdateLargest] (which also locks entry.mu): either we
// snapshot the pre-update Largest AND appear in any post-update
// CopyDownstream, or we snapshot the post-update Largest. Either way,
// every object the publisher has emitted is either covered by FETCH
// (via the snapshot) or delivered via live (via Downstream inclusion).
// AddDownstreamSnapshotLargest never creates an entry and requires at least
// one upstream to still be registered: ok=false means the track's last
// upstream vanished between the caller's establish check and this call
// (the §9.4 TOCTOU) — registering the downstream anyway would resurrect a
// sourceless entry whose subscriber then hangs with neither objects nor
// PUBLISH_DONE. The caller retries the establish step or rejects.
func (r *TrackRegistry) AddDownstreamSnapshotLargest(
fullName track.FullTrackName,
sub *DownstreamSub,
) (entry *TrackEntry, largest message.Location, hasLargest, ok bool) {
r.mu.Lock()
defer r.mu.Unlock()
entry, exists := r.tracks[fullName.Key()]
if !exists {
return nil, message.Location{}, false, false
}
entry.mu.Lock()
defer entry.mu.Unlock()
if len(entry.Upstream) == 0 {
return nil, message.Location{}, false, false
}
entry.Downstream = append(entry.Downstream, sub)
entry.downstreamGen++
return entry, entry.LargestObject, entry.HasLargestObject, true
}
// RemoveUpstream removes the upstream subscription with the given ID from
// the entry for fullName. Returns (removed, upstreamEmpty, entryDeleted):
//
// - removed reports whether an entry with that ID was found and dropped.
// It is false if the track is unknown or no upstream with subID was
// present.
// - upstreamEmpty reports whether the entry's Upstream slice is empty
// after this call. The registry uses this signal to unpublish the
// track from the Discovery Store.
// - entryDeleted reports whether the whole [TrackEntry] was removed from
// the registry as a consequence (both Upstream and Downstream became
// empty). The bool is informational — the entry pointer is no longer
// reachable through [TrackRegistry.Get] after this returns true.
//
// The fullName argument mirrors [TrackRegistry.AddUpstream] for API parity;
// internally we use only its Key. Remove never creates an entry.
//
// Like Add*, the whole remove operation runs under the registry write lock
// so the "decide to delete, then delete" sequence cannot race a concurrent
// Add that resurrects the entry.
func (r *TrackRegistry) RemoveUpstream(
fullName track.FullTrackName,
subID uint64,
) (removed, upstreamEmpty, entryDeleted bool) {
key := fullName.Key()
r.mu.Lock()
entry, ok := r.tracks[key]
if !ok {
r.mu.Unlock()
return false, false, false
}
entry.mu.Lock()
before := len(entry.Upstream)
entry.Upstream = slices.DeleteFunc(entry.Upstream, func(s *UpstreamSub) bool {
return s.ID == subID
})
removed = len(entry.Upstream) < before
upstreamEmpty = len(entry.Upstream) == 0
if !removed {
entry.mu.Unlock()
r.mu.Unlock()
return false, upstreamEmpty, false
}
// Snapshot the downstream subs while we still hold the entry lock,
// so we can notify them outside the registry locks. Writing the
// PUBLISH_DONE message involves stream I/O; holding r.mu across it
// would freeze every other track for that duration.
var notifyDownstreams []*DownstreamSub
if upstreamEmpty && len(entry.Downstream) > 0 {
notifyDownstreams = append([]*DownstreamSub(nil), entry.Downstream...)
}
allEmpty := upstreamEmpty && len(entry.Downstream) == 0
entry.mu.Unlock()
if allEmpty {
delete(r.tracks, key)
entryDeleted = true
}
if upstreamEmpty {
// Still under r.mu: see [TrackRegistry.unpublishTrackFromDiscovery]
// for why the unpublish must serialize with AddUpstream's publish.
r.unpublishTrackFromDiscovery(entry)
}
r.mu.Unlock()
if upstreamEmpty {
for _, sub := range notifyDownstreams {
sub.TerminateWithPublishDone(moqt.PublishDoneTrackEnded,
"relay: upstream gone", 0)
}
}
return true, upstreamEmpty, entryDeleted
}
// RemoveDownstream removes the downstream subscription with the given ID
// from the entry for fullName. The return contract mirrors
// [TrackRegistry.RemoveUpstream]: (removed, downstreamEmpty, entryDeleted).
//
// When the removed subscription was the entry's LAST downstream, the
// relay's on-demand upstream subscriptions (§9.4 aggregation) have no
// consumers left: they are stripped from the entry in the same critical
// section — so a concurrent SUBSCRIBE cannot latch onto a dying upstream —
// and torn down via [UpstreamSub.CloseOnDemand] after the locks are
// released. PUBLISH-fed upstreams are untouched (their stream belongs to
// the publisher; future subscribers reuse it).
func (r *TrackRegistry) RemoveDownstream(
fullName track.FullTrackName,
subID uint64,
) (removed, downstreamEmpty, entryDeleted bool) {
key := fullName.Key()
r.mu.Lock()
entry, ok := r.tracks[key]
if !ok {
r.mu.Unlock()
return false, false, false
}
entry.mu.Lock()
before := len(entry.Downstream)
entry.Downstream = slices.DeleteFunc(entry.Downstream, func(s *DownstreamSub) bool {
return s.ID == subID
})
removed = len(entry.Downstream) < before
downstreamEmpty = len(entry.Downstream) == 0
if !removed {
entry.mu.Unlock()
r.mu.Unlock()
return false, downstreamEmpty, false
}
var stranded []*UpstreamSub
hadUpstream := len(entry.Upstream) > 0
if downstreamEmpty {
stranded = stripOnDemandLocked(entry)
}
upstreamEmpty := len(entry.Upstream) == 0
allEmpty := downstreamEmpty && upstreamEmpty
entry.mu.Unlock()
if allEmpty {
delete(r.tracks, key)
entryDeleted = true
}
if hadUpstream && upstreamEmpty {
// Still under r.mu: see [TrackRegistry.unpublishTrackFromDiscovery]
// for why the unpublish must serialize with AddUpstream's publish.
r.unpublishTrackFromDiscovery(entry)
}
r.mu.Unlock()
// Stream I/O happens outside the registry locks.
for _, u := range stranded {
u.CloseOnDemand()
}
return true, downstreamEmpty, entryDeleted
}
// stripOnDemandLocked removes every OnDemand upstream from entry and returns
// them for teardown; the caller must hold entry.mu and must CloseOnDemand
// the returned subs only after releasing the registry locks (stream I/O).
//
// Deliberate trade-off: when this empties the entry, the entry — including
// its object cache and LARGEST_OBJECT watermark — is deleted with it, so a
// FETCH arriving after the last subscriber left cold-starts via a fresh
// upstream instead of hitting warm cache. The §9.4 aggregation exists to
// serve live downstreams, not to keep publishers streaming into a void.
func stripOnDemandLocked(entry *TrackEntry) (stranded []*UpstreamSub) {
entry.Upstream = slices.DeleteFunc(entry.Upstream, func(u *UpstreamSub) bool {
if u.OnDemand {
stranded = append(stranded, u)
}
return u.OnDemand
})
return stranded
}
// RemoveSession bulk-evicts every UpstreamSub and DownstreamSub owned by
// sess across every track. Used by the session handler's defer in
// [Relay.handleConn] as a belt-and-suspenders measure: per-request handler
// defers already remove individual subscriptions on a clean shutdown, but
// they cannot run if a handler goroutine is wedged on a stale stream or
// raced past Stop. RemoveSession guarantees the registry is consistent
// after a session terminates regardless of why.
//
// Returns the number of upstream and downstream subscriptions removed (in
// that order). Tracks whose subscription slices both become empty are
// deleted from the registry in the same critical section as the slice
// edits.
func (r *TrackRegistry) RemoveSession(sess *session.Session) (upstreamRemoved, downstreamRemoved int) {
r.mu.Lock()
// Collect entries whose upstream slice transitions to empty so we
// can notify their dependent downstream subscribers after releasing
// the locks — PUBLISH_DONE stream writes must not run under r.mu.
// Their Discovery unpublish, by contrast, happens before r.mu is
// released: see [TrackRegistry.unpublishTrackFromDiscovery].
type orphaned struct {
entry *TrackEntry
downstreams []*DownstreamSub
}
var (
orphans []orphaned
stranded []*UpstreamSub
)
for key, entry := range r.tracks {
entry.mu.Lock()
beforeU := len(entry.Upstream)
entry.Upstream = slices.DeleteFunc(entry.Upstream, func(s *UpstreamSub) bool {
return s.Session == sess
})
upstreamRemoved += beforeU - len(entry.Upstream)
hadUpstream := beforeU > 0
nowEmptyU := len(entry.Upstream) == 0
if hadUpstream && nowEmptyU {
// Snapshot the surviving downstreams BEFORE we strip
// the ones owned by sess — a publisher session that
// also has downstreams on the same track (a relay
// chain configuration) shouldn't notify itself.
var notify []*DownstreamSub
for _, d := range entry.Downstream {
if d.Session != sess {
notify = append(notify, d)
}
}
orphans = append(orphans, orphaned{entry: entry, downstreams: notify})
}
beforeD := len(entry.Downstream)
entry.Downstream = slices.DeleteFunc(entry.Downstream, func(s *DownstreamSub) bool {
return s.Session == sess
})
removedHereD := beforeD - len(entry.Downstream)
downstreamRemoved += removedHereD
// The dying session may have been a track's last downstream: the
// relay's on-demand upstream subscriptions on OTHER sessions then
// have no consumers left — strip them (same rule as
// [TrackRegistry.RemoveDownstream]) and tear them down after the
// locks drop. Gated on removedHereD so a disconnect never touches
// tracks this session had no downstream on: a zero-downstream
// entry may be another handler's in-flight registration (upstream
// established, downstream not yet added). Upstreams on sess itself
// were already removed above.
if removedHereD > 0 && len(entry.Downstream) == 0 && len(entry.Upstream) > 0 {
if s := stripOnDemandLocked(entry); len(s) > 0 {
stranded = append(stranded, s...)
if len(entry.Upstream) == 0 {
orphans = append(orphans, orphaned{entry: entry})
}
}
}
empty := len(entry.Upstream) == 0 && len(entry.Downstream) == 0
entry.mu.Unlock()
if empty {
delete(r.tracks, key)
}
}
for _, o := range orphans {
// Still under r.mu: see [TrackRegistry.unpublishTrackFromDiscovery]
// for why the unpublish must serialize with AddUpstream's publish.
r.unpublishTrackFromDiscovery(o.entry)
}
r.mu.Unlock()
for _, u := range stranded {
u.CloseOnDemand()
}
for _, o := range orphans {
for _, sub := range o.downstreams {
sub.TerminateWithPublishDone(moqt.PublishDoneTrackEnded,
"relay: publisher session gone", 0)
}
}
return upstreamRemoved, downstreamRemoved
}
// publishTrackToDiscovery advertises the entry to the Discovery store
// if one is configured. Called when the first UpstreamSub lands on a
// track. The caller MUST hold entry.mu — Properties is read under it.
// The Discovery call itself runs synchronously on the caller's
// goroutine, with a short context to avoid wedging the hot path on a
// misbehaving backend. Errors are logged at Warn but never propagated.
func (r *TrackRegistry) publishTrackToDiscovery(entry *TrackEntry) {
if r.discovery == nil {
return
}
info := discovery.TrackInfo{
Key: entry.Key,
FullName: entry.FullName,
Properties: entry.Properties,
RelayAddr: r.relayAddr,
}
ctx, cancel := context.WithTimeout(context.Background(), discoveryCallTimeout)
defer cancel()
// ErrWithdrawn is not a failure: the relay is shutting down and the store
// has deliberately stopped accepting advertisements, so every track still
// draining would log one of these.
if err := r.discovery.PublishTrack(ctx, info); err != nil && !errors.Is(err, discovery.ErrWithdrawn) {
r.log.Warn("discovery: PublishTrack failed", "err", err.Error(), "key", info.Key)
}
}
// unpublishTrackFromDiscovery is the counterpart called when the last
// UpstreamSub leaves a track.
//
// The caller MUST hold r.mu. Both this and [publishTrackToDiscovery] run
// under the registry lock so the store receives publish/unpublish calls in
// exactly the order the registry's upstream count crossed 0 — a late
// unpublish issued after releasing r.mu could race a concurrent
// AddUpstream's publish and erase the re-published track's record, leaving
// a live track invisible cross-relay until its upstream cycles (nothing
// re-publishes without another 0→1 transition). The Discovery call is
// bounded by [discoveryCallTimeout] — and the interface requires backends
// to honor ctx deadlines — so the lock hold is bounded too. RemoveSession
// pays this once per orphaned track, serially; with a degraded backend
// that is N × the timeout, an accepted worst case for a best-effort
// advertisement fabric.
func (r *TrackRegistry) unpublishTrackFromDiscovery(entry *TrackEntry) {
if r.discovery == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), discoveryCallTimeout)
defer cancel()
if err := r.discovery.UnpublishTrack(ctx, entry.Key, r.relayAddr); err != nil {
r.log.Warn("discovery: UnpublishTrack failed", "err", err.Error(), "key", entry.Key)
}
}
// Package relaytest holds helpers shared across the relay tests (the
// relay_test and registry_test packages). Keeping them here avoids
// duplicating the same helper across test files that cannot otherwise share
// unexported code.
package relaytest
import (
"context"
"errors"
"strings"
"testing"
"time"
"github.com/floatdrop/moq-go/pkg/moqt/message"
"github.com/floatdrop/moq-go/pkg/moqt/session"
"github.com/floatdrop/moq-go/pkg/moqt/wire"
)
// FormatNamespace renders a Track Namespace as a readable slash-joined string
// for test failure messages. Shared by the relay_test and registry_test
// packages.
func FormatNamespace(ns wire.TrackNamespace) string {
if len(ns) == 0 {
return "<root>"
}
var out strings.Builder
for i, f := range ns {
if i > 0 {
out.WriteString("/")
}
out.Write(f)
}
return out.String()
}
// ReadNextMessage parses one full MoQT control message off stream, failing
// the test if reading takes longer than the deadline allows. A context
// cancellation while blocked in Parse is treated as a clean unblock and
// returns the (possibly nil) partial message rather than failing.
func ReadNextMessage(t *testing.T, stream session.Stream, deadline <-chan time.Time) message.Message {
t.Helper()
done := make(chan struct{})
var (
msg message.Message
err error
)
go func() {
defer close(done)
msg, err = message.Parse(stream)
}()
select {
case <-done:
if err != nil && !errors.Is(err, context.Canceled) {
t.Fatalf("message.Parse: %v", err)
}
return msg
case <-deadline:
t.Fatal("timeout waiting for next message")
return nil
}
}
package relay
import "sync"
// sessionLimiter enforces the per-session resource caps from §13.1
// (subscription amplification) and §13.7.1 (relay state maintenance): a bound
// on the number of concurrently-active subscriptions and on concurrently-active
// namespace-state requests (PUBLISH_NAMESPACE / SUBSCRIBE_NAMESPACE /
// SUBSCRIBE_TRACKS) a single session may hold. A non-positive max disables the
// corresponding limit (the relay's default — these are deployment policies).
//
// The counts track in-flight request handlers: acquire is called at dispatch
// before a handler is spawned, release when it returns (a handler runs for its
// request's whole lifetime). An over-limit request is rejected with
// REQUEST_ERROR EXCESSIVE_LOAD before any shared state is mutated.
type sessionLimiter struct {
mu sync.Mutex
subs int
ns int
maxSubs int
maxNS int
}
func (l *sessionLimiter) acquireSub() bool { return l.acquire(&l.subs, l.maxSubs) }
func (l *sessionLimiter) releaseSub() { l.release(&l.subs, l.maxSubs) }
func (l *sessionLimiter) acquireNamespace() bool { return l.acquire(&l.ns, l.maxNS) }
func (l *sessionLimiter) releaseNamespace() { l.release(&l.ns, l.maxNS) }
// acquire reserves a slot in the counter *n bounded by limit. It returns false
// (without incrementing) when the limit is already reached, and true otherwise.
// A non-positive limit means unlimited.
func (l *sessionLimiter) acquire(n *int, limit int) bool {
if limit <= 0 {
return true
}
l.mu.Lock()
defer l.mu.Unlock()
if *n >= limit {
return false
}
*n++
return true
}
// release returns a slot previously taken by acquire. It is a no-op when the
// limit is disabled, and clamps at zero defensively.
func (l *sessionLimiter) release(n *int, limit int) {
if limit <= 0 {
return
}
l.mu.Lock()
if *n > 0 {
*n--
}
l.mu.Unlock()
}
package relay
// Leg says which side of a relay mesh the session carrying an event sits on.
//
// It is deliberately a property of the *session*, not of the peer's role: a
// relay knows for certain who dialled whom, and nothing else about a peer is
// trustworthy. A peer relay that dials *in* is therefore [LegLocal], exactly
// like a browser — the relay has no reliable way to tell them apart, and
// guessing from the MOQT_IMPLEMENTATION string a peer volunteers would be a
// label an operator could not trust.
//
// That asymmetry is not a gap, because a cross-relay hop is observed from both
// ends. The consuming relay reports the hop as [LegUpstream] (it dialled), and
// the producing relay reports the same hop among its [LegLocal] traffic (it was
// dialled). Scrape both instances and the hop is the difference between the
// two: objects the consumer received on its upstream leg, against objects the
// producer forwarded. Objects that leave one and never arrive at the other are
// lost in the middle.
type Leg uint8
const (
// LegLocal is a session a peer opened to this relay: an ordinary
// publisher or subscriber, or a peer relay that dialled in.
LegLocal Leg = iota
// LegUpstream is a session this relay dialled out to a peer it found
// through [discovery.DiscoveryStore] — the cross-relay hop, from the
// consuming side.
LegUpstream
)
// String returns a stable, lowercase name suitable for use as a metric label
// value. New Leg values may be added; an unknown one renders as "unknown"
// rather than a number, so a label never turns into a cardinality surprise.
func (l Leg) String() string {
switch l {
case LegLocal:
return "local"
case LegUpstream:
return "upstream"
default:
return "unknown"
}
}
// TrackRef identifies the track an event happened on. It is passed by value on
// the per-object hot path and holds no pointers, so it costs a copy and no
// allocation.
//
// Name is the track name half of the full track name — for a Media Sync Format
// producer, names like "catalog", "video" and "audio". The *namespace* is
// deliberately absent: it carries the publisher's identity (in a conference,
// one namespace per participant), so a metrics backend keyed on it would grow a
// new time series per participant per call and never retire them. A backend
// that wants per-publisher detail should sample it out-of-band, not from the
// hot path.
//
// Name comes off the wire and is chosen by the publisher, so it is NOT
// inherently bounded either. An implementation that turns it into a label MUST
// fold unrecognised names into a catch-all bucket.
type TrackRef struct {
Name string
Leg Leg
}
// ResetCause explains why the relay tore down a subgroup stream or a whole
// subscription. It is the distinction that matters when a subscriber's picture
// breaks up between keyframes: the relay abandoning a subgroup stream mid-group
// loses the rest of that group's objects, and each cause below implies a
// different fix.
type ResetCause uint8
const (
// ResetCauseGap is a §11.4.3 reopen: the next object to forward was not
// consecutive with the last one written, so the current outbound stream
// was reset and a fresh one opened. The relay MUST NOT forward a
// non-consecutive object on an existing subgroup stream, so this is
// correct behaviour — but it is also the direct consequence of an
// earlier drop or filter narrowing, and a subscriber sees the hole.
ResetCauseGap ResetCause = iota
// ResetCauseDeliveryTimeout is §8: an object sat unsent past the
// resolved publisher/subscriber delivery timeout, so
// [session.OutgoingSubgroupStream] reset this one stream with
// DELIVERY_TIMEOUT. The subscription survives; the rest of that
// subgroup does not.
ResetCauseDeliveryTimeout
// ResetCauseTooFarBehind is the §3.3.4 TOO_FAR_BEHIND verdict: an
// object waited in the send queue longer than [Config.MaxFanoutLag],
// so the subscription was terminated rather than allowed to trail the
// live edge indefinitely.
ResetCauseTooFarBehind
// ResetCauseExcessiveLoad is the optional [Config.MaxDropsBeforeReset]
// backstop: cumulative drops on one subscription passed the cap and it
// was terminated with EXCESSIVE_LOAD.
ResetCauseExcessiveLoad
// ResetCauseInboundReset is §11.4.3 propagation: the upstream stream
// feeding this subgroup was reset (or its session went away), so the
// corresponding downstream stream is reset rather than FIN'd.
ResetCauseInboundReset
// ResetCauseWriteError is a transport write failure on the outbound
// stream — the subscriber's session is in trouble, not the relay's
// scheduling.
ResetCauseWriteError
)
// String returns a stable, lowercase name suitable for use as a metric label
// value. Unknown values render as "unknown" rather than a number.
func (c ResetCause) String() string {
switch c {
case ResetCauseGap:
return "gap"
case ResetCauseDeliveryTimeout:
return "delivery_timeout"
case ResetCauseTooFarBehind:
return "too_far_behind"
case ResetCauseExcessiveLoad:
return "excessive_load"
case ResetCauseInboundReset:
return "inbound_reset"
case ResetCauseWriteError:
return "write_error"
default:
return "unknown"
}
}
// Metrics receives lifecycle and hot-path event notifications from a Relay so
// operators can wire relay activity into their own telemetry backend
// (Prometheus, OpenTelemetry, statsd, …) without this package depending on any
// of them. Install one via [Config.Metrics]; the default is [NopMetrics].
//
// All methods are invoked from relay goroutines, concurrently and — for
// ObjectReceived / ObjectForwarded / ObjectDropped — on the per-object fanout
// hot path, while the subgroup's fanout lock is held. An implementation MUST be
// safe for concurrent use and MUST NOT block: do the cheap thing (e.g. an
// atomic increment, or a counter handle looked up once and cached) and
// aggregate elsewhere. Blocking here stalls the inbound read loop for every
// subscriber of the subgroup, not just one.
//
// The interface may grow over time. Embed [NopMetrics] in your implementation
// so unimplemented methods default to a no-op and future additions stay
// backward-compatible.
type Metrics interface {
// SessionOpened is called when a session completes SETUP and is
// registered; SessionClosed is called exactly once per SessionOpened when
// that session's handler tears down. Together they track the live-session
// gauge, split by [Leg] so the count of live cross-relay hops is visible
// separately from client sessions.
SessionOpened(leg Leg)
SessionClosed(leg Leg)
// SubscriptionOpened is called when a downstream SUBSCRIBE is accepted and
// registered for fanout; SubscriptionClosed is called exactly once per
// SubscriptionOpened when the subscription is removed. Together they track
// the active-subscription gauge.
SubscriptionOpened(t TrackRef)
SubscriptionClosed(t TrackRef)
// ObjectReceived is called once for each object read off an inbound
// subgroup stream and won by this contributor — objects discarded as
// §9.3 duplicates of a redundant upstream are not counted. Compared
// against ObjectForwarded it separates "the relay never got it" from
// "the relay got it and shed it".
ObjectReceived(t TrackRef, subgroup uint64)
// ObjectForwarded is called once for each object successfully enqueued for
// delivery to a downstream subscriber. It is counted per subscriber, so a
// single received object fanned out to N subscribers reports N times —
// which is why it is not directly comparable to ObjectReceived without
// dividing by the subscriber count.
ObjectForwarded(t TrackRef, subgroup uint64)
// ObjectDropped is called when a downstream subscriber's bounded send
// queue overflows and the object is dropped (§8 slow-reader pressure).
// The subgroup is reported because it is how a layered publisher marks
// what is disposable: shedding an enhancement layer is the design
// working, and shedding the base layer is the picture breaking.
ObjectDropped(t TrackRef, subgroup uint64)
// SubgroupStreamReset is called when one outbound subgroup stream is torn
// down before its subgroup ended, for any of the [ResetCause] reasons. The
// subscription itself survives; the remainder of that subgroup does not
// reach this subscriber.
SubgroupStreamReset(t TrackRef, subgroup uint64, cause ResetCause)
// SubscriptionResetSlowReader is called when the relay forcibly resets a
// subscriber's outbound stream and terminates the whole subscription
// because it fell too far behind: an object waited longer than
// [Config.MaxFanoutLag] in the send queue
// ([ResetCauseTooFarBehind], the primary trigger), or the optional
// cumulative [Config.MaxDropsBeforeReset] cap was exceeded
// ([ResetCauseExcessiveLoad]).
SubscriptionResetSlowReader(t TrackRef, cause ResetCause)
// FetchServed is called when a FETCH is answered from the relay's object
// cache, with the number of objects returned (0 when the requested range
// produced no cached objects).
FetchServed(t TrackRef, objects int)
// UpstreamDialFailed is called when the upstream pool could not establish
// a relay-to-relay session with a peer advertised in Discovery. relayAddr
// is the peer address that failed, for logging and exemplars — it is
// operator-controlled but grows with the mesh, so an implementation
// SHOULD NOT make it a label.
UpstreamDialFailed(relayAddr string)
// NamespaceResolved is called after each Discovery FindNamespace lookup
// on the cross-relay path, with the number of peer relays advertising the
// namespace (0 when nobody does — the case where a subscriber gets
// nothing and no error explains why).
NamespaceResolved(advertisers int)
}
// NopMetrics is the no-op [Metrics] installed when [Config.Metrics] is nil.
// Embed it in a custom implementation to inherit no-op defaults for the methods
// you don't care about — which also keeps your type compiling as the [Metrics]
// interface grows:
//
// type myMetrics struct {
// relay.NopMetrics
// dropped atomic.Int64
// }
//
// func (m *myMetrics) ObjectDropped(relay.TrackRef, uint64) { m.dropped.Add(1) }
type NopMetrics struct{}
var _ Metrics = NopMetrics{}
func (NopMetrics) SessionOpened(Leg) {}
func (NopMetrics) SessionClosed(Leg) {}
func (NopMetrics) SubscriptionOpened(TrackRef) {}
func (NopMetrics) SubscriptionClosed(TrackRef) {}
func (NopMetrics) ObjectReceived(TrackRef, uint64) {}
func (NopMetrics) ObjectForwarded(TrackRef, uint64) {}
func (NopMetrics) ObjectDropped(TrackRef, uint64) {}
func (NopMetrics) SubgroupStreamReset(TrackRef, uint64, ResetCause) {}
func (NopMetrics) SubscriptionResetSlowReader(TrackRef, ResetCause) {}
func (NopMetrics) FetchServed(TrackRef, int) {}
func (NopMetrics) UpstreamDialFailed(string) {}
func (NopMetrics) NamespaceResolved(int) {}
// This file holds the relay's lifecycle scaffold: the transport-agnostic
// Listener interface, the Relay struct, and Start/Stop. The remaining
// components (Track Registry, Namespace Registry, Subscription Fanout,
// Object Cache, Discovery Store) live in sibling files and plug into this
// scaffold. See doc.go for the package overview and the file-layer map.
package relay
import (
"context"
"errors"
"fmt"
"log/slog"
"net"
"sync"
"time"
"github.com/floatdrop/moq-go/pkg/moqt"
"github.com/floatdrop/moq-go/pkg/moqt/session"
"github.com/floatdrop/moq-go/pkg/relay/discovery"
"github.com/floatdrop/moq-go/pkg/relay/internal/registry"
)
// Listener yields ready-to-use MOQT transport connections. The caller is
// responsible for TLS, ALPN ("moqt-20"), and — for WebTransport — the HTTP/3
// CONNECT upgrade before returning a Conn. The relay never binds sockets or
// terminates TLS itself.
//
// Implementations of this interface live in the transport adapter packages:
//
// - quicconn.NewListener wraps a *quic.Listener.
// - wtconn.NewListener wraps a webtransport.Server mounted on an
// http.Handler.
// - sessiontest provides an in-memory pipe listener for tests.
type Listener interface {
// Accept blocks until the next MOQT-ready Conn is available, or ctx is
// cancelled, or the listener is closed. The returned Conn has TLS and
// ALPN already negotiated; the relay only needs to drive the MOQT
// SETUP handshake on top.
Accept(ctx context.Context) (session.Conn, error)
// Addr returns the network address the listener is bound to. May be nil
// for purely in-process listeners.
Addr() net.Addr
// Close stops the listener. After Close returns, Accept must return
// promptly with an error. Close is safe to call from any goroutine and
// may be invoked more than once.
Close() error
}
// discoveryWithdrawTimeout bounds [Relay.Stop]'s Discovery withdrawal. It is the
// first step of shutdown, so a backend that has gone away must not be able to
// delay the GOAWAY broadcast behind it: the advertisements expire on their own
// once the liveness TTL lapses. Deliberately far longer than the registries'
// per-call discoveryCallTimeout — this is one RPC on the shutdown path, not a
// call made under a registry lock. Worst case it adds this much to Stop on top
// of GoawayTimeout, which still leaves room inside a typical 30s orchestrator
// termination grace period.
const discoveryWithdrawTimeout = 5 * time.Second
// Config carries all relay knobs. It bundles transport-agnostic
// scheduling parameters (queue sizes, reset thresholds, cache bounds),
// pluggable hooks (Authorizer, Discovery), the GOAWAY grace period,
// and SETUP-time SessionOptions.
type Config struct {
// GoawayTimeout is the grace period the relay grants downstream
// sessions to migrate after Stop sends GOAWAY before forcibly closing
// them. Zero means "do not send GOAWAY, just close" — useful in tests.
GoawayTimeout time.Duration
// SessionOptions are forwarded to session.Server() for every accepted
// connection. Use this to advertise implementation name, GREASE,
// MAX_AUTH_TOKEN_CACHE_SIZE, etc. Optional.
SessionOptions []session.Option
// Logger is used for relay-level events (accept loop start/stop,
// session setup failures, GOAWAY broadcast). If nil, slog.Default() is
// used. Per-session loggers are derived via Logger.With(...).
Logger *slog.Logger
// Authorizer gates every incoming request before the relay performs
// any state mutation. If nil, [AllowAllAuthorizer] is used, which is
// appropriate for tests and trusted in-process deployments.
// Production should supply a token- or session-attestation-aware
// implementation. See [Authorizer] for the full contract.
Authorizer Authorizer
// Metrics receives relay lifecycle and hot-path event notifications for
// telemetry. nil (the default) installs [NopMetrics]. See [Metrics] for
// the contract; implementations MUST be non-blocking and safe for
// concurrent use.
Metrics Metrics
// MaxFilterRanges is the MAX_FILTER_RANGES (§10.3.1.6) budget this relay
// advertises in SETUP: the largest total number of Range Filter ranges
// (§5.1.4) it will accept across every Range Filter parameter on one
// SUBSCRIBE or FETCH. Over-budget requests are answered INVALID_FILTER.
//
// Zero means: use [DefaultMaxFilterRanges]. A negative value advertises 0,
// which prohibits Range Filters outright — the session default, and the
// reason this field exists. The filters are implemented and enforced
// throughout, but [session.WithMaxFilterRanges] defaults to 0, so a relay
// that never sets it rejects every Range Filter it is sent. That silently
// disables SUBGROUP_FILTER, which is how a subscriber declines a track's
// upper temporal layers or fetches only its base layer — a request that
// looks supported, and is, until the SETUP budget refuses it.
MaxFilterRanges int
// MaxCacheSize bounds the per-track Object Cache by object count.
// Zero means: use [registry.DefaultCacheMaxSize]. The bound is applied
// independently to every track the relay observes; a noisy track
// cannot evict a quiet one's entries.
MaxCacheSize int
// MaxCacheDuration bounds the per-track Object Cache by object age.
// Zero means: use [registry.DefaultCacheMaxDuration]. Objects older than this
// are eligible for time-based eviction on the next Put.
MaxCacheDuration time.Duration
// CacheTTLPolicy, when non-nil, may override [MaxCacheDuration] on
// a per-track basis. See [CacheTTLPolicy] for the contract; the
// function is invoked once per [registry.TrackEntry] at creation time and
// never on the fanout hot path. Use this to give well-known tracks
// (e.g. an MSF catalog track) infinite retention without changing
// the default for everything else. [pkg/relay] does not own the
// rule; the binary supplies it.
CacheTTLPolicy CacheTTLPolicy
// SendQueueSize is the per-downstream-subscriber bounded channel
// size used by the fanout writer. Each subscriber's writer goroutine
// consumes from this queue; the fanout publishes to all queues with
// a non-blocking send and drops the object on overflow. A larger
// queue absorbs more transient burst but lets a slow reader keep
// more memory locked up.
// Zero means: use the default of 64.
SendQueueSize int
// MaxFanoutLag bounds how far behind the live edge a downstream
// subscriber may fall before the relay resets its outbound subgroup
// streams and terminates the subscription. The fanout writer measures
// the time each forwarded object spends queued before it is written; an
// object that waited longer than MaxFanoutLag means the subscriber has
// been unable to keep up for that long, so it is dropped. This is a
// latency window, not a drop count: a subscriber that loses the
// occasional object but stays current is left alone, while one that
// steadily falls behind is shed. Zero means: use the default of 2s.
MaxFanoutLag time.Duration
// MaxDropsBeforeReset is an OPTIONAL hard cap on the cumulative number of
// objects dropped to one subscriber's overflowing send queue, after which
// the relay resets and terminates the subscription. It is a coarse
// backstop to [MaxFanoutLag] (e.g. to bound memory for a peer that
// accepts a stream but never reads it); the time window is the primary
// slow-reader signal. Zero (the default) disables the cap.
MaxDropsBeforeReset int
// MaxSubscriptionsPerSession bounds the number of concurrently-active
// SUBSCRIBE requests a single session may hold (§13.1, subscription
// amplification). Excess SUBSCRIBEs are rejected with REQUEST_ERROR
// EXCESSIVE_LOAD before any state is mutated. Zero (the default) means
// unlimited — limits are a deployment policy the operator opts into.
MaxSubscriptionsPerSession int
// MaxNamespaceRequestsPerSession bounds the number of concurrently-active
// namespace-state requests (PUBLISH_NAMESPACE, SUBSCRIBE_NAMESPACE,
// SUBSCRIBE_TRACKS) a single session may hold (§13.7.1, relay state
// maintenance). Excess requests are rejected with REQUEST_ERROR
// EXCESSIVE_LOAD. Zero (the default) means unlimited.
MaxNamespaceRequestsPerSession int
// 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
// tests typically leave this nil; multi-instance deployments inject
// a [discovery.MemoryStore] (local-only) or a distributed backend
// (NATS / Redis).
Discovery discovery.DiscoveryStore
// RelayAddr is the address this relay registers itself as in
// Discovery entries. Empty for single-instance deployments
// (Discovery still works, RelayAddr just stays empty). NATS / Redis
// backends use this to route upstream connections to the right
// peer.
RelayAddr string
// Dialer establishes an outbound transport connection to another relay
// instance, given the RelayAddr that instance advertised in Discovery.
// It is the outbound counterpart of [Listener]: the relay stays
// transport-agnostic, so the caller owns TLS, ALPN ("moqt-20"), and — for
// WebTransport — the HTTP/3 CONNECT upgrade, returning a ready
// [session.Conn] on which the relay drives the MOQT SETUP handshake as a
// client.
//
// nil (the default) disables cross-relay dialing: the relay serves only
// from locally-connected publishers and never follows a Discovery
// [discovery.FindNamespace] result to a remote peer. Set this together
// with Discovery to enable on-demand cross-relay upstream SUBSCRIBE.
//
// The relay pools and reuses one session per RelayAddr; the Dialer is
// invoked at most once per address while a session to it is live, and
// again only after that session ends.
Dialer func(ctx context.Context, relayAddr string) (session.Conn, error)
// UpstreamFanIn optionally bounds how many remote relays a cross-relay
// upstream SUBSCRIBE fans into for one namespace. §9.5 requires a relay to
// subscribe to every publisher that advertised the namespace, so the zero
// value (the default) does exactly that — full fan-in, no data loss in any
// topology. Like the other limits on this struct, zero means "no limit," and
// bounding is a deployment policy the operator opts into.
//
// A positive N is that opt-in: the pool ranks the advertising relays by
// rendezvous (HRW) weight — a deterministic order every relay computes
// identically — and subscribes only to the top N that dial successfully.
// Because the ranking is identical fleet-wide, relays converge on the same
// few upstreams per namespace, collapsing the relay-to-relay stream count
// from a full O(n²) mesh toward a tree (N is the redundancy width: 1 is a
// pure tree, 2 keeps one backup upstream). This is a deliberate deviation
// from §9.5's "subscribe to all," sound only where the advertisers are
// redundant sources of the same objects — which the relay's fanout already
// dedups — never where different relays hold distinct objects for the track.
//
// Only meaningful when Dialer and Discovery are set; ignored otherwise.
UpstreamFanIn int
}
// resolved Config defaults; kept as constants so tests can reference them
// without poking at private fields.
const (
defaultSendQueueSize = 64
defaultMaxFanoutLag = 2 * time.Second
)
// DefaultMaxFilterRanges is the MAX_FILTER_RANGES (§10.3.1.6) budget a relay
// advertises when [Config.MaxFilterRanges] is left at zero.
//
// Sixteen ranges across all of a request's Range Filters. The uses this exists
// for are small — a SUBGROUP_FILTER naming one layer or a contiguous band of
// them is one range, an OBJECTID_FILTER picking a group's base-layer ID range
// is another — so sixteen is several such filters at once and still bounds the
// per-object matching work to something a fanout can afford. It is a budget
// against a peer asking for arbitrarily many bands, not a working limit.
const DefaultMaxFilterRanges = 16
// resolveMaxFilterRanges maps [Config.MaxFilterRanges] onto the value
// advertised in SETUP: zero takes the default, negative prohibits Range
// Filters, positive is taken as given.
func resolveMaxFilterRanges(configured int) uint64 {
switch {
case configured == 0:
return DefaultMaxFilterRanges
case configured < 0:
return 0
default:
return uint64(configured)
}
}
// Relay is a single MOQT relay instance. It owns one Listener, accepts
// session.Conn values from it, drives the MOQT SETUP handshake, and dispatches
// each established Session to a handler goroutine.
//
// A Relay is created with New and started with Start. Start blocks until the
// context is cancelled or Stop is called. Stop is safe to call concurrently
// with Start and may be invoked at most once meaningfully — subsequent calls
// are no-ops.
type Relay struct {
listener Listener
cfg Config
log *slog.Logger
// tracks and names are the relay-wide registries shared across every
// session handler.
tracks *registry.TrackRegistry
names *registry.NamespaceRegistry
// fetch rendezvouses upstream FETCH response streams (dispatched by the
// upstream session's data loop) with the downstream handler that issued
// the FETCH. Shared across every session handler.
fetch *registry.FetchRouter
// upstreams dials and pools relay-to-relay sessions for Discovery-driven
// cross-relay upstream SUBSCRIBE. nil when Config.Dialer is unset (the
// single-instance case); session handlers treat a nil pool as "no
// cross-relay routing available".
upstreams *upstreamPool
// watchWG tracks the optional Discovery WatchNamespaces consumer
// goroutine started in Start, so Stop joins it before returning.
watchWG sync.WaitGroup
// sessions tracks every Session that has completed SETUP and not yet
// been torn down. Stop iterates it under sessionsMu to broadcast GOAWAY
// and to wait for drain. shuttingDown is set (under sessionsMu, by
// beginShutdown) when Stop snapshots the set; addSession reads it under the
// same lock to decide whether a newly-registered session is a straggler
// Stop's snapshot missed.
sessionsMu sync.Mutex
sessions map[*session.Session]struct{}
shuttingDown bool
// stopOnce guards Stop so the second caller short-circuits. stopCh is
// closed by Stop to signal the accept loop to exit and to release any
// per-session handlers blocked waiting on shutdown.
stopOnce sync.Once
stopCh chan struct{}
// handlers tracks per-session handler goroutines so Stop can wait for
// them to finish before returning.
handlers sync.WaitGroup
}
// New constructs a Relay backed by listener and configured by cfg. listener
// must be non-nil; New panics otherwise, because a relay without a transport
// source is never useful and the misconfiguration would otherwise surface as
// a confusing nil-pointer panic deep inside Start.
func New(listener Listener, cfg Config) *Relay {
if listener == nil {
panic("relay.New: listener is required")
}
log := cfg.Logger
if log == nil {
log = slog.Default()
}
if cfg.Authorizer == nil {
cfg.Authorizer = AllowAllAuthorizer{}
}
if cfg.Metrics == nil {
cfg.Metrics = NopMetrics{}
}
if cfg.SendQueueSize <= 0 {
cfg.SendQueueSize = defaultSendQueueSize
}
if cfg.MaxFanoutLag <= 0 {
cfg.MaxFanoutLag = defaultMaxFanoutLag
}
// 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.
cfg.SessionOptions = append(
[]session.Option{session.WithMaxFilterRanges(resolveMaxFilterRanges(cfg.MaxFilterRanges))},
cfg.SessionOptions...,
)
// MaxDropsBeforeReset is an opt-in hard cap: 0 means "disabled", so no
// default is applied.
if cfg.MaxCacheSize <= 0 {
cfg.MaxCacheSize = registry.DefaultCacheMaxSize
}
if cfg.MaxCacheDuration <= 0 {
cfg.MaxCacheDuration = registry.DefaultCacheMaxDuration
}
trackOpts := []registry.TrackRegistryOption{
registry.WithCacheConfig(cfg.MaxCacheSize, cfg.MaxCacheDuration),
registry.WithTrackRegistryLogger(log),
}
if cfg.CacheTTLPolicy != nil {
trackOpts = append(trackOpts, registry.WithCacheTTLPolicy(registry.CacheTTLPolicy(cfg.CacheTTLPolicy)))
}
var nameOpts []registry.NamespaceRegistryOption
nameOpts = append(nameOpts, registry.WithNamespaceRegistryLogger(log))
if cfg.Discovery != nil {
trackOpts = append(trackOpts, registry.WithTrackDiscovery(cfg.Discovery, cfg.RelayAddr))
nameOpts = append(nameOpts, registry.WithNamespaceDiscovery(cfg.Discovery, cfg.RelayAddr))
}
r := &Relay{
listener: listener,
cfg: cfg,
log: log.With("component", "relay"),
tracks: registry.NewTrackRegistry(trackOpts...),
names: registry.NewNamespaceRegistry(nameOpts...),
fetch: registry.NewFetchRouter(),
sessions: make(map[*session.Session]struct{}),
stopCh: make(chan struct{}),
}
// When a Dialer is configured, the relay can follow Discovery
// FindNamespace results to a remote peer. The pool dials and reuses one
// session per RelayAddr and runs the relay's normal per-session loops on
// each dialled session (via serveSession) so its inbound data streams fan
// out and its FETCH responses route through the fetch router exactly as an
// accepted session's do.
if cfg.Dialer != nil {
// Cross-relay routing keys on RelayAddr: it identifies this instance in
// Discovery (so peers can dial it) and is the self-exclusion key that
// keeps the relay from dialing or reflecting its own advertisements. An
// empty RelayAddr breaks both — every Discovery entry looks unaddressable
// / "ours", so FindNamespace dials nothing and the namespace watcher
// reflects nothing. Warn loudly rather than fail silently.
if cfg.RelayAddr == "" {
r.log.Warn("relay: Config.Dialer is set but Config.RelayAddr is empty; " +
"cross-relay routing is disabled (Discovery entries are indistinguishable " +
"from this relay's own and the relay is unaddressable). Set a unique RelayAddr.")
}
r.upstreams = newUpstreamPool(upstreamPoolConfig{
dialer: cfg.Dialer,
discovery: cfg.Discovery,
relayAddr: cfg.RelayAddr,
sessionOpts: cfg.SessionOptions,
log: r.log,
metrics: cfg.Metrics,
serveSession: r.serveUpstreamSession,
fanIn: cfg.UpstreamFanIn,
})
}
return r
}
// serveUpstreamSession starts the relay's per-session loops on a dialled
// upstream session in a tracked goroutine and invokes onClose when the session
// ends. It mirrors the accept-path bookkeeping ([Relay.handleConn]): the
// handler goroutine is registered with r.handlers so [Relay.Stop] joins it.
// The Add happens synchronously (before the goroutine) so it cannot race
// Stop's handlers.Wait. Called only by the upstream pool.
func (r *Relay) serveUpstreamSession(sess *session.Session, onClose func()) {
r.handlers.Go(func() {
defer onClose()
r.serveSession(r.upstreams.baseCtx, sess, LegUpstream)
})
}
// Addr returns the address the underlying Listener is bound to. Convenience
// wrapper; useful for tests that need to dial back into the relay.
func (r *Relay) Addr() net.Addr { return r.listener.Addr() }
// Authorizer returns the authorization hook the relay is currently using.
// Guaranteed non-nil after [New]: a nil [Config.Authorizer] is replaced with
// [AllowAllAuthorizer]. Primarily useful for tests; production code injects
// its policy through Config.
func (r *Relay) Authorizer() Authorizer { return r.cfg.Authorizer }
// Run serves the relay until ctx is cancelled or the Listener fails fatally,
// then performs the full §10.4 graceful shutdown — GOAWAY, [Config.GoawayTimeout]
// grace period, force-close — and returns only once that drain has finished. It
// returns [Relay.Start]'s error. This is the entry point a binary driven by
// [os/signal.NotifyContext] wants; Start and Stop remain available for callers
// that need to drive the phases themselves.
//
// ctx is the shutdown *trigger*, not the lifetime of the sessions. Run
// deliberately does not hand ctx to Start, because Start propagates its ctx down
// to every per-session handler: a signal-cancelled ctx would tear the sessions
// down underneath the drain and their peers would never see the GOAWAY. Sessions
// run under an internal context that outlives ctx and is unwound by Stop.
//
// shutdownTimeout caps the GOAWAY grace period: once it elapses Stop stops
// waiting for peers to migrate and force-closes whatever is left, even if
// [Config.GoawayTimeout] has not elapsed. Zero lets the grace period run to
// Config.GoawayTimeout. It does not bound the join of in-flight handlers that
// follows, so a wedged handler can still delay the return.
func (r *Relay) Run(ctx context.Context, shutdownTimeout time.Duration) error {
// Trigger the drain on ctx without letting ctx reach the sessions.
// AfterFunc's stop is deferred so a signal arriving after Run has already
// returned cannot kick off a second drain.
stopTrigger := context.AfterFunc(ctx, func() { r.shutdown(shutdownTimeout) })
defer stopTrigger()
// WithoutCancel keeps ctx's values (logging scope, tracing) while dropping
// its cancellation. Nothing cancels this context: Start returns when Stop
// closes the listener, and the sessions it parents are unwound by Stop's
// force-close.
err := r.Start(context.WithoutCancel(ctx))
// Start returns as soon as Stop closes the listener, so the drain the
// trigger started is normally still in flight. Join it: returning here would let the
// caller exit the process mid-GOAWAY. Stop is idempotent and a second call
// blocks until the first completes, which also covers the path where Start
// failed on its own and no shutdown has begun yet.
r.shutdown(shutdownTimeout)
return err
}
// shutdown runs [Relay.Stop] under an optional timeout, logging a failure. The
// error is not propagated: it reports trouble closing the listener, which says
// nothing useful about why the relay is shutting down.
func (r *Relay) shutdown(timeout time.Duration) {
ctx := context.Background()
if timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, timeout)
defer cancel()
}
if err := r.Stop(ctx); err != nil {
r.log.LogAttrs(ctx, slog.LevelError, "relay stop failed", slog.String("err", err.Error()))
}
}
// Start runs the relay accept loop until ctx is cancelled, Stop is called, or
// the Listener returns a fatal error. The returned error reports the cause:
//
// - nil when shutdown was initiated cleanly via ctx cancellation or Stop.
// - the Listener's Accept error otherwise.
//
// ctx is also the parent context of every accepted session's handler loops, so
// cancelling it terminates live sessions immediately — without GOAWAY. Do not
// wire a signal context straight into Start; use [Relay.Run], which separates
// the shutdown trigger from the sessions' lifetime.
//
// Start is intended to be called exactly once per Relay. Calling it twice
// concurrently is undefined.
func (r *Relay) Start(ctx context.Context) error {
r.log.LogAttrs(ctx, slog.LevelInfo, "relay accept loop starting",
slog.Any("addr", r.listener.Addr()))
// Tie ctx to stopCh so a Stop call from another goroutine unblocks
// Accept the same way a cancelled context would.
acceptCtx, cancelAccept := context.WithCancel(ctx)
defer cancelAccept()
go func() {
select {
case <-r.stopCh:
cancelAccept()
case <-acceptCtx.Done():
}
}()
// Consume Discovery namespace events: forward namespaces advertised by
// *other* relays to this relay's local SUBSCRIBE_NAMESPACE holders, so a
// downstream subscriber learns about a namespace served elsewhere and can
// then SUBSCRIBE (which the on-demand cross-relay path resolves via
// FindNamespace). acceptCtx is cancelled by Stop (stopCh) or ctx, so the
// watcher unwinds with the accept loop. Skipped without Discovery.
if r.cfg.Discovery != nil {
r.watchWG.Go(func() {
r.runNamespaceWatch(acceptCtx)
})
}
for {
conn, err := r.listener.Accept(acceptCtx)
if err != nil {
// Shutdown paths look like context cancellation or
// net.ErrClosed; surface them as a clean nil so callers
// can distinguish "I asked to stop" from real listener
// failures.
if isShutdownErr(err) || acceptCtx.Err() != nil {
r.log.LogAttrs(ctx, slog.LevelInfo, "relay accept loop stopped")
return nil
}
r.log.LogAttrs(ctx, slog.LevelError, "relay listener accept failed",
slog.String("err", err.Error()))
return fmt.Errorf("relay: listener accept: %w", err)
}
r.handlers.Add(1)
go r.handleConn(ctx, conn)
}
}
// handleConn performs the MOQT SETUP handshake on conn and, on success, runs
// the per-session handler loops. SETUP failures close the underlying conn and
// log the cause; they do not propagate up to Start because one bad client
// must not take the relay down.
//
// This method owns the lifecycle: register the Session, run the
// per-session request / data / datagram loops, and unregister on exit.
func (r *Relay) handleConn(ctx context.Context, conn session.Conn) {
defer r.handlers.Done()
sess, err := session.Server(ctx, conn, r.cfg.SessionOptions...)
if err != nil {
r.log.LogAttrs(ctx, slog.LevelWarn, "relay SETUP failed",
slog.String("err", err.Error()))
// session.Server already closed conn on failure; nothing more
// to do here.
return
}
r.serveSession(ctx, sess, LegLocal)
}
// serveSession runs the per-session lifecycle for a Session that has already
// completed SETUP — whether accepted inbound by [Relay.handleConn] or dialled
// outbound by the [upstreamPool]. It registers the session, watches for Stop /
// GOAWAY, runs the per-session protocol loops, and sweeps the registries on
// exit. It blocks until the session ends.
//
// Both directions share this body so a dialled upstream relay session behaves
// identically to an accepted one: it lands in r.sessions (covered by Stop's
// GOAWAY/drain) and its handler fans out inbound data + routes FETCH responses.
// The only difference is the SETUP role (Server vs Client), handled by the
// caller before calling this — and leg, which records that difference for
// [Metrics] so cross-relay traffic is separable from client traffic.
func (r *Relay) serveSession(ctx context.Context, sess *session.Session, leg Leg) {
r.addSession(sess, leg)
defer func() {
r.removeSession(sess, leg)
// Belt-and-suspenders: per-request handlers unregister themselves on
// clean shutdown, but a handler that raced past Stop or wedged could
// leave dangling refs. Sweep both registries so the post-condition
// "serveSession returned ⇒ no registry entry references sess" holds.
r.tracks.RemoveSession(sess)
r.names.RemoveSession(sess)
}()
// Shutdown drain is owned elsewhere: Stop runs the GOAWAY / grace / close
// lifecycle for every session in the snapshot it takes under sessionsMu,
// and addSession runs it for any straggler that registered after that
// snapshot (see addSession). serveSession itself does not watch for Stop.
handler := newSessionHandler(
sess, r.log, r.tracks, r.names,
r.cfg.Authorizer, r.cfg.Metrics, leg, r.fetch, r.upstreams,
r.cfg.Discovery, r.cfg.RelayAddr,
r.cfg.SendQueueSize, r.cfg.MaxDropsBeforeReset, r.cfg.MaxFanoutLag,
r.cfg.MaxSubscriptionsPerSession, r.cfg.MaxNamespaceRequestsPerSession,
r.handlers.Go,
)
if err := handler.run(ctx); err != nil {
r.log.LogAttrs(ctx, slog.LevelDebug, "relay session handler returned",
slog.String("err", err.Error()))
}
}
// Stop initiates graceful shutdown.
// Stop is idempotent. Concurrent calls share the same shutdown sequence: only
// the first call performs work, and later calls block until it has finished, so
// a caller can use a second Stop to join a drain another goroutine started.
func (r *Relay) Stop(ctx context.Context) error {
var firstErr error
r.stopOnce.Do(func() {
r.log.LogAttrs(ctx, slog.LevelInfo, "relay stopping")
close(r.stopCh)
// 1. Withdraw from Discovery first, before anything else: a peer that
// resolves this relay via FindTrack / FindNamespace after step 2 has
// closed the listener would dial a dead endpoint. Doing it ahead of
// the listener close leaves only the harmless inverse window
// (unadvertised but still accepting). Withdraw leaves the store
// usable, so the rest of the drain can still resolve *other* relays.
// Bounded by discoveryWithdrawTimeout as well as ctx: Stop may be
// called with a deadline-free context, and an unreachable backend
// must not hold the listener close and every GOAWAY behind it.
if r.cfg.Discovery != nil {
wctx, cancelWithdraw := context.WithTimeout(ctx, discoveryWithdrawTimeout)
err := r.cfg.Discovery.Withdraw(wctx, r.cfg.RelayAddr)
cancelWithdraw()
if err != nil {
// Not promoted to firstErr: the advertisements also expire on
// their own once the backend's liveness TTL lapses, so a failed
// withdrawal delays peer convergence but does not fail shutdown.
r.log.LogAttrs(ctx, slog.LevelWarn, "relay discovery withdrawal failed",
slog.String("err", err.Error()))
} else {
r.log.LogAttrs(ctx, slog.LevelInfo, "relay withdrawn from discovery")
}
}
// 2. Close the listener; this unblocks the Accept loop. Block new
// upstream dials too — starting a cross-relay subscription while
// draining is pointless — but leave the established upstream sessions
// running: dropping them is "unsubscribing from upstream publishers",
// which §3.6 puts after the downstream GOAWAY (step 6). They are in
// the snapshot below, so they get GOAWAY'd / force-closed like
// accepted ones.
if err := r.listener.Close(); err != nil && !isShutdownErr(err) {
firstErr = fmt.Errorf("relay: listener close: %w", err)
}
if r.upstreams != nil {
r.upstreams.stopDialing()
}
// 3. Mark shutdown in progress and snapshot the session set in one
// atomic step, so we can iterate without holding the lock while
// doing potentially-blocking session work. The atomicity partitions
// sessions cleanly: every session is either in this snapshot (its
// drain is owned by steps 4–7 below) or registered later (it observes
// shuttingDown in addSession and owns its own drain) — never both.
sessions := r.beginShutdown()
// 4. Send GOAWAY to each session if a grace period is set. A
// zero timeout means "don't bother with GOAWAY"; close
// everything immediately. A relay-to-relay deployment may
// want to include a New Session URI here — extend
// SessionOptions or Config when that arrives.
if r.cfg.GoawayTimeout > 0 {
for _, sess := range sessions {
if err := sess.SendGoaway(r.cfg.GoawayTimeout, ""); err != nil {
// A session that has already sent GOAWAY
// or is closed is fine to skip.
r.log.LogAttrs(ctx, slog.LevelDebug, "relay GOAWAY send skipped",
slog.String("err", err.Error()))
}
}
}
// 5. Wait up to GoawayTimeout for sessions to drain. Whichever
// finishes first — drain or timeout — wins.
drained := make(chan struct{})
go func() {
for _, sess := range sessions {
<-sess.Done()
}
close(drained)
}()
select {
case <-drained:
case <-time.After(r.cfg.GoawayTimeout):
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")
}
// 6. Now that every downstream subscriber has had its GOAWAY and the
// grace period is over, unsubscribe from upstream publishers by
// cancelling the pool's base context. §3.6: "When the server is a
// subscriber, it SHOULD send a GOAWAY message to downstream
// subscribers prior to unsubscribing from upstream publishers."
if r.upstreams != nil {
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.
for _, sess := range sessions {
_ = sess.Close(moqt.SessionGoawayTimeout, "relay shutdown")
}
// 8. Wait for all handler goroutines to exit. This is
// important: returning while handlers are still running
// would race with anything the caller does next (e.g.
// closing a test's fake transport).
r.handlers.Wait()
// 9. Join the Discovery namespace watcher (if started). acceptCtx
// was cancelled via stopCh above, so it is already unwinding.
r.watchWG.Wait()
r.log.LogAttrs(ctx, slog.LevelInfo, "relay stopped")
})
return firstErr
}
func (r *Relay) addSession(s *session.Session, leg Leg) {
r.sessionsMu.Lock()
r.sessions[s] = struct{}{}
shuttingDown := r.shuttingDown
r.sessionsMu.Unlock()
r.cfg.Metrics.SessionOpened(leg)
// Straggler cover: if shutdown was already in progress when we registered,
// Stop's snapshot — taken under sessionsMu together with the shuttingDown
// flag (see beginShutdown) — does NOT include this session, so Stop will
// neither GOAWAY nor close it. Own that lifecycle here. When shutdown began
// after we registered, shuttingDown is false and Stop's snapshot covers us;
// exactly one owner either way. The drain runs under r.handlers so Stop's
// handlers.Wait joins it (safe: this runs inside serveSession, itself a
// tracked handler, so the WaitGroup counter is already non-zero).
if shuttingDown {
r.handlers.Go(func() { r.drainStraggler(s) })
}
}
// drainStraggler runs the GOAWAY grace + force-close lifecycle for a single
// session that registered after Stop snapshotted the live-session set, so
// Stop's bulk drain (Stop steps 4–7) does not cover it. It mirrors that bulk
// 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) {
if r.cfg.GoawayTimeout > 0 {
_ = s.SendGoaway(r.cfg.GoawayTimeout, "")
timer := time.NewTimer(r.cfg.GoawayTimeout)
defer timer.Stop()
select {
case <-timer.C:
case <-s.Done():
return // peer drained within the grace period
}
}
_ = s.Close(moqt.SessionGoawayTimeout, "relay shutdown")
}
func (r *Relay) removeSession(s *session.Session, leg Leg) {
r.sessionsMu.Lock()
delete(r.sessions, s)
r.sessionsMu.Unlock()
r.cfg.Metrics.SessionClosed(leg)
}
// beginShutdown marks the relay as shutting down and returns a snapshot of the
// currently-registered sessions, atomically under sessionsMu. The atomicity is
// what lets addSession partition sessions into exactly two non-overlapping
// groups: those in the returned snapshot (drained by Stop) and those registered
// afterward (which see shuttingDown and drain themselves via drainStraggler).
func (r *Relay) beginShutdown() []*session.Session {
r.sessionsMu.Lock()
defer r.sessionsMu.Unlock()
r.shuttingDown = true
out := make([]*session.Session, 0, len(r.sessions))
for s := range r.sessions {
out = append(out, s)
}
return out
}
// isShutdownErr reports whether err is one of the "the world is going away"
// signals that should be treated as a clean shutdown rather than a failure:
// net.ErrClosed, context.Canceled, or context.DeadlineExceeded (transports
// surface one of these when the conn/listener is closed under a loop).
func isShutdownErr(err error) bool {
if err == nil {
return false
}
if errors.Is(err, net.ErrClosed) {
return true
}
if errors.Is(err, context.Canceled) {
return true
}
if errors.Is(err, context.DeadlineExceeded) {
return true
}
return false
}
package relay
import (
"context"
"log/slog"
"github.com/floatdrop/moq-go/pkg/relay/discovery"
)
// runNamespaceWatch consumes [discovery.DiscoveryStore.WatchNamespaces] and
// forwards namespaces advertised by *other* relays to this relay's local
// SUBSCRIBE_NAMESPACE holders. It is the consume-side mirror of the advertise
// side in [registry.NamespaceRegistry]: that publishes local PUBLISH_NAMESPACE into the
// store; this reflects remote advertisements back out as NAMESPACE /
// NAMESPACE_DONE so a downstream subscriber discovers namespaces served
// elsewhere in the deployment — and can then SUBSCRIBE, which the on-demand
// cross-relay path resolves via FindNamespace.
//
// It runs as a single relay-level goroutine started in [Relay.Start] (only when
// Discovery is configured) and returns when ctx is cancelled or the store
// closes its watch channel.
//
// The watch yields an initial snapshot before following live changes (see
// [discovery.DiscoveryStore.WatchNamespaces]), so this goroutine observes
// namespaces advertised before it started, not just later ones. The remaining
// limitation is downstream of here: it starts once in [Relay.Start] and
// reflects each event only to the SUBSCRIBE_NAMESPACE holders registered at the
// moment it arrives, so a subscriber that registers later is not back-filled
// with already-advertised namespaces. That subscriber still discovers them on
// demand — its SUBSCRIBE resolves via FindNamespace — so this is a
// reflection-latency gap, not a correctness one.
func (r *Relay) runNamespaceWatch(ctx context.Context) {
ch, err := r.cfg.Discovery.WatchNamespaces(ctx)
if err != nil {
r.log.LogAttrs(ctx, slog.LevelWarn, "discovery: WatchNamespaces failed",
slog.String("err", err.Error()))
return
}
r.log.LogAttrs(ctx, slog.LevelDebug, "discovery namespace watch started")
for {
select {
case <-ctx.Done():
return
case ev, ok := <-ch:
if !ok {
return
}
r.forwardNamespaceEvent(ctx, ev)
}
}
}
// forwardNamespaceEvent reflects one remote namespace event to local
// SUBSCRIBE_NAMESPACE holders whose prefix matches.
//
// Own-relay events are skipped: [sessionHandler.handlePublishNamespace] already
// forwards a local PUBLISH_NAMESPACE to matching subscribers, so re-forwarding
// the same advertisement from the watch would duplicate the NAMESPACE message.
// SUBSCRIBE_TRACKS holders (WantsTracks) are skipped too — they receive
// forwarded PUBLISH messages, not NAMESPACE, and a relay cannot synthesize a
// remote PUBLISH from a namespace advertisement alone.
func (r *Relay) forwardNamespaceEvent(ctx context.Context, ev discovery.NamespaceEvent) {
if ev.Info.RelayAddr == r.cfg.RelayAddr {
return // our own advertisement — already forwarded locally
}
ns := ev.Info.Prefix
for _, sub := range r.names.MatchSubscribers(ns) {
if sub.WantsTracks {
continue
}
// Reuse the same suffix-stripping helpers handlePublishNamespace uses
// so the wire form is identical whether the namespace is local or
// remote (§10.17 NAMESPACE and §10.18 NAMESPACE_DONE both carry only the
// bytes beyond the subscriber prefix).
var err error
switch ev.Op {
case discovery.OpPublish:
err = sub.WriteMessage(namespaceMessageFor(ns, sub.Prefix))
case discovery.OpUnpublish:
err = sub.WriteMessage(namespaceDoneMessageFor(ns, sub.Prefix))
default:
continue
}
if err != nil {
r.log.LogAttrs(ctx, slog.LevelDebug, "discovery NAMESPACE forward failed",
slog.String("op", ev.Op.String()),
slog.String("err", err.Error()))
}
}
}
package relay
import (
"cmp"
"context"
"errors"
"fmt"
"hash/fnv"
"log/slog"
"slices"
"sync"
"time"
"github.com/floatdrop/moq-go/pkg/moqt/session"
"github.com/floatdrop/moq-go/pkg/moqt/wire"
"github.com/floatdrop/moq-go/pkg/relay/discovery"
)
// upstreamDialTimeout bounds a single relay-to-relay dial + MOQT SETUP. A hung
// dial must not pin a pool entry forever (other callers wait on it), so the
// dial context is cancelled after this regardless of the pool's lifetime.
const upstreamDialTimeout = 10 * time.Second
// upstreamPool dials and reuses relay-to-relay sessions, keyed by the RelayAddr
// a peer advertised in [discovery.DiscoveryStore]. It is the consume-side
// counterpart of the advertise-side Discovery wiring in the registries: when a
// downstream SUBSCRIBE has no local publisher, the SUBSCRIBE handler asks the
// pool to resolve a remote relay (via FindNamespace) and hand back a live
// session to issue an upstream SUBSCRIBE on.
//
// One session is kept per RelayAddr. Concurrent resolves for the same address
// dial once and share the result (the in-flight entry is published before the
// dial, so later callers block on its ready channel rather than racing a
// second dial). A session is evicted when its per-session handler loop returns,
// so the next resolve re-dials.
type upstreamPool struct {
dialer func(ctx context.Context, relayAddr string) (session.Conn, error)
discovery discovery.DiscoveryStore
relayAddr string
sessionOpts []session.Option
log *slog.Logger
metrics Metrics
baseCtx context.Context
cancelBase context.CancelFunc
serveSession func(sess *session.Session, onClose func())
// fanIn optionally caps how many rendezvous-ranked upstreams
// resolveUpstreams subscribes to per namespace (Config.UpstreamFanIn).
// Zero (or negative) means unbounded — fan in to every advertiser, the
// §9.5 default.
fanIn int
mu sync.Mutex
entries map[string]*poolEntry
// noDial is set by stopDialing once shutdown begins: existing upstream
// sessions keep running, but no new one is established.
noDial bool
}
// poolEntry is the per-RelayAddr slot. ready is closed once sess/err are set,
// so concurrent callers that found an in-flight entry block on it instead of
// dialing again.
type poolEntry struct {
ready chan struct{}
sess *session.Session
err error
}
// upstreamPoolConfig carries the pool's dependencies from [New].
type upstreamPoolConfig struct {
dialer func(ctx context.Context, relayAddr string) (session.Conn, error)
discovery discovery.DiscoveryStore
relayAddr string
sessionOpts []session.Option
log *slog.Logger
metrics Metrics
serveSession func(sess *session.Session, onClose func())
// fanIn is Config.UpstreamFanIn verbatim; zero (the default) means
// unbounded fan-in.
fanIn int
}
func newUpstreamPool(cfg upstreamPoolConfig) *upstreamPool {
// The pool's base context spans its whole lifetime: dialled sessions and
// their handler loops run under it, and close() cancels it from Relay.Stop.
base, cancel := context.WithCancel(context.Background())
// [New] already defaults Config.Metrics, but the pool is also constructed
// directly (tests), and resolveUpstreams calls into this on a path that
// only runs once a cross-relay subscribe happens — a nil here would be a
// panic nothing local reproduces.
if cfg.metrics == nil {
cfg.metrics = NopMetrics{}
}
return &upstreamPool{
dialer: cfg.dialer,
discovery: cfg.discovery,
relayAddr: cfg.relayAddr,
sessionOpts: cfg.sessionOpts,
log: cfg.log,
metrics: cfg.metrics,
baseCtx: base,
cancelBase: cancel,
serveSession: cfg.serveSession,
fanIn: cfg.fanIn,
entries: make(map[string]*poolEntry),
}
}
// resolveUpstreams finds the remote relays that serve ns and returns a live
// session to each, ranked by rendezvous (HRW) weight. The ranking is a
// deterministic function of (ns, candidate addresses) alone, so every relay
// sharing the same Discovery view computes the same order. On its own that only
// fixes the dial order; with a positive fanIn (see below) it also bounds how
// many upstreams are taken, so relays converge on the same small set and the
// relay-to-relay stream count stays bounded instead of trending toward a full
// O(n²) mesh — a tree rooted at ns's highest-weighted relays.
//
// §9.5 requires subscribing to every matching publisher (the fanout then dedups
// the redundant copies they push), so fanIn == 0 (the default) fans into all of
// them. A positive fanIn (Config.UpstreamFanIn) is an opt-in deviation: it
// bounds the subscription to the top fanIn ranked upstreams — 1 is a pure tree,
// 2 keeps one backup — trading the §9.5 fan-in for a bounded relay mesh. That is
// only sound where the advertisers are redundant sources of the same objects,
// never where different relays hold distinct objects for the track. Candidates
// are dialled in rank order and, when bounded, the first fanIn that connect are
// returned; a dead top-ranked relay still lingering in Discovery during its
// lease TTL falls through transparently to the next-ranked one.
//
// Returns nil when Discovery knows no usable remote (none advertised, only this
// relay itself, or every candidate failed to dial). Discovery-lookup and
// per-peer dial failures are logged and treated as "skip that candidate" —
// consistent with the best-effort advertise side: the local registry / a clean
// SUBSCRIBE rejection is the fallback, never a torn-down session.
//
// Loop prevention is minimal: candidates whose RelayAddr equals this relay's
// own (or is empty / unaddressable) are skipped so the relay never subscribes
// to itself. Duplicate RelayAddrs collapse to one session (the pool keys by
// address). Multi-hop cycle detection (A→B→C→A) is out of scope — see the
// package limitations.
func (p *upstreamPool) resolveUpstreams(ctx context.Context, ns wire.TrackNamespace) []*session.Session {
if p == nil || p.discovery == nil {
return nil
}
infos, err := p.discovery.FindNamespace(ctx, ns)
if err != nil {
// A Discovery lookup failure is transient (etcd RPC timeout, leader
// election) and collapses to the same nil return as a genuinely empty
// result — a caller cannot tell "the fabric hiccupped" from "no relay
// serves this namespace". Log it at Warn so that distinction survives to
// production, where the default level hides Debug.
p.log.LogAttrs(ctx, slog.LevelWarn, "upstream pool: FindNamespace failed",
slog.String("namespace", fmt.Sprintf("%v", ns)),
slog.String("err", err.Error()))
return nil
}
p.log.LogAttrs(ctx, slog.LevelInfo, "upstream pool: FindNamespace resolved",
slog.String("namespace", fmt.Sprintf("%v", ns)),
slog.Int("advertisers_found", len(infos)))
p.metrics.NamespaceResolved(len(infos))
// Rank so the dial order is identical fleet-wide; a positive fanIn then
// takes the same top-fanIn upstreams everywhere.
rankByAffinity(ns, infos)
var (
out []*session.Session
seen = make(map[string]bool, len(infos))
)
for _, info := range infos {
if info.RelayAddr == "" || info.RelayAddr == p.relayAddr || seen[info.RelayAddr] {
continue // self / unaddressable / already dialled this address
}
seen[info.RelayAddr] = true
sess, err := p.get(info.RelayAddr)
if err != nil {
p.log.LogAttrs(ctx, slog.LevelDebug, "upstream pool dial failed",
slog.String("relay_addr", info.RelayAddr),
slog.String("err", err.Error()))
p.metrics.UpstreamDialFailed(info.RelayAddr)
continue // fall through to the next-ranked relay
}
out = append(out, sess)
if p.fanIn > 0 && len(out) >= p.fanIn {
break // opt-in bound reached; deeper candidates are the fallback pool
}
}
return out
}
// rankByAffinity sorts infos in place by descending rendezvous (HRW) weight for
// ns. The weight hashes (ns, RelayAddr), so the order depends only on the
// namespace and the candidate set — every relay in the fleet derives the same
// order and, taking the top few, converges on the same upstreams. RelayAddr
// breaks weight ties, keeping the order total (and identical everywhere) even
// on the rare hash collision.
func rankByAffinity(ns wire.TrackNamespace, infos []discovery.NamespaceInfo) {
nsKey := namespaceAffinityKey(ns)
slices.SortFunc(infos, func(a, b discovery.NamespaceInfo) int {
if c := cmp.Compare(hrwWeight(nsKey, b.RelayAddr), hrwWeight(nsKey, a.RelayAddr)); c != 0 {
return c
}
return cmp.Compare(a.RelayAddr, b.RelayAddr)
})
}
// hrwWeight is the highest-random-weight score for placing ns on the relay at
// addr: FNV-1a over the namespace's canonical bytes followed by the address.
// nsKey is canonical and self-delimiting (a field count then length-prefixed
// fields), so the concatenation is injective in (ns, addr) and needs no
// separator.
func hrwWeight(nsKey []byte, addr string) uint64 {
// hash.Hash.Write never returns an error (documented on the interface); the
// blank assignments are just to satisfy the errcheck/gosec linters.
h := fnv.New64a()
_, _ = h.Write(nsKey)
_, _ = h.Write([]byte(addr))
return h.Sum64()
}
// namespaceAffinityKey is the canonical §2.4.1 wire encoding of ns, used as the
// stable per-namespace seed for hrwWeight. Reusing the wire encoding (the same
// bytes a DiscoveryStore backend keys namespaces by) keeps nested tuples
// unambiguous.
func namespaceAffinityKey(ns wire.TrackNamespace) []byte {
w := wire.NewWriter(nil)
w.TrackNamespace(ns)
return w.Bytes()
}
// get returns a pooled session for relayAddr, dialing one if none is live.
// Concurrent calls for the same address dial once and share the outcome.
func (p *upstreamPool) get(relayAddr string) (*session.Session, error) {
p.mu.Lock()
if e, ok := p.entries[relayAddr]; ok {
p.mu.Unlock()
<-e.ready
if e.err != nil {
return nil, e.err
}
// Reuse only if the session is still live; otherwise drop the stale
// entry and dial afresh. The eviction goroutine also clears dead
// entries, but a caller can race ahead of it.
select {
case <-e.sess.Done():
p.mu.Lock()
if p.entries[relayAddr] == e {
delete(p.entries, relayAddr)
}
p.mu.Unlock()
return p.get(relayAddr)
default:
return e.sess, nil
}
}
if p.noDial {
p.mu.Unlock()
// Callers treat this like any other failed upstream resolution and skip
// the candidate.
return nil, errors.New("relay: upstream dialing stopped for shutdown")
}
// Publish an in-flight entry before dialing so concurrent callers wait on
// it rather than starting a second dial.
e := &poolEntry{ready: make(chan struct{})}
p.entries[relayAddr] = e
p.mu.Unlock()
sess, err := p.dial(relayAddr)
e.sess, e.err = sess, err
close(e.ready)
if err != nil {
// Failed dial: drop the entry so a later resolve retries.
p.mu.Lock()
if p.entries[relayAddr] == e {
delete(p.entries, relayAddr)
}
p.mu.Unlock()
return nil, err
}
// Run the relay's per-session loops on the dialled session and evict the
// entry when that handler returns (session ended).
p.serveSession(sess, func() {
p.mu.Lock()
if p.entries[relayAddr] == e {
delete(p.entries, relayAddr)
}
p.mu.Unlock()
})
return sess, nil
}
// dial performs one outbound dial + client-side MOQT SETUP, bounded by
// upstreamDialTimeout. It uses the pool's base context (not a caller's request
// context) so the resulting session outlives the SUBSCRIBE that triggered it.
func (p *upstreamPool) dial(relayAddr string) (*session.Session, error) {
dialCtx, cancel := context.WithTimeout(p.baseCtx, upstreamDialTimeout)
defer cancel()
conn, err := p.dialer(dialCtx, relayAddr)
if err != nil {
return nil, err
}
sess, err := session.Client(dialCtx, conn, p.sessionOpts...)
if err != nil {
return nil, err
}
return sess, nil
}
// stopDialing blocks any further upstream dial, leaving sessions already
// established running. [Relay.Stop] calls it as shutdown begins: starting a new
// cross-relay subscription while draining is pointless, but tearing the live ones
// down is "unsubscribing from upstream publishers", which §3.6 says SHOULD happen
// only after the downstream GOAWAY has gone out — so that half is [upstreamPool.close].
func (p *upstreamPool) stopDialing() {
p.mu.Lock()
p.noDial = true
p.mu.Unlock()
}
// close cancels the pool's base context, unwinding any in-flight dial and the
// handler loops of dialled sessions. This is the "unsubscribe from upstream"
// step, so [Relay.Stop] calls it only after the GOAWAY broadcast and drain
// (§3.6).
func (p *upstreamPool) close() {
p.cancelBase()
}
package relaynet
import (
"context"
"crypto/tls"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"slices"
"sync"
"github.com/quic-go/quic-go"
"github.com/quic-go/quic-go/http3"
"github.com/quic-go/webtransport-go"
"github.com/floatdrop/moq-go/pkg/moqt"
"github.com/floatdrop/moq-go/pkg/moqt/session"
"github.com/floatdrop/moq-go/pkg/moqt/session/quicconn"
"github.com/floatdrop/moq-go/pkg/moqt/session/wtconn"
)
// DualALPNs lists the ALPNs of both MOQT transport mappings, for a listener that
// serves them on one socket — see [Listen]. A TLS config built with these accepts
// a raw-QUIC client offering "moqt-NN" and an HTTP/3 client offering "h3"; each
// connection's negotiated ALPN then says which mapping it is.
var DualALPNs = slices.Concat(MOQTQUICALPNs, WebTransportALPNs)
// dualBacklog bounds the queue of accepted-but-not-yet-Accepted connections. Both
// halves feed it, and the relay drains it promptly; the bound only matters for a
// burst arriving faster than the accept loop consumes.
const dualBacklog = 16
// Listen serves both MOQT transport mappings on a single UDP socket: raw QUIC for
// peers and native clients that dial a moqt URI, and WebTransport (HTTP/3) at
// wtPath for anything dialing the https form of the same URI (§3.1.3, §3.1.4) —
// browsers included. tlsCfg must advertise [DualALPNs].
//
// This is what a relay behind a load balancer wants, and it is why no transport
// flag is needed: the two mappings differ only in ALPN, so one listener can offer
// both and decide per connection. Clients choose by URL scheme, peer relays keep
// dialing raw QUIC, and nothing has to agree deployment-wide.
//
// The returned listener owns the socket; Close releases it along with both halves.
// A connection whose ALPN is not "h3" is treated as raw QUIC: the ALPN set the
// handshake selected from is tlsCfg's, so nothing else can get that far.
//
// CheckOrigin accepts every origin, as [ListenWebTransport] does — see the
// package doc. Serving both mappings means a relay is reachable from a browser by
// default, so a deployment that cares about which pages may open sessions needs
// its own policy here.
//
// opts tune the QUIC config this listener serves on, independently of whatever a
// cross-relay [DialQUIC] uses — see [WithQUICConfig].
func Listen(addr, wtPath string, tlsCfg *tls.Config, logger *slog.Logger, opts ...Option) (*DualListener, error) {
if logger == nil {
logger = slog.Default()
}
if wtPath == "" {
// ServeMux panics on an empty pattern; fail at startup with a message
// instead of taking the process down inside NewListener.
return nil, fmt.Errorf("relaynet: empty WebTransport path (use %q for the default)", "/moq")
}
qcfg := quicConfig(opts)
// Neither of these is the caller's to switch off here, whatever WithQUICConfig
// asked for: webtransport.Server.ServeQUICConn checks them one at a time and
// refuses the connection on the first one missing, so a listener lacking
// either would silently serve only half of what it advertises. MOQT can use
// both (§11.3, §11.4.3), but neither is what forces the hand here.
qcfg.EnableDatagrams = true
qcfg.EnableStreamResetPartialDelivery = true
// Not ListenEarly: an early listener yields connections before the handshake
// completes, which would break this listener's contract that ALPN is already
// negotiated (the dispatch below reads it) and would have the relay write
// SETUP as 0.5-RTT data to a peer whose certificate is unverified. 0-RTT would
// need Config.Allow0RTT, which defaultQUICConfig deliberately leaves unset.
ql, err := quic.ListenAddr(addr, tlsCfg, qcfg)
if err != nil {
return nil, fmt.Errorf("relaynet: listen %s: %w", addr, err)
}
mux := http.NewServeMux()
if wtPath != "/" {
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
logger.WarnContext(r.Context(), "webtransport: unmatched request",
"method", r.Method, "host", r.Host, "path", r.URL.Path,
"proto", r.Proto, "upgrade", r.Header.Get(":protocol"))
http.NotFound(w, r)
})
}
// This half is what the two re-asserted fields above are for; see there.
h3 := &http3.Server{TLSConfig: tlsCfg, Handler: mux}
webtransport.ConfigureHTTP3Server(h3)
wts := &webtransport.Server{
H3: h3,
// The WebTransport sub-protocol, not the TLS ALPN, carries the draft
// version for this mapping (§3.1) — same identifiers as raw QUIC so the
// two version signals share one source.
ApplicationProtocols: MOQTQUICALPNs,
CheckOrigin: func(*http.Request) bool { return true },
}
ctx, cancel := context.WithCancel(context.Background())
l := &DualListener{
ql: ql,
wts: wts,
conns: make(chan session.Conn, dualBacklog),
ctx: ctx,
cancel: cancel,
log: logger,
}
l.wt = wtconn.NewListener(wts, mux, wtPath, ql.Addr(), dualBacklog)
go l.acceptQUIC()
go l.pumpWebTransport()
return l, nil
}
// DualListener is the [Listen] listener: one QUIC socket whose connections are
// split by negotiated ALPN into the raw-QUIC and WebTransport halves, then merged
// into one Accept queue so the relay cannot tell them apart.
type DualListener struct {
ql *quic.Listener
wt *wtconn.Listener
wts *webtransport.Server
conns chan session.Conn
ctx context.Context
cancel context.CancelFunc
log *slog.Logger
closeOnce sync.Once
}
// acceptQUIC is the demultiplexer: HTTP/3 connections go to the WebTransport
// server, whose upgrade handler feeds the wtconn listener that pumpWebTransport
// drains; everything else is a raw-QUIC MOQT connection and is queued directly.
func (l *DualListener) acceptQUIC() {
for {
conn, err := l.ql.Accept(l.ctx)
if err != nil {
return // listener closed
}
if conn.ConnectionState().TLS.NegotiatedProtocol == http3.NextProtoH3 {
go func() {
// Returns when the HTTP/3 connection ends, which is routine.
if err := l.wts.ServeQUICConn(conn); err != nil && l.ctx.Err() == nil {
l.log.Debug("relaynet: http/3 connection ended", "err", err.Error())
}
}()
continue
}
// The mapping is not recorded on the conn, but it stays recoverable by
// type — quicconn and wtconn produce distinct implementations — which is
// what a future §10.3.1.1/§10.3.1.2 check would need (PATH and AUTHORITY
// MUST NOT be used over WebTransport).
l.deliver(quicconn.New(conn))
}
}
// pumpWebTransport moves upgraded WebTransport sessions onto the shared queue, so
// Accept has a single source regardless of transport.
func (l *DualListener) pumpWebTransport() {
for {
conn, err := l.wt.Accept(l.ctx)
if err != nil {
return // Close, or the wtconn listener shut down
}
l.deliver(conn)
}
}
// deliver queues conn, or closes it if the listener is shutting down — a conn
// nobody will Accept must not be left believing it has a session.
func (l *DualListener) deliver(conn session.Conn) {
select {
case l.conns <- conn:
case <-l.ctx.Done():
_ = conn.CloseWithError(uint64(moqt.SessionNoError), "listener closed")
}
}
// Accept returns the next connection from either transport. It satisfies the
// relay's Listener interface.
func (l *DualListener) Accept(ctx context.Context) (session.Conn, error) {
select {
case conn := <-l.conns:
return conn, nil
case <-ctx.Done():
return nil, ctx.Err()
case <-l.ctx.Done():
return nil, net.ErrClosed
}
}
// Addr returns the UDP address both transports are served on.
func (l *DualListener) Addr() net.Addr { return l.ql.Addr() }
// Close stops accepting new connections. Connections already accepted keep
// working, and the UDP socket stays open until the last of them ends — quic-go
// releases it once its transport has no connections left.
//
// That is load-bearing, not incidental: [relay.Relay.Stop] closes the listener as
// an early step and only then broadcasts GOAWAY and waits out the grace period
// (§10.4, §3.6). A Close that dropped the socket would kill every draining
// session with it, and no peer would ever see its GOAWAY.
//
// Close is idempotent and joins the failures of every step.
func (l *DualListener) Close() error {
var err error
l.closeOnce.Do(func() {
l.cancel()
err = errors.Join(l.wt.Close(), l.wts.Close(), l.ql.Close())
})
return err
}
package relaynet
import "github.com/quic-go/quic-go"
// Option customises the QUIC plumbing that [Listen], [DialQUIC] and
// [DialWebTransport] build. Every entry point takes them variadically, so a
// caller can tune each leg of a relay independently — the downstream listener
// and a cross-relay upstream dial do not have to agree.
type Option func(*quic.Config)
// WithQUICConfig returns an [Option] that runs tune over the [quic.Config] the
// entry point is about to use, once the relay's defaults have been populated.
// Passing it more than once applies the hooks in order.
//
// The hook mutates those defaults rather than replacing them, so a caller
// changing one knob keeps tracking every other default this package sets, and
// gains any it adds later. That is what makes it usable for the knob it exists
// for: quic.Config fields that only a patched or forked quic-go defines — a
// pluggable congestion controller, say — can be set here without this package
// ever naming them, and without the caller having to restate the settings MOQT
// needs around them.
//
// Two of those settings are not the caller's to turn off on a [Listen]: the
// dual listener's WebTransport half refuses a connection missing either DATAGRAM
// or stream-reset partial delivery, so Listen re-asserts both after the hooks
// run. Neither dial entry point does. [DialWebTransport] does not need to —
// webtransport-go refuses the same omission up front, before any packet is sent.
// [DialQUIC] has no such guard and none is added here: the config goes straight
// to quic-go, and a caller who disables DATAGRAM on a cross-relay leg owns the
// consequence, which is that objects this relay would have sent as datagrams
// (§11.3) stop crossing that hop with only a Debug line to show for it.
func WithQUICConfig(tune func(*quic.Config)) Option { return Option(tune) }
// quicConfig applies opts to a fresh copy of the relay's default QUIC tuning.
func quicConfig(opts []Option) *quic.Config {
cfg := defaultQUICConfig()
for _, opt := range opts {
opt(cfg)
}
return cfg
}
// Package relaynet holds the QUIC + TLS plumbing shared by the relay command
// binaries: self-signed dev certificates, the relay's QUIC tuning, and the
// listener/dialer constructors that bridge quic-go to the transport-agnostic
// [session.Conn] the relay operates on.
//
// It exists so more than one relay binary shares one copy of this setup rather
// than each carrying its own: cmd/relay here, and the out-of-tree relay built
// on a distributed [relay.DiscoveryStore], which imports this package. The helpers here are aimed at local
// development and single-operator deployments — [SelfSignedCert],
// [InsecureClientTLSConfig] and [Listen] are explicitly not for production: the
// first two skip real trust, and the third accepts every browser Origin.
package relaynet
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"log/slog"
"math/big"
"net"
"time"
"github.com/quic-go/quic-go"
"github.com/floatdrop/moq-go/pkg/moqt/session"
"github.com/floatdrop/moq-go/pkg/moqt/session/quicconn"
)
// MOQTQUICALPNs lists the raw-QUIC MOQT ALPNs the relay accepts. Draft-19
// SETUP carries no version field (§3.1), so the "moqt-NN" ALPN is itself the
// draft-version signal — the negotiated ALPN fixes the draft. We advertise
// only "moqt-20", the draft this implementation speaks. The older
// "moqt-18"/"-17"/"-16" and the pre-15 "moq-00" (which expected in-SETUP
// version negotiation, removed in -19) are deliberately not offered: our -19
// wire behavior can't complete a SETUP with a peer that selected any of them,
// so advertising them would only let such a peer clear TLS and then fail.
var MOQTQUICALPNs = []string{"moqt-20"}
// defaultQUICConfig returns the QUIC tuning the relay listens and dials with:
// a 30s idle timeout with 5s keep-alives, datagrams enabled (MOQT may deliver
// objects as QUIC datagrams), and RESET_STREAM_AT partial delivery (§11.4.3).
//
// It is the base every entry point starts from; [WithQUICConfig] is how a caller
// adjusts it per leg.
func defaultQUICConfig() *quic.Config {
return &quic.Config{
MaxIdleTimeout: 30 * time.Second,
KeepAlivePeriod: 5 * time.Second,
EnableDatagrams: true,
EnableStreamResetPartialDelivery: true,
}
}
// TLSConfig returns a server TLS config for the chosen MOQT transport. If
// certFile and keyFile are both non-empty the pair is loaded from disk;
// otherwise an ephemeral self-signed certificate is generated in memory (see
// [SelfSignedCert]). alpns lists the acceptable ALPNs in server-preference
// order.
func TLSConfig(certFile, keyFile string, alpns []string) (*tls.Config, error) {
var (
cert tls.Certificate
err error
)
if certFile != "" && keyFile != "" {
cert, err = tls.LoadX509KeyPair(certFile, keyFile)
} else {
slog.Default().Info("relaynet: no cert/key supplied; generating ephemeral self-signed certificate")
cert, err = SelfSignedCert()
}
if err != nil {
return nil, fmt.Errorf("tls: %w", err)
}
return &tls.Config{
Certificates: []tls.Certificate{cert},
NextProtos: alpns,
}, nil
}
// InsecureClientTLSConfig returns a client TLS config that offers alpns and
// SKIPS certificate verification. It exists for relay-to-relay dialing in
// development, where peers present self-signed certs; production deployments
// MUST supply a config with a real trust store instead.
func InsecureClientTLSConfig(alpns []string) *tls.Config {
return &tls.Config{
InsecureSkipVerify: true, //nolint:gosec // dev-only cross-relay dialing against self-signed peers; documented on the func.
NextProtos: alpns,
}
}
// DialQUIC dials addr over raw QUIC with the relay's default QUIC tuning and
// returns the established connection as a [session.Conn], ready for the relay to
// drive the client-side MOQT SETUP on. It is the shape a relay Dialer expects.
//
// [quicconn.Dial] owns the address handling, including the multi-address,
// RFC 6724-ordered resolution that keeps a dual-stack peer named by hostname
// from being dialed over the wrong family — see its doc comment.
//
// opts tune the QUIC config this leg dials with, leaving the listener and any
// other dial untouched — see [WithQUICConfig].
func DialQUIC(ctx context.Context, addr string, tlsCfg *tls.Config, opts ...Option) (session.Conn, error) {
return quicconn.Dial(ctx, addr, tlsCfg, quicConfig(opts))
}
// SelfSignedCert generates an ephemeral ECDSA-P256 self-signed certificate for
// localhost, valid for 10 days (within Chrome's ≤14-day tolerance for
// serverCertificateHashes pinning). It is for local development only.
func SelfSignedCert() (tls.Certificate, error) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return tls.Certificate{}, err
}
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "mediamesh-relay"},
DNSNames: []string{"localhost"},
IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1), net.IPv6loopback},
// serverAuth + a key-usage that permits TLS server handshakes is
// mandatory for clients that validate the cert against a trust store.
// Without it the QUIC/h3 handshake fails before any MOQT logic runs.
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
NotBefore: time.Now().Add(-time.Minute),
NotAfter: time.Now().Add(10 * 24 * time.Hour),
}
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
return tls.Certificate{}, err
}
keyDER, err := x509.MarshalECPrivateKey(key)
if err != nil {
return tls.Certificate{}, err
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
// SEC1 EC keys use the "EC PRIVATE KEY" PEM type (RFC 5915).
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
return tls.X509KeyPair(certPEM, keyPEM)
}
package relaynet
import (
"context"
"crypto/tls"
"fmt"
"github.com/quic-go/quic-go/http3"
"github.com/quic-go/webtransport-go"
"github.com/floatdrop/moq-go/pkg/moqt/session"
"github.com/floatdrop/moq-go/pkg/moqt/session/wtconn"
)
// WebTransportALPNs lists the TLS ALPNs of the MOQT-over-WebTransport mapping.
// WebTransport rides HTTP/3, whose ALPN is "h3" — the "moqt-NN" identifiers belong
// to raw QUIC ([MOQTQUICALPNs]). The draft version is instead negotiated as the
// WebTransport sub-protocol (§3.1), which [Listen] offers.
//
// Pass these alone to [TLSConfig] for a listener that serves *only* WebTransport;
// [DualALPNs] serves both mappings.
var WebTransportALPNs = []string{http3.NextProtoH3}
// DialWebTransport dials rawURL — the https URL of a WebTransport endpoint, i.e.
// the §3.1.4 conversion of a moqt URI — and returns the established session as a
// [session.Conn], ready for the caller to drive the client-side MOQT SETUP on.
// It is the WebTransport counterpart of [DialQUIC] and has the shape a relay
// Dialer expects; tlsCfg must advertise [WebTransportALPNs]. opts tune the QUIC
// config this leg dials with — see [WithQUICConfig].
func DialWebTransport(ctx context.Context, rawURL string, tlsCfg *tls.Config, opts ...Option) (session.Conn, error) {
d := webtransport.Transport{
TLSClientConfig: tlsCfg,
// Unlike [Listen], nothing forces DATAGRAM and stream-reset partial
// delivery back on after the opts run: webtransport-go's Dial rejects a
// config missing either before it sends a packet, so the mistake surfaces
// here rather than as a mapping that quietly stops working. The error is
// a bare errors.New, not a sentinel, so it is reportable but not testable
// with errors.Is.
QUICConfig: quicConfig(opts),
// §3.1.4: "The client includes MOQT protocol identifiers in the
// WT-Available-Protocols header." That header is how a WebTransport
// session negotiates the draft version, the way ALPN does it for raw
// QUIC (§3.1) — without it the upgrade completes with an empty protocol
// and the version is never agreed. webtransport-go builds the header
// from this list and rejects a selection it did not offer.
ApplicationProtocols: MOQTQUICALPNs,
}
// The extended-CONNECT response body is the stream the WebTransport session
// rides on, so it must NOT be closed here: doing so would tear down the very
// session being returned. Its lifetime belongs to the returned conn.
//nolint:bodyclose // response body is the session stream; owned by wtSess.
_, wtSess, err := d.Dial(ctx, rawURL, nil)
if err != nil {
return nil, fmt.Errorf("relaynet: dial webtransport %s: %w", rawURL, err)
}
return wtconn.New(wtSess), nil
}
package relay
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"slices"
"sync"
"sync/atomic"
"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/cache"
"github.com/floatdrop/moq-go/pkg/relay/discovery"
"github.com/floatdrop/moq-go/pkg/relay/internal/registry"
)
// sessionHandler owns the per-session request and data-stream loops, one per
// accepted [session.Session]. The relay's shared state (registries, authorizer)
// is injected by [Relay.handleConn] and referenced read-only.
//
// Concurrency: [sessionHandler.run] drives the request, data-stream, and
// datagram loops on separate goroutines, plus per-request handler goroutines
// spawned by the dispatch loop and tracked via wg for a clean join on teardown.
type sessionHandler struct {
sess *session.Session
log *slog.Logger
tracks *registry.TrackRegistry
names *registry.NamespaceRegistry
auth Authorizer
metrics Metrics
fetch *registry.FetchRouter
// leg records whether this session was dialled by the relay
// (LegUpstream) or by the peer (LegLocal). Every [Metrics] call this
// handler makes carries it, so an operator can separate what the
// cross-relay hop is doing from what clients are doing.
leg Leg
upstreams *upstreamPool
discovery discovery.DiscoveryStore
relayAddr string
sendQueueSize int
maxDropsBeforeReset int
maxFanoutLag time.Duration
// limiter enforces the §13.1 / §13.7.1 per-session resource caps.
limiter sessionLimiter
// wg tracks per-request goroutines spawned by the dispatch loop.
wg sync.WaitGroup
// 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
// serves every downstream subscriber of the track, not just the one on
// this session (§9.4 aggregation).
relayGo func(func())
}
// newSessionHandler constructs a handler. Callers provide the shared
// dependencies; the handler does not allocate them itself, which makes it
// trivial to fan-in a test handler with a fake registry or authorizer.
func newSessionHandler(
sess *session.Session,
log *slog.Logger,
tracks *registry.TrackRegistry,
names *registry.NamespaceRegistry,
auth Authorizer,
metrics Metrics,
leg Leg,
fetch *registry.FetchRouter,
upstreams *upstreamPool,
discovery discovery.DiscoveryStore,
relayAddr string,
sendQueueSize int,
maxDropsBeforeReset int,
maxFanoutLag time.Duration,
maxSubsPerSession int,
maxNamespaceReqsPerSession int,
relayGo func(func()),
) *sessionHandler {
return &sessionHandler{
sess: sess,
log: log.With("moqt.session", fmt.Sprintf("%p", sess)),
tracks: tracks,
names: names,
auth: auth,
metrics: metrics,
leg: leg,
fetch: fetch,
upstreams: upstreams,
discovery: discovery,
relayAddr: relayAddr,
sendQueueSize: sendQueueSize,
maxDropsBeforeReset: maxDropsBeforeReset,
maxFanoutLag: maxFanoutLag,
limiter: sessionLimiter{maxSubs: maxSubsPerSession, maxNS: maxNamespaceReqsPerSession},
relayGo: relayGo,
}
}
// trackRef labels a [Metrics] event with the track it happened on and the leg
// this session sits on.
//
// track.FullTrackName holds the name as []byte, so this conversion allocates.
// Callers MUST hoist it out of any per-object loop — build one TrackRef when a
// stream, subscription or FETCH begins and reuse it — because [Metrics]
// promises implementations a hot path with nothing to spare.
func (h *sessionHandler) trackRef(name track.FullTrackName) TrackRef {
return TrackRef{Name: string(name.Name), Leg: h.leg}
}
// saveLargestLocation folds a LARGEST_OBJECT parameter the upstream sent into
// the track's watermark.
//
// §10.2.17 is the operative rule and it is addressed to relays specifically: a
// relay MUST set LARGEST_OBJECT to the largest of (1) any value received from
// the upstream publisher in SUBSCRIBE_OK, PUBLISH or REQUEST_UPDATE_OK, and
// (2) the largest Location of an Object received on an upstream subscription.
// §9.4 makes that binding here ("Relays MUST follow the constraints on
// LARGEST_OBJECT defined in Section 10.2.16"). Only (2) was implemented, so the
// relay advertised a watermark built purely from objects it had watched arrive.
//
// Call this on every path carrying the parameter, unconditionally.
// [registry.TrackEntry.UpdateLargest] keeps the maximum, which is exactly what
// §10.2.17 asks for, so a value already overtaken changes nothing.
//
// Do NOT narrow this to the Forward-State transition. §5.1 says "A publisher
// MUST save the Largest Location communicated in SUBSCRIBE_OK, PUBLISH or
// REQUEST_UPDATE_OK that changes the Forward State from 0 to 1", and that
// qualifier is about what an endpoint may use as *its own* Joining Location —
// §10.2.17's relay rule carries no such condition. The distinction is
// load-bearing: subscribeUpstreamOnSession sends FORWARD=0 whenever no
// downstream wants forwarding, so on that path §5.1's sentence does not apply
// at all, and reading it as the authority here reintroduces the bug below.
//
// What that bug was: a relay is the publisher for its own downstream
// subscribers (§9.4), so a freshly established cross-relay subscription
// reported no Largest Object until the first object happened to flow. For a
// track published *once* that never happens — the live subscription carries
// only future objects, and the §5.1.3 fill fetch stream that exists to backfill
// the rest is refused with INVALID_RANGE for having no Joining Location. An MSF
// catalog is exactly that shape, so across two relays the participant its
// catalog described stayed invisible for the whole call.
func saveLargestLocation(entry *registry.TrackEntry, ps message.Parameters) {
if p, ok := ps.Find(message.ParamLargestObject); ok {
entry.UpdateLargest(message.Location{Group: p.Group, Object: p.Object})
}
}
// handleInboundGoaway implements §10.4: when the peer sends GOAWAY, grant the
// timeout it declared for in-flight subscriptions to wrap up, then close the
// session. Per-session registry cleanup drops the entries on teardown.
//
// The relay does not migrate upstream subscriptions to the peer's NewSessionURI
// (§9.5.1); dependent DownstreamSubs see their tracks end and the client
// re-subscribes, which may re-establish the track via the on-demand upstream
// subscribe path.
//
// Blocks on the declared timeout, sess.Done() (peer closed earlier), or ctx
// (relay shutdown). The caller's defer then cancels runCtx to unblock the loops.
func (h *sessionHandler) handleInboundGoaway(ctx context.Context) {
g := h.sess.PeerGoaway()
if g == nil {
return
}
//nolint:gosec // G115: g.Timeout is a peer-supplied ms value; an out-of-range value yields a wrong duration, not a memory-safety issue.
timeout := time.Duration(g.Timeout) * time.Millisecond
h.log.LogAttrs(ctx, slog.LevelInfo, "relay received inbound GOAWAY",
slog.Duration("timeout", timeout),
slog.String("new_session_uri", string(g.NewSessionURI)))
if timeout > 0 {
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case <-timer.C:
case <-h.sess.Done():
return // peer drained cleanly
case <-ctx.Done():
return // relay-level shutdown took priority
}
}
_ = h.sess.Close(moqt.SessionGoawayTimeout, "inbound GOAWAY timeout")
}
// subIDCounter allocates process-globally unique subscription IDs. It MUST
// be global, not per-handler: a TrackEntry aggregates subscriptions from
// many sessions (§9.4), and the registry removes by ID — two handlers'
// per-handler counters would collide, so one subscriber unsubscribing would
// silently delete another session's subscription from the same track.
var subIDCounter atomic.Uint64
// allocSubID returns a fresh, process-unique subscription ID. Used when
// instantiating registry.UpstreamSub / registry.DownstreamSub from inside the request handlers.
func (h *sessionHandler) allocSubID() uint64 {
return subIDCounter.Add(1)
}
// run blocks until the session ends, returning nil on a clean close (peer or
// ctx-driven shutdown) or the request/data-loop error otherwise. It spawns the
// protocol loops, joins them and all in-flight per-request goroutines, and does
// not close the session itself except on a protocol violation detected by a loop.
func (h *sessionHandler) run(ctx context.Context) error {
// Watcher ties runCtx to the parent ctx and the session's Done channel so
// loops unblock as soon as the session terminates, and folds in inbound
// GOAWAY handling (see handleInboundGoaway).
runCtx, cancel := context.WithCancel(ctx)
defer cancel()
go func() {
select {
case <-h.sess.Done():
case <-runCtx.Done():
case <-h.sess.GoawayReceived():
h.handleInboundGoaway(ctx)
}
cancel()
}()
var (
loops sync.WaitGroup
reqErr error
dataErr error
)
// The request and data loops are load-bearing: when either dies, the
// session is no longer usable, so each cancels runCtx to unwind the rest.
loops.Go(func() {
reqErr = h.runRequestLoop(runCtx)
cancel() // wake sibling loops if the request loop dies first
})
loops.Go(func() {
dataErr = h.runDataLoop(runCtx)
cancel() // wake sibling loops if the data loop dies first
})
// Datagrams are OPTIONAL (§11.3): a transport or peer without DATAGRAM
// support fails ReceiveDatagram on the first call, which must not take down
// SUBSCRIBE/PUBLISH handling. So the datagram loop neither cancels its
// siblings nor promotes its error as a session fault — it just stops.
loops.Go(func() {
if err := h.runDatagramLoop(runCtx); err != nil && !isShutdownErr(err) {
h.log.LogAttrs(ctx, slog.LevelDebug,
"relay datagram loop ended; datagrams unavailable on this session",
slog.String("err", err.Error()))
}
})
loops.Wait()
h.wg.Wait()
// Promote the first non-shutdown error (request > data) to the caller. All
// errors are logged at Debug for postmortem.
if reqErr != nil && !isShutdownErr(reqErr) {
h.log.LogAttrs(ctx, slog.LevelDebug, "relay request loop ended", slog.String("err", reqErr.Error()))
return reqErr
}
if dataErr != nil && !isShutdownErr(dataErr) {
h.log.LogAttrs(ctx, slog.LevelDebug, "relay data loop ended", slog.String("err", dataErr.Error()))
return dataErr
}
return nil
}
// runRequestLoop reads requests off the session and dispatches each to the
// appropriate handler. Each handler is responsible for its own bidi stream
// lifecycle — the loop hands off the [*session.Request] and does NOT wait
// for the handler to finish.
//
// The loop terminates when:
//
// - ctx is cancelled (returns ctx.Err()),
// - the session emits an unrecoverable error from AcceptRequest,
// - a non-shutdown read failure occurs.
//
// Per-request protocol errors (parse failures, unknown message types, auth
// failures) do NOT terminate the loop — the relay rejects the individual
// request and continues serving the session. This matches §9.5's rule that
// a single bad request must not break unrelated subscriptions.
func (h *sessionHandler) runRequestLoop(ctx context.Context) error {
err := h.requestMux(ctx).Run(ctx, h.sess)
// A malformed / duplicate / overflowing / unknown AUTHORIZATION_TOKEN alias
// surfaces from AcceptRequest as a session-level fault per §10.2.2: close the
// session with the mapped SESSION_ERROR code rather than just tearing down
// the request loop.
if tce, ok := errors.AsType[*session.TokenCacheError](err); ok {
h.log.LogAttrs(ctx, slog.LevelDebug, "relay closing session on token cache error",
slog.String("err", err.Error()),
slog.Uint64("code", uint64(tce.Code)))
_ = h.sess.Close(tce.Code, tce.Error())
}
// §10.9: a REQUEST_UPDATE that opens a request stream is a PROTOCOL_VIOLATION
// AcceptRequest surfaces as *ErrUnexpectedRequestUpdate; close the session.
if _, ok := errors.AsType[*session.ErrUnexpectedRequestUpdate](err); ok {
h.log.LogAttrs(ctx, slog.LevelDebug, "relay closing session on stray REQUEST_UPDATE",
slog.String("err", err.Error()))
_ = h.sess.Close(moqt.SessionProtocolViolation, err.Error())
}
return err
}
// runDataLoop accepts inbound data streams and routes each by type: subgroup
// streams to [sessionHandler.runFanout], fetch response streams to the fetch
// router (see the inline comments below).
//
// Per-stream errors do not terminate the loop (§9.5: one bad data stream must
// not kill the session); transport-level errors from AcceptDataStream do.
func (h *sessionHandler) runDataLoop(ctx context.Context) error {
for {
ds, err := h.sess.AcceptDataStream(ctx)
if err != nil {
if errors.Is(err, session.ErrPaddingStream) {
// §11.5.1 padding stream — silently discarded
// by AcceptDataStream itself; loop and try again.
continue
}
return err
}
switch s := ds.(type) {
case *session.IncomingSubgroupStream:
h.spawn(func() { h.runFanout(ctx, s) })
case *session.IncomingFetchStream:
// Body side of a FETCH the relay issued upstream on this
// session. Hand it to the downstream handler waiting on the
// matching (session, RequestID) via the fetch router; if none
// is registered (no stitch in flight, or a duplicate/late
// response), reset it to keep the upstream's flow control free.
if !h.fetch.Deliver(h.sess, s.Header.RequestID, s) {
h.log.LogAttrs(ctx, slog.LevelDebug, "relay dropped unmatched IncomingFetchStream",
slog.Uint64("request_id", s.Header.RequestID))
s.Cancel(moqt.StreamResetInternalError)
}
default:
h.log.LogAttrs(ctx, slog.LevelDebug, "relay dropped unknown data stream",
slog.String("type", fmt.Sprintf("%T", ds)))
}
}
}
// requestMux builds the per-session [session.RequestMux] that routes each inbound
// request to the handler responsible for its First-message type. Each handler is
// expected to:
//
// 1. Authorize the request.
// 2. Reply with either *_OK or REQUEST_ERROR.
// 3. Keep the bidi stream open for as long as the subscription's lifetime
// warrants (or close it cleanly on rejection).
// 4. Update [registry.TrackRegistry] / [registry.NamespaceRegistry] as appropriate.
//
// Two cross-cutting policies are shared across the per-type handlers:
// verifyRequest applies the §10.2.2 token-verification pre-step, and
// namespaceRequest folds in the §13.7.1 per-session cap for the three
// namespace-state requests (the §13.1 subscription cap is inline on SUBSCRIBE).
//
// An unexpected first-message type violates §10 ("Messages marked "First" MUST
// be the first message on a new request stream"): OnUnknown resets the bidi
// stream per §3.3.3 and logs. The session is NOT closed — §9.5
// ("if a Session is closed due to an unknown or invalid control message [...] the
// Relay MUST NOT propagate that message [...] to another Session") means the
// relay isolates the failure to the one request.
func (h *sessionHandler) requestMux(ctx context.Context) *session.RequestMux {
mux := session.NewRequestMux()
mux.HandleType(func(req *session.Request, msg *message.Subscribe) {
if !h.verifyRequest(ctx, req) {
return
}
// §13.1: bound concurrent subscriptions per session.
if !h.limiter.acquireSub() {
h.rejectExcessiveLoad(ctx, req, "subscription")
return
}
h.spawn(func() { defer h.limiter.releaseSub(); h.handleSubscribe(ctx, req, msg) })
})
mux.HandleType(func(req *session.Request, msg *message.Publish) {
if !h.verifyRequest(ctx, req) {
return
}
h.spawn(func() { h.handlePublish(ctx, req, msg) })
})
mux.HandleType(func(req *session.Request, msg *message.Fetch) {
if !h.verifyRequest(ctx, req) {
return
}
h.spawn(func() { h.handleFetch(ctx, req, msg) })
})
mux.HandleType(func(req *session.Request, msg *message.TrackStatus) {
if !h.verifyRequest(ctx, req) {
return
}
h.spawn(func() { h.handleTrackStatus(ctx, req, msg) })
})
mux.HandleType(func(req *session.Request, msg *message.PublishNamespace) {
h.namespaceRequest(ctx, req, func() { h.handlePublishNamespace(ctx, req, msg) })
})
mux.HandleType(func(req *session.Request, msg *message.SubscribeNamespace) {
h.namespaceRequest(ctx, req, func() { h.handleSubscribeNamespace(ctx, req, msg) })
})
mux.HandleType(func(req *session.Request, msg *message.SubscribeTracks) {
h.namespaceRequest(ctx, req, func() { h.handleSubscribeTracks(ctx, req, msg) })
})
mux.OnUnknown(func(req *session.Request) {
h.log.LogAttrs(ctx, slog.LevelWarn, "relay rejected unknown request type",
slog.String("type", fmt.Sprintf("%T", req.First)))
req.Stream.CancelRead(uint64(moqt.StreamResetInternalError))
req.Stream.CancelWrite(uint64(moqt.StreamResetInternalError))
})
return mux
}
// verifyRequest runs the per-request dispatch log and the §10.2.2 token
// verification shared by every known request type. It returns false — after
// replying REQUEST_ERROR with the mapped code — when the request's resolved
// AUTHORIZATION_TOKEN is denied; the session stays up (a denial is per-request).
func (h *sessionHandler) verifyRequest(ctx context.Context, req *session.Request) bool {
h.log.LogAttrs(ctx, slog.LevelDebug, "relay dispatching request",
slog.String("type", fmt.Sprintf("%T", req.First)))
if err := h.sess.VerifyRequestTokens(ctx, req); err != nil {
h.rejectTokenDenied(ctx, req, err)
return false
}
return true
}
// namespaceRequest wraps a namespace-state handler (PUBLISH_NAMESPACE,
// SUBSCRIBE_NAMESPACE, SUBSCRIBE_TRACKS) with the shared token verification and
// the §13.7.1 per-session cap, spawning fn under the limiter when admitted.
func (h *sessionHandler) namespaceRequest(ctx context.Context, req *session.Request, fn func()) {
if !h.verifyRequest(ctx, req) {
return
}
if !h.limiter.acquireNamespace() {
h.rejectExcessiveLoad(ctx, req, "namespace request")
return
}
h.spawn(func() { defer h.limiter.releaseNamespace(); fn() })
}
// spawn registers a goroutine with the handler's wg so run() can join it
// during shutdown. Handlers are responsible for handling their own ctx
// cancellation; spawn does not impose a timeout.
func (h *sessionHandler) spawn(fn func()) {
h.wg.Go(fn)
}
// ---------------------------------------------------------------------------
// Request rejection helpers
// ---------------------------------------------------------------------------
// rejectAuth writes a REQUEST_ERROR with the code derived from the authorizer
// error and FINs the bidi stream. Any write failure is logged but otherwise
// swallowed — the stream is being torn down anyway.
func (h *sessionHandler) rejectAuth(ctx context.Context, req *session.Request, kind string, authErr error) {
code := CodeForAuthorizerError(authErr)
reason := ReasonForAuthorizerError(authErr)
if err := req.RejectError(code, reason); err != nil && !errors.Is(err, context.Canceled) {
h.log.LogAttrs(ctx, slog.LevelDebug, "relay reject write failed",
slog.String("kind", kind), slog.String("err", err.Error()))
}
}
// rejectExcessiveLoad rejects a request that exceeds a per-session resource cap
// (§13.1 / §13.7.1) with REQUEST_ERROR EXCESSIVE_LOAD and FINs the bidi stream.
// what names the limit category for the log/reason. The reject happens before
// any registry mutation, so no cleanup is needed.
func (h *sessionHandler) rejectExcessiveLoad(ctx context.Context, req *session.Request, what string) {
h.log.LogAttrs(ctx, slog.LevelDebug, "relay rejecting request: per-session limit reached",
slog.String("limit", what))
if err := req.RejectError(moqt.RequestExcessiveLoad, "relay: "+what+" limit reached"); err != nil &&
!errors.Is(err, context.Canceled) {
h.log.LogAttrs(ctx, slog.LevelDebug, "relay EXCESSIVE_LOAD reject write failed",
slog.String("err", err.Error()))
}
}
// rejectTokenDenied writes a REQUEST_ERROR for a token-verification denial and
// FINs the bidi stream. The error is always a [*session.TokenDeniedError]
// (VerifyRequestTokens normalises plain verifier errors into one), so its
// RequestErrorCode — e.g. [moqt.RequestExpiredAuthToken] or the default
// [moqt.RequestUnauthorized] — maps straight onto the wire reply. Like
// rejectAuth, a write failure is logged and otherwise swallowed.
func (h *sessionHandler) rejectTokenDenied(ctx context.Context, req *session.Request, denyErr error) {
code := moqt.RequestUnauthorized
reason := denyErr.Error()
if denied, ok := errors.AsType[*session.TokenDeniedError](denyErr); ok {
code = denied.RequestErrorCode()
if denied.Reason != "" {
reason = denied.Reason
}
}
h.log.LogAttrs(ctx, slog.LevelDebug, "relay rejecting request on token verification",
slog.String("err", denyErr.Error()), slog.Uint64("code", uint64(code)))
if err := req.RejectError(code, reason); err != nil && !errors.Is(err, context.Canceled) {
h.log.LogAttrs(ctx, slog.LevelDebug, "relay token-denied reject write failed",
slog.String("err", err.Error()))
}
}
// 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
// wrong-parity or duplicate ID is session-fatal (INVALID_REQUEST_ID);
// returns false when the session was closed, in which case the caller's
// read loop should stop.
func (h *sessionHandler) handleFollowupRequestID(ctx context.Context, upd *message.RequestUpdate) bool {
err := h.sess.CheckPeerRequestID(upd.RequestID)
if err == nil {
return true
}
h.log.LogAttrs(ctx, slog.LevelDebug, "relay closing session on follow-up Request ID violation",
slog.String("err", err.Error()))
_ = h.sess.Close(moqt.SessionInvalidRequestID, err.Error())
return false
}
// handleRequestUpdateLimit charges one credit on lim for a received
// REQUEST_UPDATE and enforces the per-stream MAX_REQUEST_UPDATES limit
// (§10.3.1.7). Exceeding it is session-fatal (TOO_MANY_REQUEST_UPDATES);
// returns false when the session was closed, in which case the caller's read
// loop should stop. The caller invokes lim.Responded once it has written the
// mandated REQUEST_OK/REQUEST_ERROR.
func (h *sessionHandler) handleRequestUpdateLimit(ctx context.Context, lim *session.RequestUpdateLimiter) bool {
err := lim.Received()
if err == nil {
return true
}
h.log.LogAttrs(ctx, slog.LevelDebug, "relay closing session on REQUEST_UPDATE limit",
slog.String("err", err.Error()))
_ = h.sess.Close(moqt.SessionTooManyRequestUpdates, err.Error())
return false
}
// handleFollowupTokens routes a follow-up message's AUTHORIZATION_TOKEN
// parameters through the session token cache — §10.2.2 allows REQUEST_UPDATE
// to REGISTER or DELETE aliases, and the readers that parse follow-ups
// directly bypass AcceptRequest's processing. Returns false when a token
// fault closed the session, in which case the caller's read loop should
// stop.
func (h *sessionHandler) handleFollowupTokens(ctx context.Context, msg message.Message) bool {
_, err := h.sess.ProcessFollowupTokens(msg)
if err == nil {
return true
}
if tce, ok := errors.AsType[*session.TokenCacheError](err); ok {
h.log.LogAttrs(ctx, slog.LevelDebug, "relay closing session on follow-up token cache error",
slog.String("err", err.Error()),
slog.Uint64("code", uint64(tce.Code)))
_ = h.sess.Close(tce.Code, tce.Error())
return false
}
h.log.LogAttrs(ctx, slog.LevelDebug, "follow-up token processing failed",
slog.String("err", err.Error()))
return false
}
// readRequestStream owns all reads on an established request stream: it
// parses follow-up messages off the stream and dispatches each to onMsg
// until the peer tears the stream down (EOF / reset), onMsg returns false,
// or ctx is cancelled (the read side is then reset with
// StreamResetSessionClosed to unblock the parse). A malformed follow-up —
// any non-EOF parse error — resets the read side with
// StreamResetInternalError so the peer learns reads stopped instead of
// filling flow control into a void.
//
// This is the single scaffolding under readSubscribeUpdates,
// readFetchUpdates — the responder-side follow-up loops, which differ only
// in their per-message dispatch. (Requester-side upstream streams use
// [session.RequestBroker.Serve] instead, which additionally routes §10.9
// responses to in-flight Update calls.)
func readRequestStream(ctx context.Context, stream session.Stream, onMsg func(message.Message) bool) {
done := make(chan struct{})
go func() {
defer close(done)
for {
m, err := message.Parse(stream)
if err != nil {
// Covers peer resets too (a STOP_SENDING on an
// already-reset stream is a transport no-op), and may run
// after the ctx arm's SessionClosed CancelRead — the first
// code sent wins on every bundled adapter.
if !errors.Is(err, io.EOF) {
stream.CancelRead(uint64(moqt.StreamResetInternalError))
}
return
}
if !onMsg(m) {
return
}
}
}()
select {
case <-done:
case <-ctx.Done():
stream.CancelRead(uint64(moqt.StreamResetSessionClosed))
<-done
}
}
// serveFetchObjects is the shared response tail of the standalone and
// joining FETCH handlers: open the data stream, stream the stitched range,
// count the objects actually written (the FetchServed metric), FIN, and
// park in the §10.9 follow-up loop until the peer tears the request stream
// down. kind tags log lines with the FETCH flavour ("standalone" / "joining").
func (h *sessionHandler) serveFetchObjects(
ctx context.Context,
req *session.Request,
kind string,
requestID uint64,
entry *registry.TrackEntry,
fullName track.FullTrackName,
start, end message.Location,
order message.GroupOrder,
fillTimeout time.Duration,
rangeFilters *message.RangeFilterSet,
) {
out, ok := h.streamFetchRange(ctx, kind, requestID, entry, fullName,
start, end, order, fillTimeout, rangeFilters)
if !ok {
return
}
// Read follow-ups (§10.9 REQUEST_UPDATE, peer FIN/reset) on the bidi
// request stream until the peer tears it down or ctx is cancelled, so a
// malformed FETCH update is answered with REQUEST_ERROR and the data
// stream reset per §10.9.
h.readFetchUpdates(ctx, req, out)
}
// streamFetchRange opens a unidirectional fetch stream, writes the stitched
// range to it, and FINs. It is the shared body of a FETCH response (§10.13)
// and of a fill fetch stream (§5.1.3), which differ only in what opens them
// and in what happens afterwards — a FETCH parks in the §10.9 follow-up loop,
// a fill is simply done.
//
// ok is false when the stream could not be opened or the write failed; the
// stream is already reset in the latter case. The returned stream is otherwise
// closed (FIN) and returned only so a FETCH can reset it from its follow-up
// loop.
func (h *sessionHandler) streamFetchRange(
ctx context.Context,
kind string,
requestID uint64,
entry *registry.TrackEntry,
fullName track.FullTrackName,
start, end message.Location,
order message.GroupOrder,
fillTimeout time.Duration,
rangeFilters *message.RangeFilterSet,
) (*session.OutgoingFetchStream, bool) {
out, err := h.sess.OpenFetchStream(message.FetchHeader{RequestID: requestID})
if err != nil {
h.log.LogAttrs(ctx, slog.LevelDebug, "OpenFetchStream failed",
slog.String("kind", kind), slog.String("err", err.Error()))
return nil, false
}
// Gather cached objects, stitching the below-floor portion from upstream
// when the cache doesn't cover the whole range (§9.4).
objs := h.stitchedFetchObjects(ctx, entry, fullName, start, end, order, fillTimeout)
// §5.1.4: drop objects that fail the request's Range Filters. §11.4.4.2
// end-of-range markers are not objects and are always kept — they carry no
// Subgroup ID, Priority or Properties, so matching one against a filter
// tests zero values and drops it, turning the span into a plain gap that
// §10.13 reads as authoritative non-existence.
if rangeFilters != nil {
objs = slices.DeleteFunc(objs, func(o *cache.CachedObject) bool {
return !o.IsRangeMarker() &&
!rangeFilters.MatchesObject(o.SubgroupID, o.ObjectID, o.PublisherPriority, o.Properties)
})
}
written, err := streamFetchObjects(out, objs)
h.metrics.FetchServed(h.trackRef(fullName), written)
if err != nil {
h.log.LogAttrs(ctx, slog.LevelDebug, "fetch stream write failed",
slog.String("kind", kind), slog.String("err", err.Error()))
out.Cancel(moqt.StreamResetInternalError)
return nil, false
}
_ = out.Close()
return out, true
}