Files
Raja SubramanianandClaude Opus 5 bbdfd50d62 Fix simulcast RTX pairing broken by pion/webrtc#3470 (#4800)
pion no longer starts the repair stream reader when a custom BufferFactory
is set, so the mid/rid/rsid extensions were never observed and simulcast RTX
streams were never paired with their primary streams.

Extract the extensions on the buffer write path instead. Migrated publishers
send no extensions at all, so pair those from SimTracks.

Adds an integration test covering both paths, and moves the vnet setup it
shares with the downtrack test into pkg/testutils/vnettest.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 13:01:22 +05:30

232 lines
6.8 KiB
Go

// Copyright 2026 LiveKit, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package vnettest sets up real pion peer connections on an in-memory virtual
// network, for integration tests that exercise media paths without a server.
//
// It depends only on pion, so it can be imported both by tests inside pkg/... and by
// the top level test package.
package vnettest
import (
"fmt"
"io"
"sync"
"testing"
"time"
"github.com/pion/interceptor"
"github.com/pion/logging"
"github.com/pion/sdp/v3"
"github.com/pion/transport/v4/packetio"
"github.com/pion/transport/v4/vnet"
"github.com/pion/webrtc/v4"
"github.com/stretchr/testify/require"
)
const (
VP8PayloadType = 96
RTXPayloadType = VP8PayloadType + 1
OpusPayloadType = 111
)
// Hosts are the two ends of a started virtual network.
type Hosts struct {
OfferNet *vnet.Net
AnswerNet *vnet.Net
}
// NewHosts returns two hosts on a started virtual network, torn down with the test.
func NewHosts(t *testing.T) *Hosts {
t.Helper()
wan, err := vnet.NewRouter(&vnet.RouterConfig{
CIDR: "1.2.3.0/24",
LoggerFactory: logging.NewDefaultLoggerFactory(),
})
require.NoError(t, err)
offerNet, err := vnet.NewNet(&vnet.NetConfig{StaticIPs: []string{"1.2.3.4"}})
require.NoError(t, err)
require.NoError(t, wan.AddNet(offerNet))
answerNet, err := vnet.NewNet(&vnet.NetConfig{StaticIPs: []string{"1.2.3.5"}})
require.NoError(t, err)
require.NoError(t, wan.AddNet(answerNet))
require.NoError(t, wan.Start())
t.Cleanup(func() { _ = wan.Stop() })
return &Hosts{OfferNet: offerNet, AnswerNet: answerNet}
}
// NewSettingEngine returns a setting engine bound to net, with ICE timeouts short
// enough to keep tests quick.
func NewSettingEngine(net *vnet.Net) webrtc.SettingEngine {
se := webrtc.SettingEngine{}
se.SetNet(net)
se.SetNetworkTypes([]webrtc.NetworkType{webrtc.NetworkTypeUDP4})
se.SetICETimeouts(5*time.Second, 5*time.Second, 500*time.Millisecond)
return se
}
// MediaEngineConfig describes what to register on a media engine.
type MediaEngineConfig struct {
Video bool // VP8 and its RTX codec; otherwise opus
HeaderExtensions bool // abs-send-time + transport-cc
SimulcastExtensions bool // mid + rid + rsid
}
func VideoRTCPFeedback() []webrtc.RTCPFeedback {
return []webrtc.RTCPFeedback{
{Type: webrtc.TypeRTCPFBNACK},
{Type: webrtc.TypeRTCPFBNACK, Parameter: "pli"},
{Type: webrtc.TypeRTCPFBTransportCC},
{Type: webrtc.TypeRTCPFBGoogREMB},
}
}
func NewMediaEngine(t *testing.T, cfg MediaEngineConfig) *webrtc.MediaEngine {
t.Helper()
me := &webrtc.MediaEngine{}
kind := webrtc.RTPCodecTypeAudio
if cfg.Video {
kind = webrtc.RTPCodecTypeVideo
require.NoError(t, me.RegisterCodec(webrtc.RTPCodecParameters{
RTPCodecCapability: webrtc.RTPCodecCapability{
MimeType: webrtc.MimeTypeVP8, ClockRate: 90000, RTCPFeedback: VideoRTCPFeedback(),
},
PayloadType: VP8PayloadType,
}, kind))
require.NoError(t, me.RegisterCodec(webrtc.RTPCodecParameters{
RTPCodecCapability: webrtc.RTPCodecCapability{
MimeType: webrtc.MimeTypeRTX,
ClockRate: 90000,
SDPFmtpLine: fmt.Sprintf("apt=%d", VP8PayloadType),
},
PayloadType: RTXPayloadType,
}, kind))
} else {
require.NoError(t, me.RegisterCodec(webrtc.RTPCodecParameters{
RTPCodecCapability: webrtc.RTPCodecCapability{
MimeType: webrtc.MimeTypeOpus, ClockRate: 48000, Channels: 2,
},
PayloadType: OpusPayloadType,
}, kind))
}
if cfg.HeaderExtensions {
require.NoError(t, me.RegisterHeaderExtension(webrtc.RTPHeaderExtensionCapability{URI: sdp.ABSSendTimeURI}, kind))
require.NoError(t, me.RegisterHeaderExtension(webrtc.RTPHeaderExtensionCapability{URI: sdp.TransportCCURI}, kind))
}
if cfg.SimulcastExtensions {
require.NoError(t, webrtc.ConfigureSimulcastExtensionHeaders(me))
}
return me
}
// PCConfig describes a peer connection on the virtual network.
type PCConfig struct {
Net *vnet.Net
MediaEngine MediaEngineConfig
// BufferFactory is SettingEngine.BufferFactory, e. g. buffer.Factory.GetOrNew.
// Optional.
BufferFactory func(packetType packetio.BufferPacketType, ssrc uint32) io.ReadWriteCloser
}
// NewPeerConnection builds a peer connection on the virtual network with no
// interceptors, so nothing rewrites what a test puts on the wire.
func NewPeerConnection(t *testing.T, cfg PCConfig) *webrtc.PeerConnection {
t.Helper()
se := NewSettingEngine(cfg.Net)
se.BufferFactory = cfg.BufferFactory
api := webrtc.NewAPI(
webrtc.WithMediaEngine(NewMediaEngine(t, cfg.MediaEngine)),
webrtc.WithSettingEngine(se),
webrtc.WithInterceptorRegistry(&interceptor.Registry{}),
)
pc, err := api.NewPeerConnection(webrtc.Configuration{})
require.NoError(t, err)
t.Cleanup(func() { _ = pc.Close() })
return pc
}
// GatheredOffer creates an offer and waits for gathering, so the SDP carries every
// candidate and the caller needs no trickle.
func GatheredOffer(t *testing.T, pc *webrtc.PeerConnection) webrtc.SessionDescription {
t.Helper()
offer, err := pc.CreateOffer(nil)
require.NoError(t, err)
gathered := webrtc.GatheringCompletePromise(pc)
require.NoError(t, pc.SetLocalDescription(offer))
<-gathered
return *pc.LocalDescription()
}
// SignalPair performs a full offer/answer exchange between two peer connections and
// waits for both to connect.
func SignalPair(t *testing.T, offerer, answerer *webrtc.PeerConnection) {
t.Helper()
connected := UntilConnected(offerer, answerer)
require.NoError(t, answerer.SetRemoteDescription(GatheredOffer(t, offerer)))
answer, err := answerer.CreateAnswer(nil)
require.NoError(t, err)
gathered := webrtc.GatheringCompletePromise(answerer)
require.NoError(t, answerer.SetLocalDescription(answer))
<-gathered
require.NoError(t, offerer.SetRemoteDescription(*answerer.LocalDescription()))
select {
case <-connected:
case <-time.After(30 * time.Second):
t.Fatal("timed out waiting for peer connections to connect")
}
}
// UntilConnected closes the returned channel once every peer connection is connected.
func UntilConnected(pcs ...*webrtc.PeerConnection) <-chan struct{} {
var wg sync.WaitGroup
wg.Add(len(pcs))
for _, pc := range pcs {
var once sync.Once
pc.OnConnectionStateChange(func(s webrtc.PeerConnectionState) {
if s == webrtc.PeerConnectionStateConnected {
once.Do(wg.Done)
}
})
}
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
return done
}