snapshot of https://github.com/livekit/livekit.git @ 08c5433449c106943231037d0b892895f6e92d3b (2026-09-15)
This commit is contained in:
805
pkg/rtc/transport_test.go
Normal file
805
pkg/rtc/transport_test.go
Normal file
@@ -0,0 +1,805 @@
|
||||
// Copyright 2023 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 rtc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pion/sdp/v3"
|
||||
"github.com/pion/webrtc/v4"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/atomic"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/rtc/transport"
|
||||
"github.com/livekit/livekit-server/pkg/rtc/transport/transportfakes"
|
||||
"github.com/livekit/livekit-server/pkg/testutils"
|
||||
"github.com/livekit/protocol/codecs/mime"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
)
|
||||
|
||||
func TestMissingAnswerDuringICERestart(t *testing.T) {
|
||||
params := TransportParams{
|
||||
Config: &WebRTCConfig{},
|
||||
IsOfferer: true,
|
||||
}
|
||||
|
||||
paramsA := params
|
||||
handlerA := &transportfakes.FakeHandler{}
|
||||
paramsA.Handler = handlerA
|
||||
transportA, err := NewPCTransport(paramsA)
|
||||
require.NoError(t, err)
|
||||
_, err = transportA.pc.CreateDataChannel(ReliableDataChannel, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
paramsB := params
|
||||
handlerB := &transportfakes.FakeHandler{}
|
||||
paramsB.Handler = handlerB
|
||||
paramsB.IsOfferer = false
|
||||
transportB, err := NewPCTransport(paramsB)
|
||||
require.NoError(t, err)
|
||||
|
||||
// exchange ICE
|
||||
handleICEExchange(t, transportA, transportB, handlerA, handlerB)
|
||||
|
||||
connectTransports(t, transportA, transportB, handlerA, handlerB, false, 1, 1)
|
||||
require.Equal(t, webrtc.ICEConnectionStateConnected, transportA.pc.ICEConnectionState())
|
||||
require.Equal(t, webrtc.ICEConnectionStateConnected, transportB.pc.ICEConnectionState())
|
||||
|
||||
var negotiationState atomic.Value
|
||||
transportA.OnNegotiationStateChanged(func(state transport.NegotiationState) {
|
||||
negotiationState.Store(state)
|
||||
})
|
||||
|
||||
// offer again, but missed
|
||||
var offerReceived atomic.Bool
|
||||
handlerA.OnOfferCalls(func(sd webrtc.SessionDescription, _offerId uint32, _midToTrackID map[string]string) error {
|
||||
require.Equal(t, webrtc.SignalingStateHaveLocalOffer, transportA.pc.SignalingState())
|
||||
require.Equal(t, transport.NegotiationStateRemote, negotiationState.Load().(transport.NegotiationState))
|
||||
offerReceived.Store(true)
|
||||
return nil
|
||||
})
|
||||
transportA.Negotiate(true)
|
||||
require.Eventually(t, func() bool {
|
||||
return offerReceived.Load()
|
||||
}, 10*time.Second, time.Millisecond*10, "transportA offer not received")
|
||||
|
||||
connectTransports(t, transportA, transportB, handlerA, handlerB, true, 1, 1)
|
||||
require.Equal(t, webrtc.ICEConnectionStateConnected, transportA.pc.ICEConnectionState())
|
||||
require.Equal(t, webrtc.ICEConnectionStateConnected, transportB.pc.ICEConnectionState())
|
||||
|
||||
transportA.Close()
|
||||
transportB.Close()
|
||||
}
|
||||
|
||||
func TestNegotiationTiming(t *testing.T) {
|
||||
params := TransportParams{
|
||||
Config: &WebRTCConfig{},
|
||||
IsOfferer: true,
|
||||
}
|
||||
|
||||
paramsA := params
|
||||
handlerA := &transportfakes.FakeHandler{}
|
||||
paramsA.Handler = handlerA
|
||||
transportA, err := NewPCTransport(paramsA)
|
||||
require.NoError(t, err)
|
||||
_, err = transportA.pc.CreateDataChannel(LossyDataChannel, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
paramsB := params
|
||||
handlerB := &transportfakes.FakeHandler{}
|
||||
paramsB.Handler = handlerB
|
||||
paramsB.IsOfferer = false
|
||||
transportB, err := NewPCTransport(paramsB)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.False(t, transportA.IsEstablished())
|
||||
require.False(t, transportB.IsEstablished())
|
||||
|
||||
handleICEExchange(t, transportA, transportB, handlerA, handlerB)
|
||||
firstOffer := atomic.Value{}
|
||||
firstOfferId := atomic.Uint32{}
|
||||
secondOffer := atomic.Value{}
|
||||
handlerA.OnOfferCalls(func(sd webrtc.SessionDescription, offerId uint32, _midToTrackID map[string]string) error {
|
||||
if _, ok := firstOffer.Load().(*webrtc.SessionDescription); !ok {
|
||||
firstOffer.Store(&sd)
|
||||
firstOfferId.Store(offerId)
|
||||
} else {
|
||||
secondOffer.Store(&sd)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
var negotiationState atomic.Value
|
||||
transportA.OnNegotiationStateChanged(func(state transport.NegotiationState) {
|
||||
negotiationState.Store(state)
|
||||
})
|
||||
|
||||
// initial offer
|
||||
transportA.Negotiate(true)
|
||||
require.Eventually(t, func() bool {
|
||||
state, ok := negotiationState.Load().(transport.NegotiationState)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
return state == transport.NegotiationStateRemote
|
||||
}, 10*time.Second, 10*time.Millisecond, "negotiation state does not match NegotiateStateRemote")
|
||||
|
||||
// second try, should've flipped transport status to retry
|
||||
transportA.Negotiate(true)
|
||||
require.Eventually(t, func() bool {
|
||||
state, ok := negotiationState.Load().(transport.NegotiationState)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
return state == transport.NegotiationStateRetry
|
||||
}, 10*time.Second, 10*time.Millisecond, "negotiation state does not match NegotiateStateRetry")
|
||||
|
||||
// third try, should've stayed at retry
|
||||
transportA.Negotiate(true)
|
||||
time.Sleep(100 * time.Millisecond) // some time to process the negotiate event
|
||||
require.Eventually(t, func() bool {
|
||||
state, ok := negotiationState.Load().(transport.NegotiationState)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
return state == transport.NegotiationStateRetry
|
||||
}, 10*time.Second, 10*time.Millisecond, "negotiation state does not match NegotiateStateRetry")
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
_, ok := firstOffer.Load().(*webrtc.SessionDescription)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if firstOfferId.Load() == 0 {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}, 10*time.Second, 10*time.Millisecond, "first offer not received yet")
|
||||
|
||||
handlerB.OnAnswerCalls(func(answer webrtc.SessionDescription, answerId uint32, _midToTrackID map[string]string) error {
|
||||
transportA.HandleRemoteDescription(answer, answerId)
|
||||
return nil
|
||||
})
|
||||
transportB.HandleRemoteDescription(*firstOffer.Load().(*webrtc.SessionDescription), firstOfferId.Load())
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return transportA.IsEstablished()
|
||||
}, 10*time.Second, time.Millisecond*10, "transportA is not established")
|
||||
require.Eventually(t, func() bool {
|
||||
return transportB.IsEstablished()
|
||||
}, 10*time.Second, time.Millisecond*10, "transportB is not established")
|
||||
|
||||
// offerer should send another offer after processing the answer
|
||||
// as there were forced negotiations a couple of time above
|
||||
require.Eventually(t, func() bool {
|
||||
state, ok := negotiationState.Load().(transport.NegotiationState)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
return state == transport.NegotiationStateRemote
|
||||
}, 10*time.Second, 10*time.Millisecond, "negotiation state does not match NegotiateStateRemote")
|
||||
_, ok := secondOffer.Load().(*webrtc.SessionDescription)
|
||||
require.True(t, ok)
|
||||
|
||||
transportA.Close()
|
||||
transportB.Close()
|
||||
}
|
||||
|
||||
func TestFirstOfferMissedDuringICERestart(t *testing.T) {
|
||||
params := TransportParams{
|
||||
Config: &WebRTCConfig{},
|
||||
IsOfferer: true,
|
||||
}
|
||||
|
||||
paramsA := params
|
||||
handlerA := &transportfakes.FakeHandler{}
|
||||
paramsA.Handler = handlerA
|
||||
transportA, err := NewPCTransport(paramsA)
|
||||
require.NoError(t, err)
|
||||
_, err = transportA.pc.CreateDataChannel(ReliableDataChannel, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
paramsB := params
|
||||
handlerB := &transportfakes.FakeHandler{}
|
||||
paramsB.Handler = handlerB
|
||||
paramsB.IsOfferer = false
|
||||
transportB, err := NewPCTransport(paramsB)
|
||||
require.NoError(t, err)
|
||||
|
||||
// exchange ICE
|
||||
handleICEExchange(t, transportA, transportB, handlerA, handlerB)
|
||||
|
||||
// first offer missed
|
||||
var firstOfferReceived atomic.Bool
|
||||
handlerA.OnOfferCalls(func(sd webrtc.SessionDescription, _offerId uint32, _midToTrackID map[string]string) error {
|
||||
firstOfferReceived.Store(true)
|
||||
return nil
|
||||
})
|
||||
transportA.Negotiate(true)
|
||||
require.Eventually(t, func() bool {
|
||||
return firstOfferReceived.Load()
|
||||
}, 10*time.Second, 10*time.Millisecond, "first offer not received")
|
||||
|
||||
// set offer/answer with restart ICE, will negotiate twice,
|
||||
// first one is recover from missed offer
|
||||
// second one is restartICE
|
||||
handlerB.OnAnswerCalls(func(answer webrtc.SessionDescription, answerId uint32, _midToTrackID map[string]string) error {
|
||||
transportA.HandleRemoteDescription(answer, answerId)
|
||||
return nil
|
||||
})
|
||||
|
||||
var offerCount atomic.Int32
|
||||
handlerA.OnOfferCalls(func(sd webrtc.SessionDescription, offerId uint32, _midToTrackID map[string]string) error {
|
||||
offerCount.Inc()
|
||||
|
||||
// the second offer is a ice restart offer, so we wait transportB complete the ice gathering
|
||||
if transportB.pc.ICEGatheringState() == webrtc.ICEGatheringStateGathering {
|
||||
require.Eventually(t, func() bool {
|
||||
return transportB.pc.ICEGatheringState() == webrtc.ICEGatheringStateComplete
|
||||
}, 10*time.Second, time.Millisecond*10)
|
||||
}
|
||||
|
||||
transportB.HandleRemoteDescription(sd, offerId)
|
||||
return nil
|
||||
})
|
||||
|
||||
// first establish connection
|
||||
transportA.ICERestart()
|
||||
|
||||
// ensure we are connected
|
||||
require.Eventually(t, func() bool {
|
||||
return transportA.pc.ICEConnectionState() == webrtc.ICEConnectionStateConnected &&
|
||||
transportB.pc.ICEConnectionState() == webrtc.ICEConnectionStateConnected &&
|
||||
offerCount.Load() == 2
|
||||
}, testutils.ConnectTimeout, 10*time.Millisecond, "transport did not connect")
|
||||
|
||||
transportA.Close()
|
||||
transportB.Close()
|
||||
}
|
||||
|
||||
func TestFirstAnswerMissedDuringICERestart(t *testing.T) {
|
||||
params := TransportParams{
|
||||
Config: &WebRTCConfig{},
|
||||
IsOfferer: true,
|
||||
}
|
||||
|
||||
paramsA := params
|
||||
handlerA := &transportfakes.FakeHandler{}
|
||||
paramsA.Handler = handlerA
|
||||
transportA, err := NewPCTransport(paramsA)
|
||||
require.NoError(t, err)
|
||||
_, err = transportA.pc.CreateDataChannel(LossyDataChannel, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
paramsB := params
|
||||
handlerB := &transportfakes.FakeHandler{}
|
||||
paramsB.Handler = handlerB
|
||||
paramsB.IsOfferer = false
|
||||
transportB, err := NewPCTransport(paramsB)
|
||||
require.NoError(t, err)
|
||||
|
||||
// exchange ICE
|
||||
handleICEExchange(t, transportA, transportB, handlerA, handlerB)
|
||||
|
||||
// first answer missed
|
||||
var firstAnswerReceived atomic.Bool
|
||||
handlerB.OnAnswerCalls(func(sd webrtc.SessionDescription, answerId uint32, _midToTrackID map[string]string) error {
|
||||
if firstAnswerReceived.Load() {
|
||||
transportA.HandleRemoteDescription(sd, answerId)
|
||||
} else {
|
||||
// do not send first answer so that remote misses the first answer
|
||||
firstAnswerReceived.Store(true)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
handlerA.OnOfferCalls(func(sd webrtc.SessionDescription, offerId uint32, _midToTrackID map[string]string) error {
|
||||
transportB.HandleRemoteDescription(sd, offerId)
|
||||
return nil
|
||||
})
|
||||
|
||||
transportA.Negotiate(true)
|
||||
require.Eventually(t, func() bool {
|
||||
return transportB.pc.SignalingState() == webrtc.SignalingStateStable && firstAnswerReceived.Load()
|
||||
}, time.Second, 10*time.Millisecond, "transportB signaling state did not go to stable")
|
||||
|
||||
// set offer/answer with restart ICE, will negotiate twice,
|
||||
// first one is recover from missed offer
|
||||
// second one is restartICE
|
||||
var offerCount atomic.Int32
|
||||
handlerA.OnOfferCalls(func(sd webrtc.SessionDescription, offerId uint32, _midToTrackID map[string]string) error {
|
||||
offerCount.Inc()
|
||||
|
||||
// the second offer is a ice restart offer, so we wait for transportB to complete ICE gathering
|
||||
if transportB.pc.ICEGatheringState() == webrtc.ICEGatheringStateGathering {
|
||||
require.Eventually(t, func() bool {
|
||||
return transportB.pc.ICEGatheringState() == webrtc.ICEGatheringStateComplete
|
||||
}, 10*time.Second, time.Millisecond*10)
|
||||
}
|
||||
|
||||
transportB.HandleRemoteDescription(sd, offerId)
|
||||
return nil
|
||||
})
|
||||
|
||||
// first establish connection
|
||||
transportA.ICERestart()
|
||||
|
||||
// ensure we are connected
|
||||
require.Eventually(t, func() bool {
|
||||
return transportA.pc.ICEConnectionState() == webrtc.ICEConnectionStateConnected &&
|
||||
transportB.pc.ICEConnectionState() == webrtc.ICEConnectionStateConnected &&
|
||||
offerCount.Load() == 2
|
||||
}, testutils.ConnectTimeout, 10*time.Millisecond, "transport did not connect")
|
||||
|
||||
transportA.Close()
|
||||
transportB.Close()
|
||||
}
|
||||
|
||||
func TestNegotiationFailed(t *testing.T) {
|
||||
params := TransportParams{
|
||||
Config: &WebRTCConfig{},
|
||||
IsOfferer: true,
|
||||
}
|
||||
|
||||
paramsA := params
|
||||
handlerA := &transportfakes.FakeHandler{}
|
||||
paramsA.Handler = handlerA
|
||||
transportA, err := NewPCTransport(paramsA)
|
||||
require.NoError(t, err)
|
||||
_, err = transportA.pc.CreateDataChannel(ReliableDataChannel, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
paramsB := params
|
||||
handlerB := &transportfakes.FakeHandler{}
|
||||
paramsB.Handler = handlerB
|
||||
paramsB.IsOfferer = false
|
||||
transportB, err := NewPCTransport(paramsB)
|
||||
require.NoError(t, err)
|
||||
|
||||
// exchange ICE
|
||||
handleICEExchange(t, transportA, transportB, handlerA, handlerB)
|
||||
|
||||
// wait for transport to be connected before maiming the signalling channel
|
||||
connectTransports(t, transportA, transportB, handlerA, handlerB, false, 1, 1)
|
||||
|
||||
// reset OnOffer to force a negotiation failure
|
||||
handlerA.OnOfferCalls(func(sd webrtc.SessionDescription, offerId uint32, _midToTrackID map[string]string) error {
|
||||
return nil
|
||||
})
|
||||
var failed atomic.Int32
|
||||
handlerA.OnNegotiationFailedCalls(func() {
|
||||
failed.Inc()
|
||||
})
|
||||
transportA.Negotiate(true)
|
||||
require.Eventually(t, func() bool {
|
||||
return failed.Load() == 1
|
||||
}, negotiationFailedTimeout+time.Second, 10*time.Millisecond, "negotiation failed")
|
||||
|
||||
transportA.Close()
|
||||
}
|
||||
|
||||
func TestFilteringCandidates(t *testing.T) {
|
||||
params := TransportParams{
|
||||
Config: &WebRTCConfig{},
|
||||
EnabledPublishCodecs: []*livekit.Codec{
|
||||
{Mime: mime.MimeTypeOpus.String()},
|
||||
{Mime: mime.MimeTypeVP8.String()},
|
||||
{Mime: mime.MimeTypeH264.String()},
|
||||
},
|
||||
Handler: &transportfakes.FakeHandler{},
|
||||
}
|
||||
transport, err := NewPCTransport(params)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = transport.pc.CreateDataChannel(ReliableDataChannel, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = transport.pc.AddTransceiverFromKind(webrtc.RTPCodecTypeAudio)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = transport.pc.AddTransceiverFromKind(webrtc.RTPCodecTypeVideo)
|
||||
require.NoError(t, err)
|
||||
|
||||
offer, err := transport.pc.CreateOffer(nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
offerGatheringComplete := webrtc.GatheringCompletePromise(transport.pc)
|
||||
require.NoError(t, transport.pc.SetLocalDescription(offer))
|
||||
<-offerGatheringComplete
|
||||
|
||||
// should not filter out UDP candidates if TCP is not preferred
|
||||
offer = *transport.pc.LocalDescription()
|
||||
filteredOffer := transport.filterCandidates(offer, false, true)
|
||||
require.EqualValues(t, offer.SDP, filteredOffer.SDP)
|
||||
|
||||
parsed, err := offer.Unmarshal()
|
||||
require.NoError(t, err)
|
||||
|
||||
// add a couple of TCP candidates
|
||||
done := false
|
||||
for _, m := range parsed.MediaDescriptions {
|
||||
for _, a := range m.Attributes {
|
||||
if a.Key == sdp.AttrKeyCandidate {
|
||||
for idx, aa := range m.Attributes {
|
||||
if aa.Key == sdp.AttrKeyEndOfCandidates {
|
||||
modifiedAttributes := make([]sdp.Attribute, idx)
|
||||
copy(modifiedAttributes, m.Attributes[:idx])
|
||||
modifiedAttributes = append(modifiedAttributes, []sdp.Attribute{
|
||||
{
|
||||
Key: sdp.AttrKeyCandidate,
|
||||
Value: "054225987 1 tcp 2124414975 159.203.70.248 7881 typ host tcptype passive",
|
||||
},
|
||||
{
|
||||
Key: sdp.AttrKeyCandidate,
|
||||
Value: "054225987 2 tcp 2124414975 159.203.70.248 7881 typ host tcptype passive",
|
||||
},
|
||||
}...)
|
||||
m.Attributes = append(modifiedAttributes, m.Attributes[idx:]...)
|
||||
done = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if done {
|
||||
break
|
||||
}
|
||||
}
|
||||
if done {
|
||||
break
|
||||
}
|
||||
}
|
||||
bytes, err := parsed.Marshal()
|
||||
require.NoError(t, err)
|
||||
offer.SDP = string(bytes)
|
||||
|
||||
parsed, err = offer.Unmarshal()
|
||||
require.NoError(t, err)
|
||||
|
||||
getNumTransportTypeCandidates := func(sd *sdp.SessionDescription) (int, int) {
|
||||
numUDPCandidates := 0
|
||||
numTCPCandidates := 0
|
||||
for _, a := range sd.Attributes {
|
||||
if a.Key == sdp.AttrKeyCandidate {
|
||||
if strings.Contains(a.Value, "udp") {
|
||||
numUDPCandidates++
|
||||
}
|
||||
if strings.Contains(a.Value, "tcp") {
|
||||
numTCPCandidates++
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, m := range sd.MediaDescriptions {
|
||||
for _, a := range m.Attributes {
|
||||
if a.Key == sdp.AttrKeyCandidate {
|
||||
if strings.Contains(a.Value, "udp") {
|
||||
numUDPCandidates++
|
||||
}
|
||||
if strings.Contains(a.Value, "tcp") {
|
||||
numTCPCandidates++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return numUDPCandidates, numTCPCandidates
|
||||
}
|
||||
udp, tcp := getNumTransportTypeCandidates(parsed)
|
||||
require.NotZero(t, udp)
|
||||
require.Equal(t, 2, tcp)
|
||||
|
||||
transport.SetPreferTCP(true)
|
||||
filteredOffer = transport.filterCandidates(offer, true, true)
|
||||
parsed, err = filteredOffer.Unmarshal()
|
||||
require.NoError(t, err)
|
||||
udp, tcp = getNumTransportTypeCandidates(parsed)
|
||||
require.Zero(t, udp)
|
||||
require.Equal(t, 2, tcp)
|
||||
|
||||
transport.Close()
|
||||
}
|
||||
|
||||
func handleICEExchange(t *testing.T, a, b *PCTransport, ah, bh *transportfakes.FakeHandler) {
|
||||
ah.OnICECandidateCalls(func(candidate *webrtc.ICECandidate, target livekit.SignalTarget) error {
|
||||
if candidate == nil {
|
||||
return nil
|
||||
}
|
||||
t.Logf("got ICE candidate from A: %v", candidate)
|
||||
b.AddICECandidate(candidate.ToJSON())
|
||||
return nil
|
||||
})
|
||||
bh.OnICECandidateCalls(func(candidate *webrtc.ICECandidate, target livekit.SignalTarget) error {
|
||||
if candidate == nil {
|
||||
return nil
|
||||
}
|
||||
t.Logf("got ICE candidate from B: %v", candidate)
|
||||
a.AddICECandidate(candidate.ToJSON())
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func connectTransports(t *testing.T, offerer, answerer *PCTransport, offererHandler, answererHandler *transportfakes.FakeHandler, isICERestart bool, expectedOfferCount int32, expectedAnswerCount int32) {
|
||||
var offerCount atomic.Int32
|
||||
var answerCount atomic.Int32
|
||||
answererHandler.OnAnswerCalls(func(answer webrtc.SessionDescription, answerId uint32, _midToTrackID map[string]string) error {
|
||||
answerCount.Inc()
|
||||
offerer.HandleRemoteDescription(answer, answerId)
|
||||
return nil
|
||||
})
|
||||
|
||||
offererHandler.OnOfferCalls(func(offer webrtc.SessionDescription, offerId uint32, _midToTrackID map[string]string) error {
|
||||
offerCount.Inc()
|
||||
answerer.HandleRemoteDescription(offer, offerId)
|
||||
return nil
|
||||
})
|
||||
|
||||
if isICERestart {
|
||||
offerer.ICERestart()
|
||||
} else {
|
||||
offerer.Negotiate(true)
|
||||
}
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return offerCount.Load() == expectedOfferCount
|
||||
}, 10*time.Second, time.Millisecond*10, fmt.Sprintf("offer count mismatch, expected: %d, actual: %d", expectedOfferCount, offerCount.Load()))
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return offerer.pc.ICEConnectionState() == webrtc.ICEConnectionStateConnected
|
||||
}, 10*time.Second, time.Millisecond*10, "offerer did not become connected")
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return answerCount.Load() == expectedAnswerCount
|
||||
}, 10*time.Second, time.Millisecond*10, fmt.Sprintf("answer count mismatch, expected: %d, actual: %d", expectedAnswerCount, answerCount.Load()))
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return answerer.pc.ICEConnectionState() == webrtc.ICEConnectionStateConnected
|
||||
}, 10*time.Second, time.Millisecond*10, "answerer did not become connected")
|
||||
|
||||
transportsConnected := untilTransportsConnected(offererHandler, answererHandler)
|
||||
transportsConnected.Wait()
|
||||
}
|
||||
|
||||
func untilTransportsConnected(transports ...*transportfakes.FakeHandler) *sync.WaitGroup {
|
||||
var triggered sync.WaitGroup
|
||||
triggered.Add(len(transports))
|
||||
|
||||
for _, t := range transports {
|
||||
var done atomic.Value
|
||||
done.Store(false)
|
||||
hdlr := func() {
|
||||
if val, ok := done.Load().(bool); ok && !val {
|
||||
done.Store(true)
|
||||
triggered.Done()
|
||||
}
|
||||
}
|
||||
|
||||
if t.OnInitialConnectedCallCount() != 0 {
|
||||
hdlr()
|
||||
}
|
||||
t.OnInitialConnectedCalls(hdlr)
|
||||
}
|
||||
return &triggered
|
||||
}
|
||||
|
||||
func TestConfigureAudioTransceiver(t *testing.T) {
|
||||
for _, testcase := range []struct {
|
||||
nack bool
|
||||
stereo bool
|
||||
}{
|
||||
{false, false},
|
||||
{true, false},
|
||||
{false, true},
|
||||
{true, true},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("nack=%v,stereo=%v", testcase.nack, testcase.stereo), func(t *testing.T) {
|
||||
var me webrtc.MediaEngine
|
||||
registerCodecs(&me, []*livekit.Codec{{Mime: mime.MimeTypeOpus.String()}}, RTCPFeedbackConfig{Audio: []webrtc.RTCPFeedback{{Type: webrtc.TypeRTCPFBNACK}}}, false)
|
||||
pc, err := webrtc.NewAPI(webrtc.WithMediaEngine(&me)).NewPeerConnection(webrtc.Configuration{})
|
||||
require.NoError(t, err)
|
||||
defer pc.Close()
|
||||
tr, err := pc.AddTransceiverFromKind(webrtc.RTPCodecTypeAudio, webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionSendonly})
|
||||
require.NoError(t, err)
|
||||
|
||||
configureSenderAudio(tr, testcase.stereo, testcase.nack, nil)
|
||||
codecs := tr.Sender().GetParameters().Codecs
|
||||
for _, codec := range codecs {
|
||||
if mime.IsMimeTypeStringOpus(codec.MimeType) {
|
||||
require.Equal(t, testcase.stereo, strings.Contains(codec.SDPFmtpLine, "sprop-stereo=1"))
|
||||
var nackEnabled bool
|
||||
for _, fb := range codec.RTCPFeedback {
|
||||
if fb.Type == webrtc.TypeRTCPFBNACK {
|
||||
nackEnabled = true
|
||||
break
|
||||
}
|
||||
}
|
||||
require.Equal(t, testcase.nack, nackEnabled)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// When answering a subscriber offer, the sender's audio payload type must echo
|
||||
// the payload type the offer assigned (RFC 3264), otherwise Firefox decodes no
|
||||
// audio. See https://github.com/livekit/livekit/issues/4599.
|
||||
func TestConfigureAudioTransceiverEchoesOfferPayloadType(t *testing.T) {
|
||||
var me webrtc.MediaEngine
|
||||
registerCodecs(&me, []*livekit.Codec{{Mime: mime.MimeTypeOpus.String()}}, RTCPFeedbackConfig{Audio: []webrtc.RTCPFeedback{{Type: webrtc.TypeRTCPFBNACK}}}, false)
|
||||
pc, err := webrtc.NewAPI(webrtc.WithMediaEngine(&me)).NewPeerConnection(webrtc.Configuration{})
|
||||
require.NoError(t, err)
|
||||
defer pc.Close()
|
||||
tr, err := pc.AddTransceiverFromKind(webrtc.RTPCodecTypeAudio, webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionSendonly})
|
||||
require.NoError(t, err)
|
||||
|
||||
// offer mapped Opus to a payload type different from the server MediaEngine's.
|
||||
const offeredOpusPT = webrtc.PayloadType(109)
|
||||
configureSenderAudio(tr, false, true, map[mime.MimeType]webrtc.PayloadType{mime.MimeTypeOpus: offeredOpusPT})
|
||||
|
||||
var found bool
|
||||
for _, codec := range tr.Sender().GetParameters().Codecs {
|
||||
if mime.IsMimeTypeStringOpus(codec.MimeType) {
|
||||
require.Equal(t, offeredOpusPT, codec.PayloadType)
|
||||
found = true
|
||||
}
|
||||
}
|
||||
require.True(t, found, "opus codec must be present in sender preferences")
|
||||
}
|
||||
|
||||
// In single-PC mode the publisher PC carries both publish and subscribe
|
||||
// directions. If the MediaEngine were built only from the publish codec list,
|
||||
// the SDP offer would not advertise some codecs in the m-section even though
|
||||
// the subscribe direction is supposed to support it. This regression-tests
|
||||
// the union behavior in newPeerConnection: build the MediaEngine from publish +
|
||||
// subscribe codec lists.
|
||||
func TestSinglePCMediaEngineUnionsCodecs(t *testing.T) {
|
||||
videoMSectionCodecs := func(transport *PCTransport) []string {
|
||||
_, err := transport.pc.AddTransceiverFromKind(webrtc.RTPCodecTypeVideo)
|
||||
require.NoError(t, err)
|
||||
offer, err := transport.pc.CreateOffer(nil)
|
||||
require.NoError(t, err)
|
||||
parsed, err := offer.Unmarshal()
|
||||
require.NoError(t, err)
|
||||
var rtpmaps []string
|
||||
for _, m := range parsed.MediaDescriptions {
|
||||
if m.MediaName.Media != "video" {
|
||||
continue
|
||||
}
|
||||
for _, a := range m.Attributes {
|
||||
if a.Key == "rtpmap" {
|
||||
rtpmaps = append(rtpmaps, a.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
return rtpmaps
|
||||
}
|
||||
|
||||
sdpHasH264 := func(rtpmaps []string) bool {
|
||||
for _, r := range rtpmaps {
|
||||
if strings.Contains(r, "H264/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
publishOnly := []*livekit.Codec{
|
||||
{Mime: mime.MimeTypeOpus.String()},
|
||||
{Mime: mime.MimeTypeVP8.String()},
|
||||
}
|
||||
subscribeOnly := []*livekit.Codec{
|
||||
{Mime: mime.MimeTypeOpus.String()},
|
||||
{Mime: mime.MimeTypeVP8.String()},
|
||||
{Mime: mime.MimeTypeH264.String()},
|
||||
}
|
||||
|
||||
// Control: only publish codecs set (dual-PC publisher PC). H.264 absent.
|
||||
dualPC, err := NewPCTransport(TransportParams{
|
||||
Config: &WebRTCConfig{},
|
||||
EnabledPublishCodecs: publishOnly,
|
||||
Handler: &transportfakes.FakeHandler{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.False(t, sdpHasH264(videoMSectionCodecs(dualPC)),
|
||||
"dual-PC publisher must not advertise H.264 when it's stripped from the publish list")
|
||||
|
||||
// Single-PC publisher PC: both lists set. H.264 must appear.
|
||||
singlePC, err := NewPCTransport(TransportParams{
|
||||
Config: &WebRTCConfig{},
|
||||
EnabledPublishCodecs: publishOnly,
|
||||
EnabledSubscribeCodecs: subscribeOnly,
|
||||
IsSendSide: true,
|
||||
Handler: &transportfakes.FakeHandler{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, sdpHasH264(videoMSectionCodecs(singlePC)),
|
||||
"single-PC publisher must advertise H.264 from the subscribe list even when it's stripped from the publish list")
|
||||
}
|
||||
|
||||
// Regression test for restrictReceiverCodecsToPublishList: subscribe-only
|
||||
// codecs (e.g., H.264) registered for subscriptions must not leak into the
|
||||
// recv-side m-section of an answer, or the peer could publish them.
|
||||
func TestSinglePCAnswerStripsSubscribeOnlyCodecsFromRecvSide(t *testing.T) {
|
||||
publishCodecs := []*livekit.Codec{
|
||||
{Mime: mime.MimeTypeOpus.String()},
|
||||
{Mime: mime.MimeTypeVP8.String()},
|
||||
}
|
||||
subscribeCodecs := []*livekit.Codec{
|
||||
{Mime: mime.MimeTypeOpus.String()},
|
||||
{Mime: mime.MimeTypeVP8.String()},
|
||||
{Mime: mime.MimeTypeH264.String()},
|
||||
}
|
||||
|
||||
handler := &transportfakes.FakeHandler{}
|
||||
server, err := NewPCTransport(TransportParams{
|
||||
Config: &WebRTCConfig{},
|
||||
EnabledPublishCodecs: publishCodecs,
|
||||
EnabledSubscribeCodecs: subscribeCodecs,
|
||||
IsSendSide: true,
|
||||
Handler: handler,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer server.Close()
|
||||
|
||||
var clientME webrtc.MediaEngine
|
||||
require.NoError(t, registerCodecs(&clientME, subscribeCodecs, RTCPFeedbackConfig{}, false))
|
||||
client, err := webrtc.NewAPI(webrtc.WithMediaEngine(&clientME)).NewPeerConnection(webrtc.Configuration{})
|
||||
require.NoError(t, err)
|
||||
defer client.Close()
|
||||
|
||||
_, err = client.AddTransceiverFromKind(webrtc.RTPCodecTypeVideo, webrtc.RTPTransceiverInit{
|
||||
Direction: webrtc.RTPTransceiverDirectionSendonly,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
offer, err := client.CreateOffer(nil)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, offer.SDP, "H264/", "offer must advertise H.264")
|
||||
require.NoError(t, client.SetLocalDescription(offer))
|
||||
|
||||
var answer atomic.Pointer[webrtc.SessionDescription]
|
||||
handler.OnAnswerCalls(func(sd webrtc.SessionDescription, _ uint32, _ map[string]string) error {
|
||||
answer.Store(&sd)
|
||||
return nil
|
||||
})
|
||||
require.NoError(t, server.HandleRemoteDescription(*client.LocalDescription(), 1))
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return answer.Load() != nil
|
||||
}, 5*time.Second, 10*time.Millisecond, "server did not produce answer")
|
||||
|
||||
parsed, err := answer.Load().Unmarshal()
|
||||
require.NoError(t, err)
|
||||
|
||||
var videoSection *sdp.MediaDescription
|
||||
for _, m := range parsed.MediaDescriptions {
|
||||
if m.MediaName.Media == "video" {
|
||||
videoSection = m
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotNil(t, videoSection, "answer missing video m-section")
|
||||
|
||||
for _, a := range videoSection.Attributes {
|
||||
if a.Key != "rtpmap" {
|
||||
continue
|
||||
}
|
||||
require.NotContains(t, a.Value, "H264/",
|
||||
"answer must not advertise H.264 in recv-side m-section: %s", a.Value)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user