snapshot of https://github.com/pion/webrtc.git (main, 2026-09-15; blobs > 131072B omitted)
Some checks failed
REUSE Compliance Check / lint (push) Failing after 0s
Browser E2E / Test (push) Failing after 3s
Test / test (1.24) (push) Failing after 0s
Test / test-i386 (1.25) (push) Failing after 0s
Test / test-i386 (1.24) (push) Failing after 0s
Test / test-windows (1.25) (push) Failing after 0s
Test / test-windows (1.24) (push) Failing after 0s
Test / test (1.25) (push) Failing after 0s
Examples Tests / Test (push) Failing after 3s
Test / test-macos (1.25) (push) Failing after 0s
Test / test-macos (1.24) (push) Failing after 0s
Go mod tidy / tidy (push) Failing after 0s
Test / test-wasm (push) Failing after 0s
CodeQL / analyze (push) Failing after 1s
Fuzz / fuzz (push) Failing after 0s
Some checks failed
REUSE Compliance Check / lint (push) Failing after 0s
Browser E2E / Test (push) Failing after 3s
Test / test (1.24) (push) Failing after 0s
Test / test-i386 (1.25) (push) Failing after 0s
Test / test-i386 (1.24) (push) Failing after 0s
Test / test-windows (1.25) (push) Failing after 0s
Test / test-windows (1.24) (push) Failing after 0s
Test / test (1.25) (push) Failing after 0s
Examples Tests / Test (push) Failing after 3s
Test / test-macos (1.25) (push) Failing after 0s
Test / test-macos (1.24) (push) Failing after 0s
Go mod tidy / tidy (push) Failing after 0s
Test / test-wasm (push) Failing after 0s
CodeQL / analyze (push) Failing after 1s
Fuzz / fuzz (push) Failing after 0s
This commit is contained in:
42
internal/fmtp/av1.go
Normal file
42
internal/fmtp/av1.go
Normal file
@@ -0,0 +1,42 @@
|
||||
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package fmtp
|
||||
|
||||
type av1FMTP struct {
|
||||
parameters map[string]string
|
||||
}
|
||||
|
||||
func (h *av1FMTP) MimeType() string {
|
||||
return "video/av1"
|
||||
}
|
||||
|
||||
func (h *av1FMTP) Match(b FMTP) bool {
|
||||
c, ok := b.(*av1FMTP)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
// RTP Payload Format For AV1 (v1.0)
|
||||
// https://aomediacodec.github.io/av1-rtp-spec/
|
||||
// If the profile parameter is not present, it MUST be inferred to be 0 (“Main” profile).
|
||||
hProfile, ok := h.parameters["profile"]
|
||||
if !ok {
|
||||
hProfile = "0"
|
||||
}
|
||||
cProfile, ok := c.parameters["profile"]
|
||||
if !ok {
|
||||
cProfile = "0"
|
||||
}
|
||||
if hProfile != cProfile {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (h *av1FMTP) Parameter(key string) (string, bool) {
|
||||
v, ok := h.parameters[key]
|
||||
|
||||
return v, ok
|
||||
}
|
||||
190
internal/fmtp/fmtp.go
Normal file
190
internal/fmtp/fmtp.go
Normal file
@@ -0,0 +1,190 @@
|
||||
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package fmtp implements per codec parsing of fmtp lines
|
||||
package fmtp
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
func defaultClockRate(mimeType string) uint32 {
|
||||
defaults := map[string]uint32{
|
||||
"audio/opus": 48000,
|
||||
"audio/pcmu": 8000,
|
||||
"audio/pcma": 8000,
|
||||
}
|
||||
|
||||
if def, ok := defaults[strings.ToLower(mimeType)]; ok {
|
||||
return def
|
||||
}
|
||||
|
||||
return 90000
|
||||
}
|
||||
|
||||
func defaultChannels(mimeType string) uint16 {
|
||||
defaults := map[string]uint16{
|
||||
"audio/opus": 2,
|
||||
}
|
||||
|
||||
if def, ok := defaults[strings.ToLower(mimeType)]; ok {
|
||||
return def
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
func parseParameters(line string) map[string]string {
|
||||
parameters := make(map[string]string)
|
||||
|
||||
for p := range strings.SplitSeq(line, ";") {
|
||||
pp := strings.SplitN(strings.TrimSpace(p), "=", 2)
|
||||
key := strings.ToLower(pp[0])
|
||||
var value string
|
||||
if len(pp) > 1 {
|
||||
value = pp[1]
|
||||
}
|
||||
parameters[key] = value
|
||||
}
|
||||
|
||||
return parameters
|
||||
}
|
||||
|
||||
// ClockRateEqual checks whether two clock rates are equal.
|
||||
func ClockRateEqual(mimeType string, valA, valB uint32) bool {
|
||||
// Lots of users use formats without setting clock rate or channels.
|
||||
// In this case, use default values.
|
||||
// It would be better to remove this exception in a future major release.
|
||||
if valA == 0 {
|
||||
valA = defaultClockRate(mimeType)
|
||||
}
|
||||
if valB == 0 {
|
||||
valB = defaultClockRate(mimeType)
|
||||
}
|
||||
|
||||
return valA == valB
|
||||
}
|
||||
|
||||
// ChannelsEqual checks whether two channels are equal.
|
||||
func ChannelsEqual(mimeType string, valA, valB uint16) bool {
|
||||
// Lots of users use formats without setting clock rate or channels.
|
||||
// In this case, use default values.
|
||||
// It would be better to remove this exception in a future major release.
|
||||
if valA == 0 {
|
||||
valA = defaultChannels(mimeType)
|
||||
}
|
||||
if valB == 0 {
|
||||
valB = defaultChannels(mimeType)
|
||||
}
|
||||
|
||||
// RFC8866: channel count "is OPTIONAL and may be omitted
|
||||
// if the number of channels is one".
|
||||
if valA == 0 {
|
||||
valA = 1
|
||||
}
|
||||
if valB == 0 {
|
||||
valB = 1
|
||||
}
|
||||
|
||||
return valA == valB
|
||||
}
|
||||
|
||||
func paramsEqual(valA, valB map[string]string) bool {
|
||||
for k, v := range valA {
|
||||
if vb, ok := valB[k]; ok && !strings.EqualFold(vb, v) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
for k, v := range valB {
|
||||
if va, ok := valA[k]; ok && !strings.EqualFold(va, v) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// FMTP interface for implementing custom
|
||||
// FMTP parsers based on MimeType.
|
||||
type FMTP interface {
|
||||
// MimeType returns the MimeType associated with
|
||||
// the fmtp
|
||||
MimeType() string
|
||||
// Match compares two fmtp descriptions for
|
||||
// compatibility based on the MimeType
|
||||
Match(f FMTP) bool
|
||||
// Parameter returns a value for the associated key
|
||||
// if contained in the parsed fmtp string
|
||||
Parameter(key string) (string, bool)
|
||||
}
|
||||
|
||||
// Parse parses an fmtp string based on the MimeType.
|
||||
func Parse(mimeType string, clockRate uint32, channels uint16, line string) FMTP {
|
||||
var fmtp FMTP
|
||||
|
||||
parameters := parseParameters(line)
|
||||
|
||||
switch {
|
||||
case strings.EqualFold(mimeType, "video/h264"):
|
||||
fmtp = &h264FMTP{
|
||||
parameters: parameters,
|
||||
}
|
||||
|
||||
case strings.EqualFold(mimeType, "video/h265"):
|
||||
fmtp = &h265FMTP{
|
||||
parameters: parameters,
|
||||
}
|
||||
|
||||
case strings.EqualFold(mimeType, "video/vp9"):
|
||||
fmtp = &vp9FMTP{
|
||||
parameters: parameters,
|
||||
}
|
||||
|
||||
case strings.EqualFold(mimeType, "video/av1"):
|
||||
fmtp = &av1FMTP{
|
||||
parameters: parameters,
|
||||
}
|
||||
|
||||
default:
|
||||
fmtp = &genericFMTP{
|
||||
mimeType: mimeType,
|
||||
clockRate: clockRate,
|
||||
channels: channels,
|
||||
parameters: parameters,
|
||||
}
|
||||
}
|
||||
|
||||
return fmtp
|
||||
}
|
||||
|
||||
type genericFMTP struct {
|
||||
mimeType string
|
||||
clockRate uint32
|
||||
channels uint16
|
||||
parameters map[string]string
|
||||
}
|
||||
|
||||
func (g *genericFMTP) MimeType() string {
|
||||
return g.mimeType
|
||||
}
|
||||
|
||||
// Match returns true if g and b are compatible fmtp descriptions
|
||||
// The generic implementation is used for MimeTypes that are not defined.
|
||||
func (g *genericFMTP) Match(b FMTP) bool {
|
||||
fmtp, ok := b.(*genericFMTP)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
return strings.EqualFold(g.mimeType, fmtp.MimeType()) &&
|
||||
ClockRateEqual(g.mimeType, g.clockRate, fmtp.clockRate) &&
|
||||
ChannelsEqual(g.mimeType, g.channels, fmtp.channels) &&
|
||||
paramsEqual(g.parameters, fmtp.parameters)
|
||||
}
|
||||
|
||||
func (g *genericFMTP) Parameter(key string) (string, bool) {
|
||||
v, ok := g.parameters[key]
|
||||
|
||||
return v, ok
|
||||
}
|
||||
768
internal/fmtp/fmtp_test.go
Normal file
768
internal/fmtp/fmtp_test.go
Normal file
@@ -0,0 +1,768 @@
|
||||
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package fmtp
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestParseParameters(t *testing.T) {
|
||||
for _, ca := range []struct {
|
||||
name string
|
||||
line string
|
||||
parameters map[string]string
|
||||
}{
|
||||
{
|
||||
"one param",
|
||||
"key-name=value",
|
||||
map[string]string{
|
||||
"key-name": "value",
|
||||
},
|
||||
},
|
||||
{
|
||||
"one param with white spaces",
|
||||
"\tkey-name=value ",
|
||||
map[string]string{
|
||||
"key-name": "value",
|
||||
},
|
||||
},
|
||||
{
|
||||
"two params",
|
||||
"key-name=value;key2=value2",
|
||||
map[string]string{
|
||||
"key-name": "value",
|
||||
"key2": "value2",
|
||||
},
|
||||
},
|
||||
{
|
||||
"two params with white spaces",
|
||||
"key-name=value; \n\tkey2=value2 ",
|
||||
map[string]string{
|
||||
"key-name": "value",
|
||||
"key2": "value2",
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(ca.name, func(t *testing.T) {
|
||||
parameters := parseParameters(ca.line)
|
||||
assert.Equal(t, ca.parameters, parameters)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse(t *testing.T) {
|
||||
for _, ca := range []struct {
|
||||
name string
|
||||
mimeType string
|
||||
clockRate uint32
|
||||
channels uint16
|
||||
line string
|
||||
expected FMTP
|
||||
}{
|
||||
{
|
||||
"generic",
|
||||
"generic",
|
||||
90000,
|
||||
2,
|
||||
"key-name=value",
|
||||
&genericFMTP{
|
||||
mimeType: "generic",
|
||||
clockRate: 90000,
|
||||
channels: 2,
|
||||
parameters: map[string]string{
|
||||
"key-name": "value",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"generic case normalization",
|
||||
"generic",
|
||||
90000,
|
||||
2,
|
||||
"Key=value",
|
||||
&genericFMTP{
|
||||
mimeType: "generic",
|
||||
clockRate: 90000,
|
||||
channels: 2,
|
||||
parameters: map[string]string{
|
||||
"key": "value",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"h264",
|
||||
"video/h264",
|
||||
90000,
|
||||
0,
|
||||
"key-name=value",
|
||||
&h264FMTP{
|
||||
parameters: map[string]string{
|
||||
"key-name": "value",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"h265",
|
||||
"video/h265",
|
||||
90000,
|
||||
0,
|
||||
"key-name=value",
|
||||
&h265FMTP{
|
||||
parameters: map[string]string{
|
||||
"key-name": "value",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"vp9",
|
||||
"video/vp9",
|
||||
90000,
|
||||
0,
|
||||
"key-name=value",
|
||||
&vp9FMTP{
|
||||
parameters: map[string]string{
|
||||
"key-name": "value",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"av1",
|
||||
"video/av1",
|
||||
90000,
|
||||
0,
|
||||
"key-name=value",
|
||||
&av1FMTP{
|
||||
parameters: map[string]string{
|
||||
"key-name": "value",
|
||||
},
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(ca.name, func(t *testing.T) {
|
||||
f := Parse(ca.mimeType, ca.clockRate, ca.channels, ca.line)
|
||||
|
||||
assert.Equal(t, ca.expected, f)
|
||||
assert.Equal(t, ca.mimeType, f.MimeType())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatch(t *testing.T) { //nolint:maintidx
|
||||
consistString := map[bool]string{true: "consist", false: "inconsist"}
|
||||
parseH265 := func(line string) FMTP {
|
||||
return Parse("video/h265", 90000, 0, line)
|
||||
}
|
||||
|
||||
for _, ca := range []struct {
|
||||
name string
|
||||
a FMTP
|
||||
b FMTP
|
||||
consist bool
|
||||
}{
|
||||
{
|
||||
"generic equal",
|
||||
&genericFMTP{
|
||||
mimeType: "generic",
|
||||
parameters: map[string]string{
|
||||
"key1": "value1",
|
||||
"key2": "value2",
|
||||
"key3": "value3",
|
||||
},
|
||||
},
|
||||
&genericFMTP{
|
||||
mimeType: "generic",
|
||||
parameters: map[string]string{
|
||||
"key1": "value1",
|
||||
"key2": "value2",
|
||||
"key3": "value3",
|
||||
},
|
||||
},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"generic one extra param",
|
||||
&genericFMTP{
|
||||
mimeType: "generic",
|
||||
parameters: map[string]string{
|
||||
"key1": "value1",
|
||||
"key2": "value2",
|
||||
"key3": "value3",
|
||||
},
|
||||
},
|
||||
&genericFMTP{
|
||||
mimeType: "generic",
|
||||
parameters: map[string]string{
|
||||
"key1": "value1",
|
||||
"key2": "value2",
|
||||
"key3": "value3",
|
||||
"key4": "value4",
|
||||
},
|
||||
},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"generic inferred channels",
|
||||
&genericFMTP{
|
||||
mimeType: "generic",
|
||||
channels: 1,
|
||||
parameters: map[string]string{
|
||||
"key1": "value1",
|
||||
"key2": "value2",
|
||||
"key3": "value3",
|
||||
},
|
||||
},
|
||||
&genericFMTP{
|
||||
mimeType: "generic",
|
||||
parameters: map[string]string{
|
||||
"key1": "value1",
|
||||
"key2": "value2",
|
||||
"key3": "value3",
|
||||
},
|
||||
},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"generic inconsistent different kind",
|
||||
&genericFMTP{
|
||||
mimeType: "generic",
|
||||
parameters: map[string]string{
|
||||
"key1": "value1",
|
||||
"key2": "value2",
|
||||
"key3": "value3",
|
||||
},
|
||||
},
|
||||
&h264FMTP{},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"generic inconsistent different mime type",
|
||||
&genericFMTP{
|
||||
mimeType: "generic1",
|
||||
parameters: map[string]string{
|
||||
"key1": "value1",
|
||||
"key2": "value2",
|
||||
"key3": "value3",
|
||||
},
|
||||
},
|
||||
&genericFMTP{
|
||||
mimeType: "generic2",
|
||||
parameters: map[string]string{
|
||||
"key1": "value1",
|
||||
"key2": "value2",
|
||||
"key3": "value3",
|
||||
},
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"generic inconsistent different clock rate",
|
||||
&genericFMTP{
|
||||
mimeType: "generic",
|
||||
clockRate: 90000,
|
||||
parameters: map[string]string{
|
||||
"key1": "value1",
|
||||
"key2": "value2",
|
||||
"key3": "value3",
|
||||
},
|
||||
},
|
||||
&genericFMTP{
|
||||
mimeType: "generic",
|
||||
clockRate: 48000,
|
||||
parameters: map[string]string{
|
||||
"key1": "value1",
|
||||
"key2": "value2",
|
||||
"key3": "value3",
|
||||
},
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"generic inconsistent different channels",
|
||||
&genericFMTP{
|
||||
mimeType: "generic",
|
||||
clockRate: 90000,
|
||||
channels: 2,
|
||||
parameters: map[string]string{
|
||||
"key1": "value1",
|
||||
"key2": "value2",
|
||||
"key3": "value3",
|
||||
},
|
||||
},
|
||||
&genericFMTP{
|
||||
mimeType: "generic",
|
||||
clockRate: 90000,
|
||||
channels: 1,
|
||||
parameters: map[string]string{
|
||||
"key1": "value1",
|
||||
"key2": "value2",
|
||||
"key3": "value3",
|
||||
},
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"generic inconsistent different parameters",
|
||||
&genericFMTP{
|
||||
mimeType: "generic",
|
||||
parameters: map[string]string{
|
||||
"key1": "value1",
|
||||
"key2": "value2",
|
||||
"key3": "value3",
|
||||
},
|
||||
},
|
||||
&genericFMTP{
|
||||
mimeType: "generic",
|
||||
parameters: map[string]string{
|
||||
"key1": "value1",
|
||||
"key2": "different_value",
|
||||
"key3": "value3",
|
||||
},
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"h264 equal",
|
||||
&h264FMTP{
|
||||
parameters: map[string]string{
|
||||
"level-asymmetry-allowed": "1",
|
||||
"packetization-mode": "1",
|
||||
"profile-level-id": "42e01f",
|
||||
},
|
||||
},
|
||||
&h264FMTP{
|
||||
parameters: map[string]string{
|
||||
"level-asymmetry-allowed": "1",
|
||||
"packetization-mode": "1",
|
||||
"profile-level-id": "42e01f",
|
||||
},
|
||||
},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"h264 one extra param",
|
||||
&h264FMTP{
|
||||
parameters: map[string]string{
|
||||
"level-asymmetry-allowed": "1",
|
||||
"packetization-mode": "1",
|
||||
"profile-level-id": "42e01f",
|
||||
},
|
||||
},
|
||||
&h264FMTP{
|
||||
parameters: map[string]string{
|
||||
"packetization-mode": "1",
|
||||
"profile-level-id": "42e01f",
|
||||
},
|
||||
},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"h264 different profile level ids version",
|
||||
&h264FMTP{
|
||||
parameters: map[string]string{
|
||||
"packetization-mode": "1",
|
||||
"profile-level-id": "42e01f",
|
||||
},
|
||||
},
|
||||
&h264FMTP{
|
||||
parameters: map[string]string{
|
||||
"packetization-mode": "1",
|
||||
"profile-level-id": "42e029",
|
||||
},
|
||||
},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"h264 inconsistent different kind",
|
||||
&h264FMTP{
|
||||
parameters: map[string]string{
|
||||
"packetization-mode": "0",
|
||||
"profile-level-id": "42e01f",
|
||||
},
|
||||
},
|
||||
&genericFMTP{},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"h264 inconsistent different parameters",
|
||||
&h264FMTP{
|
||||
parameters: map[string]string{
|
||||
"packetization-mode": "0",
|
||||
"profile-level-id": "42e01f",
|
||||
},
|
||||
},
|
||||
&h264FMTP{
|
||||
parameters: map[string]string{
|
||||
"packetization-mode": "1",
|
||||
"profile-level-id": "42e01f",
|
||||
},
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"h264 inconsistent missing packetization mode",
|
||||
&h264FMTP{
|
||||
parameters: map[string]string{
|
||||
"packetization-mode": "0",
|
||||
"profile-level-id": "42e01f",
|
||||
},
|
||||
},
|
||||
&h264FMTP{
|
||||
parameters: map[string]string{
|
||||
"profile-level-id": "42e01f",
|
||||
},
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"h264 inconsistent missing profile level id",
|
||||
&h264FMTP{
|
||||
parameters: map[string]string{
|
||||
"packetization-mode": "1",
|
||||
"profile-level-id": "42e01f",
|
||||
},
|
||||
},
|
||||
&h264FMTP{
|
||||
parameters: map[string]string{
|
||||
"packetization-mode": "1",
|
||||
},
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"h264 inconsistent invalid profile level id",
|
||||
&h264FMTP{
|
||||
parameters: map[string]string{
|
||||
"packetization-mode": "1",
|
||||
"profile-level-id": "42e029",
|
||||
},
|
||||
},
|
||||
&h264FMTP{
|
||||
parameters: map[string]string{
|
||||
"packetization-mode": "1",
|
||||
"profile-level-id": "41e029",
|
||||
},
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"h265 downgraded level",
|
||||
parseH265("level-id=186;profile-id=1;tier-flag=0;tx-mode=SRST"),
|
||||
parseH265("level-id=93;profile-id=1;tier-flag=0;tx-mode=SRST"),
|
||||
true,
|
||||
},
|
||||
{
|
||||
"h265 inferred defaults and ignored sprop parameter",
|
||||
parseH265(""),
|
||||
parseH265("profile-id=1;tier-flag=0;tx-mode=srst;sprop-vps=ignored"),
|
||||
true,
|
||||
},
|
||||
{
|
||||
"h265 inconsistent different kind",
|
||||
parseH265(""),
|
||||
&genericFMTP{},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"h265 inconsistent different profile",
|
||||
parseH265("profile-id=1"),
|
||||
parseH265("profile-id=2"),
|
||||
false,
|
||||
},
|
||||
{
|
||||
"h265 inconsistent different tier",
|
||||
parseH265("tier-flag=0"),
|
||||
parseH265("tier-flag=1"),
|
||||
false,
|
||||
},
|
||||
{
|
||||
"h265 inconsistent different transmission mode",
|
||||
parseH265("tx-mode=SRST"),
|
||||
parseH265("tx-mode=MRST"),
|
||||
false,
|
||||
},
|
||||
{
|
||||
"vp9 equal",
|
||||
&vp9FMTP{
|
||||
parameters: map[string]string{
|
||||
"profile-id": "1",
|
||||
},
|
||||
},
|
||||
&vp9FMTP{
|
||||
parameters: map[string]string{
|
||||
"profile-id": "1",
|
||||
},
|
||||
},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"vp9 missing profile",
|
||||
&vp9FMTP{
|
||||
parameters: map[string]string{},
|
||||
},
|
||||
&vp9FMTP{
|
||||
parameters: map[string]string{},
|
||||
},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"vp9 inferred profile",
|
||||
&vp9FMTP{
|
||||
parameters: map[string]string{
|
||||
"profile-id": "0",
|
||||
},
|
||||
},
|
||||
&vp9FMTP{
|
||||
parameters: map[string]string{},
|
||||
},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"vp9 inconsistent different kind",
|
||||
&vp9FMTP{
|
||||
parameters: map[string]string{
|
||||
"profile-id": "0",
|
||||
},
|
||||
},
|
||||
&genericFMTP{},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"vp9 inconsistent different profile",
|
||||
&vp9FMTP{
|
||||
parameters: map[string]string{
|
||||
"profile-id": "0",
|
||||
},
|
||||
},
|
||||
&vp9FMTP{
|
||||
parameters: map[string]string{
|
||||
"profile-id": "1",
|
||||
},
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"vp9 inconsistent different inferred profile",
|
||||
&vp9FMTP{
|
||||
parameters: map[string]string{},
|
||||
},
|
||||
&vp9FMTP{
|
||||
parameters: map[string]string{
|
||||
"profile-id": "1",
|
||||
},
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"av1 equal",
|
||||
&av1FMTP{
|
||||
parameters: map[string]string{
|
||||
"profile": "1",
|
||||
},
|
||||
},
|
||||
&av1FMTP{
|
||||
parameters: map[string]string{
|
||||
"profile": "1",
|
||||
},
|
||||
},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"av1 missing profile",
|
||||
&av1FMTP{
|
||||
parameters: map[string]string{},
|
||||
},
|
||||
&av1FMTP{
|
||||
parameters: map[string]string{},
|
||||
},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"av1 inferred profile",
|
||||
&av1FMTP{
|
||||
parameters: map[string]string{
|
||||
"profile": "0",
|
||||
},
|
||||
},
|
||||
&av1FMTP{
|
||||
parameters: map[string]string{},
|
||||
},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"av1 inconsistent different kind",
|
||||
&av1FMTP{
|
||||
parameters: map[string]string{
|
||||
"profile": "0",
|
||||
},
|
||||
},
|
||||
&genericFMTP{},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"av1 inconsistent different profile",
|
||||
&av1FMTP{
|
||||
parameters: map[string]string{
|
||||
"profile": "0",
|
||||
},
|
||||
},
|
||||
&av1FMTP{
|
||||
parameters: map[string]string{
|
||||
"profile": "1",
|
||||
},
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"av1 inconsistent different inferred profile",
|
||||
&av1FMTP{
|
||||
parameters: map[string]string{},
|
||||
},
|
||||
&av1FMTP{
|
||||
parameters: map[string]string{
|
||||
"profile": "1",
|
||||
},
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"pcmu channels",
|
||||
&genericFMTP{
|
||||
mimeType: "audio/pcmu",
|
||||
clockRate: 8000,
|
||||
channels: 0,
|
||||
parameters: map[string]string{
|
||||
"key1": "value1",
|
||||
"key2": "value2",
|
||||
"key3": "value3",
|
||||
},
|
||||
},
|
||||
&genericFMTP{
|
||||
mimeType: "audio/pcmu",
|
||||
clockRate: 8000,
|
||||
channels: 1,
|
||||
parameters: map[string]string{
|
||||
"key1": "value1",
|
||||
"key2": "value2",
|
||||
"key3": "value3",
|
||||
},
|
||||
},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"pcmu inconsistent channels",
|
||||
&genericFMTP{
|
||||
mimeType: "audio/pcmu",
|
||||
clockRate: 8000,
|
||||
channels: 0,
|
||||
parameters: map[string]string{
|
||||
"key1": "value1",
|
||||
"key2": "value2",
|
||||
"key3": "value3",
|
||||
},
|
||||
},
|
||||
&genericFMTP{
|
||||
mimeType: "audio/pcmu",
|
||||
clockRate: 8000,
|
||||
channels: 2,
|
||||
parameters: map[string]string{
|
||||
"key1": "value1",
|
||||
"key2": "value2",
|
||||
"key3": "value3",
|
||||
},
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"pcmu clockrate",
|
||||
&genericFMTP{
|
||||
mimeType: "audio/pcmu",
|
||||
clockRate: 0,
|
||||
channels: 0,
|
||||
parameters: map[string]string{
|
||||
"key1": "value1",
|
||||
"key2": "value2",
|
||||
"key3": "value3",
|
||||
},
|
||||
},
|
||||
&genericFMTP{
|
||||
mimeType: "audio/pcmu",
|
||||
clockRate: 8000,
|
||||
channels: 0,
|
||||
parameters: map[string]string{
|
||||
"key1": "value1",
|
||||
"key2": "value2",
|
||||
"key3": "value3",
|
||||
},
|
||||
},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"pcmu inconsistent clockrate",
|
||||
&genericFMTP{
|
||||
mimeType: "audio/pcmu",
|
||||
clockRate: 0,
|
||||
channels: 0,
|
||||
parameters: map[string]string{
|
||||
"key1": "value1",
|
||||
"key2": "value2",
|
||||
"key3": "value3",
|
||||
},
|
||||
},
|
||||
&genericFMTP{
|
||||
mimeType: "audio/pcmu",
|
||||
clockRate: 16000,
|
||||
channels: 0,
|
||||
parameters: map[string]string{
|
||||
"key1": "value1",
|
||||
"key2": "value2",
|
||||
"key3": "value3",
|
||||
},
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"opus clockrate",
|
||||
&genericFMTP{
|
||||
mimeType: "audio/opus",
|
||||
clockRate: 0,
|
||||
channels: 0,
|
||||
parameters: map[string]string{
|
||||
"key1": "value1",
|
||||
"key2": "value2",
|
||||
"key3": "value3",
|
||||
},
|
||||
},
|
||||
&genericFMTP{
|
||||
mimeType: "audio/opus",
|
||||
clockRate: 48000,
|
||||
channels: 2,
|
||||
parameters: map[string]string{
|
||||
"key1": "value1",
|
||||
"key2": "value2",
|
||||
"key3": "value3",
|
||||
},
|
||||
},
|
||||
true,
|
||||
},
|
||||
} {
|
||||
t.Run(ca.name, func(t *testing.T) {
|
||||
c := ca.a.Match(ca.b)
|
||||
assert.Equal(t, ca.consist, c)
|
||||
assert.Equal(
|
||||
t, ca.consist, c,
|
||||
"'%s' and '%s' are expected to be %s, but treated as %s",
|
||||
ca.a, ca.b, consistString[ca.consist], consistString[c],
|
||||
)
|
||||
|
||||
c = ca.b.Match(ca.a)
|
||||
assert.Equalf(
|
||||
t, ca.consist, c,
|
||||
"'%s' and '%s' are expected to be %s, but treated as %s",
|
||||
ca.b, ca.a, consistString[ca.consist], consistString[c],
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
86
internal/fmtp/h264.go
Normal file
86
internal/fmtp/h264.go
Normal file
@@ -0,0 +1,86 @@
|
||||
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package fmtp
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
)
|
||||
|
||||
func profileLevelIDMatches(a, b string) bool {
|
||||
aa, err := hex.DecodeString(a)
|
||||
if err != nil || len(aa) < 2 {
|
||||
return false
|
||||
}
|
||||
bb, err := hex.DecodeString(b)
|
||||
if err != nil || len(bb) < 2 {
|
||||
return false
|
||||
}
|
||||
|
||||
return aa[0] == bb[0] && aa[1] == bb[1]
|
||||
}
|
||||
|
||||
type h264FMTP struct {
|
||||
parameters map[string]string
|
||||
}
|
||||
|
||||
func (h *h264FMTP) MimeType() string {
|
||||
return "video/h264"
|
||||
}
|
||||
|
||||
// Match returns true if h and b are compatible fmtp descriptions
|
||||
// Based on RFC6184 Section 8.2.2:
|
||||
//
|
||||
// The parameters identifying a media format configuration for H.264
|
||||
// are profile-level-id and packetization-mode. These media format
|
||||
// configuration parameters (except for the level part of profile-
|
||||
// level-id) MUST be used symmetrically; that is, the answerer MUST
|
||||
// either maintain all configuration parameters or remove the media
|
||||
// format (payload type) completely if one or more of the parameter
|
||||
// values are not supported.
|
||||
// Informative note: The requirement for symmetric use does not
|
||||
// apply for the level part of profile-level-id and does not apply
|
||||
// for the other stream properties and capability parameters.
|
||||
func (h *h264FMTP) Match(b FMTP) bool {
|
||||
fmtp, ok := b.(*h264FMTP)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
// test packetization-mode
|
||||
hpmode, hok := h.parameters["packetization-mode"]
|
||||
if !hok {
|
||||
return false
|
||||
}
|
||||
cpmode, cok := fmtp.parameters["packetization-mode"]
|
||||
if !cok {
|
||||
return false
|
||||
}
|
||||
|
||||
if hpmode != cpmode {
|
||||
return false
|
||||
}
|
||||
|
||||
// test profile-level-id
|
||||
hplid, hok := h.parameters["profile-level-id"]
|
||||
if !hok {
|
||||
return false
|
||||
}
|
||||
|
||||
cplid, cok := fmtp.parameters["profile-level-id"]
|
||||
if !cok {
|
||||
return false
|
||||
}
|
||||
|
||||
if !profileLevelIDMatches(hplid, cplid) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (h *h264FMTP) Parameter(key string) (string, bool) {
|
||||
v, ok := h.parameters[key]
|
||||
|
||||
return v, ok
|
||||
}
|
||||
57
internal/fmtp/h265.go
Normal file
57
internal/fmtp/h265.go
Normal file
@@ -0,0 +1,57 @@
|
||||
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package fmtp
|
||||
|
||||
import "strings"
|
||||
|
||||
type h265FMTP struct {
|
||||
parameters map[string]string
|
||||
}
|
||||
|
||||
func (h *h265FMTP) MimeType() string {
|
||||
return "video/h265"
|
||||
}
|
||||
|
||||
// Match returns true if h and b describe compatible H.265 media formats.
|
||||
// draft-ietf-avtcore-hevc-webrtc-09 Section 2.1 defines profile-id,
|
||||
// tier-flag, and tx-mode as the H.265 media format parameters for WebRTC:
|
||||
// https://datatracker.ietf.org/doc/html/draft-ietf-avtcore-hevc-webrtc-09#section-2.1
|
||||
//
|
||||
// level-id is intentionally not compared, as required by W3C WebRTC candidate
|
||||
// correction 52:
|
||||
// https://www.w3.org/TR/webrtc/#codec-match-without-level-id
|
||||
// RFC 7798 Section 7.2.2 permits an answer to lower it, so treating it as a
|
||||
// symmetric parameter would reject a valid offer/answer exchange:
|
||||
// https://www.rfc-editor.org/rfc/rfc7798.html#section-7.2.2
|
||||
func (h *h265FMTP) Match(b FMTP) bool {
|
||||
fmtp, ok := b.(*h265FMTP)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
return strings.EqualFold(
|
||||
h.parameterOrDefault("profile-id", "1"),
|
||||
fmtp.parameterOrDefault("profile-id", "1"),
|
||||
) && strings.EqualFold(
|
||||
h.parameterOrDefault("tier-flag", "0"),
|
||||
fmtp.parameterOrDefault("tier-flag", "0"),
|
||||
) && strings.EqualFold(
|
||||
h.parameterOrDefault("tx-mode", "SRST"),
|
||||
fmtp.parameterOrDefault("tx-mode", "SRST"),
|
||||
)
|
||||
}
|
||||
|
||||
func (h *h265FMTP) Parameter(key string) (string, bool) {
|
||||
v, ok := h.parameters[key]
|
||||
|
||||
return v, ok
|
||||
}
|
||||
|
||||
func (h *h265FMTP) parameterOrDefault(key, defaultValue string) string {
|
||||
if value, ok := h.Parameter(key); ok {
|
||||
return value
|
||||
}
|
||||
|
||||
return defaultValue
|
||||
}
|
||||
42
internal/fmtp/vp9.go
Normal file
42
internal/fmtp/vp9.go
Normal file
@@ -0,0 +1,42 @@
|
||||
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package fmtp
|
||||
|
||||
type vp9FMTP struct {
|
||||
parameters map[string]string
|
||||
}
|
||||
|
||||
func (h *vp9FMTP) MimeType() string {
|
||||
return "video/vp9"
|
||||
}
|
||||
|
||||
func (h *vp9FMTP) Match(b FMTP) bool {
|
||||
c, ok := b.(*vp9FMTP)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
// RTP Payload Format for VP9 Video - draft-ietf-payload-vp9-16
|
||||
// https://datatracker.ietf.org/doc/html/draft-ietf-payload-vp9-16
|
||||
// If no profile-id is present, Profile 0 MUST be inferred
|
||||
hProfileID, ok := h.parameters["profile-id"]
|
||||
if !ok {
|
||||
hProfileID = "0"
|
||||
}
|
||||
cProfileID, ok := c.parameters["profile-id"]
|
||||
if !ok {
|
||||
cProfileID = "0"
|
||||
}
|
||||
if hProfileID != cProfileID {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (h *vp9FMTP) Parameter(key string) (string, bool) {
|
||||
v, ok := h.parameters[key]
|
||||
|
||||
return v, ok
|
||||
}
|
||||
107
internal/mux/endpoint.go
Normal file
107
internal/mux/endpoint.go
Normal file
@@ -0,0 +1,107 @@
|
||||
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mux
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/pion/ice/v4"
|
||||
"github.com/pion/transport/v4/packetio"
|
||||
)
|
||||
|
||||
// Endpoint implements net.Conn. It is used to read muxed packets.
|
||||
type Endpoint struct {
|
||||
mux *Mux
|
||||
buffer *packetio.Buffer
|
||||
onClose func()
|
||||
}
|
||||
|
||||
// Close unregisters the endpoint from the Mux.
|
||||
func (e *Endpoint) Close() (err error) {
|
||||
if e.onClose != nil {
|
||||
e.onClose()
|
||||
}
|
||||
|
||||
if err = e.close(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
e.mux.RemoveEndpoint(e)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Endpoint) close() error {
|
||||
return e.buffer.Close()
|
||||
}
|
||||
|
||||
// Read reads a packet of len(p) bytes from the underlying conn
|
||||
// that are matched by the associated MuxFunc.
|
||||
func (e *Endpoint) Read(p []byte) (int, error) {
|
||||
return e.buffer.Read(p)
|
||||
}
|
||||
|
||||
// ReadFrom reads a packet of len(p) bytes from the underlying conn
|
||||
// that are matched by the associated MuxFunc.
|
||||
func (e *Endpoint) ReadFrom(p []byte) (int, net.Addr, error) {
|
||||
i, err := e.Read(p)
|
||||
|
||||
return i, nil, err
|
||||
}
|
||||
|
||||
// Write writes len(p) bytes to the underlying conn.
|
||||
func (e *Endpoint) Write(p []byte) (int, error) {
|
||||
n, err := e.mux.nextConn.Write(p)
|
||||
if errors.Is(err, ice.ErrNoCandidatePairs) {
|
||||
return 0, nil
|
||||
} else if errors.Is(err, ice.ErrClosed) {
|
||||
return 0, io.ErrClosedPipe
|
||||
}
|
||||
|
||||
return n, err
|
||||
}
|
||||
|
||||
// WriteTo writes len(p) bytes to the underlying conn.
|
||||
func (e *Endpoint) WriteTo(p []byte, _ net.Addr) (int, error) {
|
||||
return e.Write(p)
|
||||
}
|
||||
|
||||
// LocalAddr returns the local network address, if known.
|
||||
func (e *Endpoint) LocalAddr() net.Addr {
|
||||
return e.mux.nextConn.LocalAddr()
|
||||
}
|
||||
|
||||
// RemoteAddr returns the remote network address, if known.
|
||||
func (e *Endpoint) RemoteAddr() net.Addr {
|
||||
return e.mux.nextConn.RemoteAddr()
|
||||
}
|
||||
|
||||
// SetDeadline sets the read and write deadlines on the shared underlying
|
||||
// connection. Because the connection is shared, this applies to all endpoints
|
||||
// on the mux. Per-endpoint read deadlines can be set with SetReadDeadline.
|
||||
func (e *Endpoint) SetDeadline(t time.Time) error {
|
||||
return e.mux.nextConn.SetDeadline(t)
|
||||
}
|
||||
|
||||
// SetReadDeadline sets the read deadline for this Endpoint's internal
|
||||
// packet buffer. This timeout applies only to reads from this Endpoint,
|
||||
// not to the shared underlying connection.
|
||||
func (e *Endpoint) SetReadDeadline(t time.Time) error {
|
||||
return e.buffer.SetReadDeadline(t)
|
||||
}
|
||||
|
||||
// SetWriteDeadline sets the write deadline on the shared underlying connection.
|
||||
// Because the connection is shared, this applies to all endpoints on the mux.
|
||||
func (e *Endpoint) SetWriteDeadline(t time.Time) error {
|
||||
return e.mux.nextConn.SetWriteDeadline(t)
|
||||
}
|
||||
|
||||
// SetOnClose is a user set callback that
|
||||
// will be executed when `Close` is called.
|
||||
func (e *Endpoint) SetOnClose(onClose func()) {
|
||||
e.onClose = onClose
|
||||
}
|
||||
216
internal/mux/mux.go
Normal file
216
internal/mux/mux.go
Normal file
@@ -0,0 +1,216 @@
|
||||
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package mux multiplexes packets on a single socket (RFC7983)
|
||||
package mux
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"sync"
|
||||
|
||||
"github.com/pion/ice/v4"
|
||||
"github.com/pion/logging"
|
||||
"github.com/pion/transport/v4/packetio"
|
||||
)
|
||||
|
||||
const (
|
||||
// The maximum amount of data that can be buffered before returning errors.
|
||||
maxBufferSize = 1000 * 1000 // 1MB
|
||||
|
||||
// How many total pending packets can be cached.
|
||||
maxPendingPackets = 15
|
||||
)
|
||||
|
||||
// Config collects the arguments to mux.Mux construction into
|
||||
// a single structure.
|
||||
type Config struct {
|
||||
Conn net.Conn
|
||||
BufferSize int
|
||||
LoggerFactory logging.LoggerFactory
|
||||
}
|
||||
|
||||
// Mux allows multiplexing.
|
||||
type Mux struct {
|
||||
nextConn net.Conn
|
||||
bufferSize int
|
||||
lock sync.Mutex
|
||||
endpoints map[*Endpoint]MatchFunc
|
||||
isClosed bool
|
||||
|
||||
pendingPackets [][]byte
|
||||
|
||||
closedCh chan struct{}
|
||||
log logging.LeveledLogger
|
||||
}
|
||||
|
||||
// NewMux creates a new Mux.
|
||||
func NewMux(config Config) *Mux {
|
||||
mux := &Mux{
|
||||
nextConn: config.Conn,
|
||||
endpoints: make(map[*Endpoint]MatchFunc),
|
||||
bufferSize: config.BufferSize,
|
||||
closedCh: make(chan struct{}),
|
||||
log: config.LoggerFactory.NewLogger("mux"),
|
||||
}
|
||||
|
||||
go mux.readLoop()
|
||||
|
||||
return mux
|
||||
}
|
||||
|
||||
// NewEndpoint creates a new Endpoint.
|
||||
func (m *Mux) NewEndpoint(matchFunc MatchFunc) *Endpoint {
|
||||
endpoint := &Endpoint{
|
||||
mux: m,
|
||||
buffer: packetio.NewBuffer(),
|
||||
}
|
||||
|
||||
// Set a maximum size of the buffer in bytes.
|
||||
endpoint.buffer.SetLimitSize(maxBufferSize)
|
||||
|
||||
m.lock.Lock()
|
||||
m.endpoints[endpoint] = matchFunc
|
||||
m.lock.Unlock()
|
||||
|
||||
go m.handlePendingPackets(endpoint, matchFunc)
|
||||
|
||||
return endpoint
|
||||
}
|
||||
|
||||
// RemoveEndpoint removes an endpoint from the Mux.
|
||||
func (m *Mux) RemoveEndpoint(e *Endpoint) {
|
||||
m.lock.Lock()
|
||||
defer m.lock.Unlock()
|
||||
delete(m.endpoints, e)
|
||||
}
|
||||
|
||||
// Close closes the Mux and all associated Endpoints.
|
||||
func (m *Mux) Close() error {
|
||||
m.lock.Lock()
|
||||
for e := range m.endpoints {
|
||||
if err := e.close(); err != nil {
|
||||
m.lock.Unlock()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
delete(m.endpoints, e)
|
||||
}
|
||||
m.isClosed = true
|
||||
m.lock.Unlock()
|
||||
|
||||
err := m.nextConn.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Wait for readLoop to end
|
||||
<-m.closedCh
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Mux) readLoop() {
|
||||
defer func() {
|
||||
close(m.closedCh)
|
||||
}()
|
||||
|
||||
buf := make([]byte, m.bufferSize)
|
||||
for {
|
||||
n, err := m.nextConn.Read(buf)
|
||||
switch {
|
||||
case errors.Is(err, io.EOF), errors.Is(err, ice.ErrClosed):
|
||||
return
|
||||
case errors.Is(err, io.ErrShortBuffer), errors.Is(err, packetio.ErrTimeout):
|
||||
m.log.Errorf("mux: failed to read from packetio.Buffer %s", err.Error())
|
||||
|
||||
continue
|
||||
case err != nil:
|
||||
m.log.Errorf("mux: ending readLoop packetio.Buffer error %s", err.Error())
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if err = m.dispatch(buf[:n]); err != nil {
|
||||
if errors.Is(err, io.ErrClosedPipe) {
|
||||
// if the buffer was closed, that's not an error we care to report
|
||||
return
|
||||
}
|
||||
m.log.Errorf("mux: ending readLoop dispatch error %s", err.Error())
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Mux) dispatch(buf []byte) error {
|
||||
if len(buf) == 0 {
|
||||
m.log.Warnf("Warning: mux: unable to dispatch zero length packet")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var endpoint *Endpoint
|
||||
|
||||
m.lock.Lock()
|
||||
for e, f := range m.endpoints {
|
||||
if f(buf) {
|
||||
endpoint = e
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
if endpoint == nil {
|
||||
defer m.lock.Unlock()
|
||||
|
||||
if !m.isClosed {
|
||||
if len(m.pendingPackets) >= maxPendingPackets {
|
||||
m.log.Warnf(
|
||||
"Warning: mux: no endpoint for packet starting with %d, not adding to queue size(%d)",
|
||||
buf[0], //nolint:gosec // G602, false positive?
|
||||
len(m.pendingPackets),
|
||||
)
|
||||
} else {
|
||||
m.log.Warnf(
|
||||
"Warning: mux: no endpoint for packet starting with %d, adding to queue size(%d)",
|
||||
buf[0], //nolint:gosec // G602, false positive?
|
||||
len(m.pendingPackets),
|
||||
)
|
||||
m.pendingPackets = append(m.pendingPackets, append([]byte{}, buf...))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
m.lock.Unlock()
|
||||
_, err := endpoint.buffer.Write(buf)
|
||||
|
||||
// Expected when bytes are received faster than the endpoint can process them (#2152, #2180)
|
||||
if errors.Is(err, packetio.ErrFull) {
|
||||
m.log.Infof("mux: endpoint buffer is full, dropping packet")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *Mux) handlePendingPackets(endpoint *Endpoint, matchFunc MatchFunc) {
|
||||
m.lock.Lock()
|
||||
defer m.lock.Unlock()
|
||||
|
||||
pendingPackets := make([][]byte, 0, len(m.pendingPackets))
|
||||
for _, buf := range m.pendingPackets {
|
||||
if matchFunc(buf) {
|
||||
if _, err := endpoint.buffer.Write(buf); err != nil {
|
||||
m.log.Warnf("Warning: mux: error writing packet to endpoint from pending queue: %s", err)
|
||||
}
|
||||
} else {
|
||||
pendingPackets = append(pendingPackets, buf)
|
||||
}
|
||||
}
|
||||
m.pendingPackets = pendingPackets
|
||||
}
|
||||
317
internal/mux/mux_test.go
Normal file
317
internal/mux/mux_test.go
Normal file
@@ -0,0 +1,317 @@
|
||||
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mux
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pion/logging"
|
||||
"github.com/pion/transport/v4/packetio"
|
||||
"github.com/pion/transport/v4/test"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const testPipeBufferSize = 8192
|
||||
|
||||
func TestNoEndpoints(t *testing.T) {
|
||||
// In memory pipe
|
||||
ca, cb := net.Pipe()
|
||||
require.NoError(t, cb.Close())
|
||||
|
||||
mux := NewMux(Config{
|
||||
Conn: ca,
|
||||
BufferSize: testPipeBufferSize,
|
||||
LoggerFactory: logging.NewDefaultLoggerFactory(),
|
||||
})
|
||||
require.NoError(t, mux.dispatch(make([]byte, 1)))
|
||||
require.NoError(t, mux.Close())
|
||||
require.NoError(t, ca.Close())
|
||||
}
|
||||
|
||||
func TestEndpointDeadline(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setDeadline func(*Endpoint, time.Time) error
|
||||
}{
|
||||
{
|
||||
name: "SetReadDeadline",
|
||||
setDeadline: (*Endpoint).SetReadDeadline,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
lim := test.TimeOut(2 * time.Second)
|
||||
defer lim.Stop()
|
||||
|
||||
ca, cb := net.Pipe()
|
||||
defer func() {
|
||||
_ = ca.Close()
|
||||
_ = cb.Close()
|
||||
}()
|
||||
|
||||
mux := NewMux(Config{
|
||||
Conn: ca,
|
||||
BufferSize: testPipeBufferSize,
|
||||
LoggerFactory: logging.NewDefaultLoggerFactory(),
|
||||
})
|
||||
|
||||
endpoint := mux.NewEndpoint(MatchAll)
|
||||
require.NoError(t, tt.setDeadline(endpoint, time.Now().Add(10*time.Millisecond)))
|
||||
|
||||
_, err := endpoint.Read(make([]byte, testPipeBufferSize))
|
||||
require.Error(t, err)
|
||||
var netErr interface{ Timeout() bool }
|
||||
require.ErrorAs(t, err, &netErr)
|
||||
require.True(t, netErr.Timeout())
|
||||
|
||||
require.NoError(t, mux.Close())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type writeDeadlineConn struct {
|
||||
net.Conn
|
||||
writeDeadline time.Time
|
||||
}
|
||||
|
||||
func (w *writeDeadlineConn) SetWriteDeadline(t time.Time) error {
|
||||
w.writeDeadline = t
|
||||
|
||||
if w.Conn == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return w.Conn.SetWriteDeadline(t)
|
||||
}
|
||||
|
||||
func TestEndpointSetWriteDeadline(t *testing.T) {
|
||||
lim := test.TimeOut(2 * time.Second)
|
||||
defer lim.Stop()
|
||||
|
||||
ca, cb := net.Pipe()
|
||||
defer func() {
|
||||
_ = cb.Close()
|
||||
}()
|
||||
|
||||
rdConn := &writeDeadlineConn{Conn: ca}
|
||||
|
||||
mux := NewMux(Config{
|
||||
Conn: rdConn,
|
||||
BufferSize: testPipeBufferSize,
|
||||
LoggerFactory: logging.NewDefaultLoggerFactory(),
|
||||
})
|
||||
|
||||
endpoint := mux.NewEndpoint(MatchAll)
|
||||
deadline := time.Now().Add(10 * time.Millisecond)
|
||||
require.NoError(t, endpoint.SetWriteDeadline(deadline))
|
||||
require.WithinDuration(t, deadline, rdConn.writeDeadline, time.Millisecond)
|
||||
|
||||
require.NoError(t, mux.Close())
|
||||
}
|
||||
|
||||
type writeDeadlineErrorConn struct {
|
||||
net.Conn
|
||||
deadlineErr error
|
||||
}
|
||||
|
||||
func (w *writeDeadlineErrorConn) SetDeadline(t time.Time) error {
|
||||
if w.deadlineErr != nil {
|
||||
return w.deadlineErr
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var errDeadlineTest = errors.New("write deadline failed")
|
||||
|
||||
func TestEndpointSetDeadlineWriteDeadlineError(t *testing.T) {
|
||||
lim := test.TimeOut(2 * time.Second)
|
||||
defer lim.Stop()
|
||||
|
||||
ca, cb := net.Pipe()
|
||||
defer func() {
|
||||
_ = ca.Close()
|
||||
_ = cb.Close()
|
||||
}()
|
||||
|
||||
rdConn := &writeDeadlineErrorConn{Conn: ca, deadlineErr: errDeadlineTest}
|
||||
|
||||
mux := NewMux(Config{
|
||||
Conn: rdConn,
|
||||
BufferSize: testPipeBufferSize,
|
||||
LoggerFactory: logging.NewDefaultLoggerFactory(),
|
||||
})
|
||||
|
||||
endpoint := mux.NewEndpoint(MatchAll)
|
||||
err := endpoint.SetDeadline(time.Now().Add(10 * time.Millisecond))
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, errDeadlineTest)
|
||||
|
||||
require.NoError(t, mux.Close())
|
||||
require.NoError(t, ca.Close())
|
||||
require.NoError(t, rdConn.Close())
|
||||
}
|
||||
|
||||
type muxErrorConnReadResult struct {
|
||||
err error
|
||||
data []byte
|
||||
}
|
||||
|
||||
// muxErrorConn.
|
||||
type muxErrorConn struct {
|
||||
net.Conn
|
||||
readResults []muxErrorConnReadResult
|
||||
}
|
||||
|
||||
func (m *muxErrorConn) Read(b []byte) (n int, err error) {
|
||||
err = m.readResults[0].err
|
||||
copy(b, m.readResults[0].data)
|
||||
n = len(m.readResults[0].data)
|
||||
|
||||
m.readResults = m.readResults[1:]
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
/*
|
||||
Don't end the mux readLoop for packetio.ErrTimeout or io.ErrShortBuffer, assert the following
|
||||
|
||||
- io.ErrShortBuffer and packetio.ErrTimeout don't end the read loop
|
||||
|
||||
- io.EOF ends the loop
|
||||
|
||||
pion/webrtc#1720
|
||||
*/
|
||||
func TestNonFatalRead(t *testing.T) {
|
||||
// Limit runtime in case of deadlocks
|
||||
lim := test.TimeOut(time.Second * 20)
|
||||
defer lim.Stop()
|
||||
|
||||
expectedData := []byte("expectedData")
|
||||
|
||||
// In memory pipe
|
||||
ca, cb := net.Pipe()
|
||||
require.NoError(t, cb.Close())
|
||||
|
||||
conn := &muxErrorConn{ca, []muxErrorConnReadResult{
|
||||
// Non-fatal timeout error
|
||||
{packetio.ErrTimeout, nil},
|
||||
{nil, expectedData},
|
||||
{io.ErrShortBuffer, nil},
|
||||
{nil, expectedData},
|
||||
{io.EOF, nil},
|
||||
}}
|
||||
|
||||
mux := NewMux(Config{
|
||||
Conn: conn,
|
||||
BufferSize: testPipeBufferSize,
|
||||
LoggerFactory: logging.NewDefaultLoggerFactory(),
|
||||
})
|
||||
|
||||
e := mux.NewEndpoint(MatchAll)
|
||||
|
||||
buff := make([]byte, testPipeBufferSize)
|
||||
n, err := e.Read(buff)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, buff[:n], expectedData)
|
||||
|
||||
n, err = e.Read(buff)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, buff[:n], expectedData)
|
||||
|
||||
<-mux.closedCh
|
||||
require.NoError(t, mux.Close())
|
||||
require.NoError(t, ca.Close())
|
||||
}
|
||||
|
||||
// If a endpoint returns packetio.ErrFull it is a non-fatal error and shouldn't cause
|
||||
// the mux to be destroyed
|
||||
// pion/webrtc#2180
|
||||
// .
|
||||
func TestNonFatalDispatch(t *testing.T) {
|
||||
in, out := net.Pipe()
|
||||
|
||||
mux := NewMux(Config{
|
||||
Conn: out,
|
||||
LoggerFactory: logging.NewDefaultLoggerFactory(),
|
||||
BufferSize: 1500,
|
||||
})
|
||||
|
||||
e := mux.NewEndpoint(MatchSRTP)
|
||||
e.buffer.SetLimitSize(1)
|
||||
|
||||
for i := 0; i <= 25; i++ {
|
||||
srtpPacket := []byte{128, 1, 2, 3, 4}
|
||||
_, err := in.Write(srtpPacket)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
require.NoError(t, mux.Close())
|
||||
require.NoError(t, in.Close())
|
||||
require.NoError(t, out.Close())
|
||||
}
|
||||
|
||||
func BenchmarkDispatch(b *testing.B) {
|
||||
mux := &Mux{
|
||||
endpoints: make(map[*Endpoint]MatchFunc),
|
||||
log: logging.NewDefaultLoggerFactory().NewLogger("mux"),
|
||||
}
|
||||
|
||||
endpoint := mux.NewEndpoint(MatchSRTP)
|
||||
mux.NewEndpoint(MatchSRTCP)
|
||||
|
||||
buf := []byte{128, 1, 2, 3, 4}
|
||||
buf2 := make([]byte, 1200)
|
||||
|
||||
b.StartTimer()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
err := mux.dispatch(buf)
|
||||
if err != nil {
|
||||
b.Errorf("dispatch: %v", err)
|
||||
}
|
||||
_, err = endpoint.buffer.Read(buf2)
|
||||
if err != nil {
|
||||
b.Errorf("read: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPendingQueue(t *testing.T) {
|
||||
factory := logging.NewDefaultLoggerFactory()
|
||||
factory.DefaultLogLevel = logging.LogLevelDebug
|
||||
mux := &Mux{
|
||||
endpoints: make(map[*Endpoint]MatchFunc),
|
||||
log: factory.NewLogger("mux"),
|
||||
}
|
||||
|
||||
// Assert empty packets don't end up in queue
|
||||
require.NoError(t, mux.dispatch([]byte{}))
|
||||
require.Equal(t, len(mux.pendingPackets), 0)
|
||||
|
||||
// Test Happy Case
|
||||
inBuffer := []byte{20, 1, 2, 3, 4}
|
||||
outBuffer := make([]byte, len(inBuffer))
|
||||
|
||||
require.NoError(t, mux.dispatch(inBuffer))
|
||||
|
||||
endpoint := mux.NewEndpoint(MatchDTLS)
|
||||
require.NotNil(t, endpoint)
|
||||
|
||||
_, err := endpoint.Read(outBuffer)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, outBuffer, inBuffer)
|
||||
|
||||
// Assert limit on pendingPackets
|
||||
for i := 0; i <= 100; i++ {
|
||||
require.NoError(t, mux.dispatch([]byte{64, 65, 66}))
|
||||
}
|
||||
require.Equal(t, len(mux.pendingPackets), maxPendingPackets)
|
||||
}
|
||||
67
internal/mux/muxfunc.go
Normal file
67
internal/mux/muxfunc.go
Normal file
@@ -0,0 +1,67 @@
|
||||
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mux
|
||||
|
||||
// MatchFunc allows custom logic for mapping packets to an Endpoint.
|
||||
type MatchFunc func([]byte) bool
|
||||
|
||||
// MatchAll always returns true.
|
||||
func MatchAll([]byte) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// MatchRange returns true if the first byte of buf is in [lower..upper].
|
||||
func MatchRange(lower, upper byte, buf []byte) bool {
|
||||
if len(buf) < 1 {
|
||||
return false
|
||||
}
|
||||
b := buf[0]
|
||||
|
||||
return b >= lower && b <= upper
|
||||
}
|
||||
|
||||
// MatchFuncs as described in RFC7983
|
||||
// https://tools.ietf.org/html/rfc7983
|
||||
// +----------------+
|
||||
// | [0..3] -+--> forward to STUN
|
||||
// | |
|
||||
// | [16..19] -+--> forward to ZRTP
|
||||
// | |
|
||||
// packet --> | [20..63] -+--> forward to DTLS
|
||||
// | |
|
||||
// | [64..79] -+--> forward to TURN Channel
|
||||
// | |
|
||||
// | [128..191] -+--> forward to RTP/RTCP
|
||||
// +----------------+
|
||||
|
||||
// MatchDTLS is a MatchFunc that accepts packets with the first byte in [20..63]
|
||||
// as defied in RFC7983.
|
||||
func MatchDTLS(b []byte) bool {
|
||||
return MatchRange(20, 63, b)
|
||||
}
|
||||
|
||||
// MatchSRTPOrSRTCP is a MatchFunc that accepts packets with the first byte in [128..191]
|
||||
// as defied in RFC7983.
|
||||
func MatchSRTPOrSRTCP(b []byte) bool {
|
||||
return MatchRange(128, 191, b)
|
||||
}
|
||||
|
||||
func isRTCP(buf []byte) bool {
|
||||
// Not long enough to determine RTP/RTCP
|
||||
if len(buf) < 4 {
|
||||
return false
|
||||
}
|
||||
|
||||
return buf[1] >= 192 && buf[1] <= 223
|
||||
}
|
||||
|
||||
// MatchSRTP is a MatchFunc that only matches SRTP and not SRTCP.
|
||||
func MatchSRTP(buf []byte) bool {
|
||||
return MatchSRTPOrSRTCP(buf) && !isRTCP(buf)
|
||||
}
|
||||
|
||||
// MatchSRTCP is a MatchFunc that only matches SRTCP and not SRTP.
|
||||
func MatchSRTCP(buf []byte) bool {
|
||||
return MatchSRTPOrSRTCP(buf) && isRTCP(buf)
|
||||
}
|
||||
77
internal/util/util.go
Normal file
77
internal/util/util.go
Normal file
@@ -0,0 +1,77 @@
|
||||
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package util provides auxiliary functions internally used in webrtc package
|
||||
package util //nolint: revive
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/pion/randutil"
|
||||
)
|
||||
|
||||
const (
|
||||
runesAlpha = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
)
|
||||
|
||||
// Use global random generator to properly seed by crypto grade random.
|
||||
var globalMathRandomGenerator = randutil.NewMathRandomGenerator() // nolint:gochecknoglobals
|
||||
|
||||
// MathRandAlpha generates a mathematical random alphabet sequence of the requested length.
|
||||
func MathRandAlpha(n int) string {
|
||||
return globalMathRandomGenerator.GenerateString(n, runesAlpha)
|
||||
}
|
||||
|
||||
// RandUint32 generates a mathematical random uint32.
|
||||
func RandUint32() uint32 {
|
||||
return globalMathRandomGenerator.Uint32()
|
||||
}
|
||||
|
||||
// FlattenErrs flattens multiple errors into one.
|
||||
func FlattenErrs(errs []error) error {
|
||||
errs2 := []error{}
|
||||
for _, e := range errs {
|
||||
if e != nil {
|
||||
errs2 = append(errs2, e)
|
||||
}
|
||||
}
|
||||
if len(errs2) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return multiError(errs2)
|
||||
}
|
||||
|
||||
type multiError []error //nolint:errname
|
||||
|
||||
func (me multiError) Error() string {
|
||||
var errstrings []string
|
||||
|
||||
for _, err := range me {
|
||||
if err != nil {
|
||||
errstrings = append(errstrings, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
if len(errstrings) == 0 {
|
||||
return "multiError must contain multiple error but is empty"
|
||||
}
|
||||
|
||||
return strings.Join(errstrings, "\n")
|
||||
}
|
||||
|
||||
func (me multiError) Is(err error) bool {
|
||||
for _, e := range me {
|
||||
if errors.Is(e, err) {
|
||||
return true
|
||||
}
|
||||
if me2, ok := e.(multiError); ok { //nolint:errorlint
|
||||
if me2.Is(err) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
44
internal/util/util_test.go
Normal file
44
internal/util/util_test.go
Normal file
@@ -0,0 +1,44 @@
|
||||
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package util //nolint: revive
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestMathRandAlpha(t *testing.T) {
|
||||
assert.Len(t, MathRandAlpha(10), 10, "MathRandAlpha should return 10 characters")
|
||||
assert.Regexp(t, `^[a-zA-Z]+$`, MathRandAlpha(10), "MathRandAlpha should be Alpha only")
|
||||
}
|
||||
|
||||
func TestMultiError(t *testing.T) {
|
||||
rawErrs := []error{
|
||||
errors.New("err1"), //nolint
|
||||
errors.New("err2"), //nolint
|
||||
errors.New("err3"), //nolint
|
||||
errors.New("err4"), //nolint
|
||||
}
|
||||
errs := FlattenErrs([]error{
|
||||
rawErrs[0],
|
||||
nil,
|
||||
rawErrs[1],
|
||||
FlattenErrs([]error{
|
||||
rawErrs[2],
|
||||
}),
|
||||
})
|
||||
str := "err1\nerr2\nerr3"
|
||||
|
||||
assert.Equal(t, str, errs.Error(), "String representation doesn't match")
|
||||
|
||||
errIs, ok := errs.(multiError) //nolint:errorlint
|
||||
assert.True(t, ok, "FlattenErrs returns non-multiError")
|
||||
for i := range 3 {
|
||||
assert.Truef(t, errIs.Is(rawErrs[i]), "Should contains this error '%v'", rawErrs[i])
|
||||
}
|
||||
|
||||
assert.Falsef(t, errIs.Is(rawErrs[3]), "Should not contains this error '%v'", rawErrs[3])
|
||||
}
|
||||
Reference in New Issue
Block a user