Files
livekit/pkg/rtc/participant_internal_test.go
T
Ramlal 29d7079164 rtc: apply simulcast rids from SDP for WHIP (one-shot) publishers (#5004)
* rtc: apply simulcast rids from SDP for WHIP (one-shot) publishers

In one-shot signalling mode HandleRemoteDescription set the remote offer
directly and never called OnSetRemoteDescriptionOffer, so
updateRidsFromSDP did not run for WHIP participants. Every simulcast layer
then mapped to spatial layer 0 and all but the first were rejected with
'duplicate layer'.

* rtc: test that a one-shot simulcast offer applies rids to the pending track

* rtc: use h/q rids in one-shot simulcast test, cover single-layer offer

Name the simulcast test layers medium/h and low/q per convention, and add
TestOneShotSingleLayerOfferUnchanged to check a one-shot offer without rids
still yields a single layer with rid "" at spatial layer 0.
2026-10-14 16:30:22 +05:30

1371 lines
47 KiB
Go

// 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"
"testing"
"time"
"github.com/pion/webrtc/v4"
"github.com/stretchr/testify/require"
"github.com/twitchtv/twirp"
"go.uber.org/atomic"
"google.golang.org/protobuf/proto"
"github.com/livekit/livekit-server/pkg/sfu/buffer"
"github.com/livekit/protocol/auth"
protoCodecs "github.com/livekit/protocol/codecs"
"github.com/livekit/protocol/codecs/mime"
"github.com/livekit/protocol/livekit"
"github.com/livekit/protocol/logger"
"github.com/livekit/protocol/logger/zaputil"
"github.com/livekit/protocol/observability/roomobs"
lksdp "github.com/livekit/protocol/sdp"
"github.com/livekit/protocol/signalling"
"github.com/livekit/protocol/utils"
"github.com/livekit/protocol/utils/guid"
"github.com/livekit/livekit-server/pkg/config"
"github.com/livekit/livekit-server/pkg/routing"
"github.com/livekit/livekit-server/pkg/routing/routingfakes"
"github.com/livekit/livekit-server/pkg/rtc/types"
"github.com/livekit/livekit-server/pkg/rtc/types/typesfakes"
"github.com/livekit/livekit-server/pkg/testutils"
)
func TestIsReady(t *testing.T) {
tests := []struct {
state livekit.ParticipantInfo_State
ready bool
}{
{
state: livekit.ParticipantInfo_JOINING,
ready: false,
},
{
state: livekit.ParticipantInfo_JOINED,
ready: true,
},
{
state: livekit.ParticipantInfo_ACTIVE,
ready: true,
},
{
state: livekit.ParticipantInfo_DISCONNECTED,
ready: false,
},
}
for _, test := range tests {
t.Run(test.state.String(), func(t *testing.T) {
p := &ParticipantImpl{}
p.state.Store(test.state)
require.Equal(t, test.ready, p.IsReady())
})
}
}
// Disabling a codec can leave the publish list with only RTX and FEC. The
// fallback must still pick a real codec, or the track registers as RTX and
// subscribers see a blank tile.
func TestPublishCodecFallbackSkipsRTX(t *testing.T) {
h264 := &livekit.Codec{Mime: mime.MimeTypeH264.String()}
rtx := &livekit.Codec{Mime: mime.MimeTypeRTX.String()}
newParticipant := func() *ParticipantImpl {
return &ParticipantImpl{params: ParticipantParams{Logger: logger.GetLogger()}}
}
t.Run("h264 enabled, rtx is last and not chosen", func(t *testing.T) {
p := newParticipant()
p.setupEnabledCodecs([]*livekit.Codec{h264, rtx}, nil, nil)
require.Equal(t, mime.MimeTypeH264.String(), selectAlternativeVideoCodec(p.enabledPublishCodecs))
})
t.Run("h264 disabled leaves rtx only, fallback avoids rtx", func(t *testing.T) {
p := newParticipant()
disabled := &livekit.DisabledCodecs{Publish: []*livekit.Codec{{Mime: mime.MimeTypeH264.String()}}}
p.setupEnabledCodecs([]*livekit.Codec{h264, rtx}, nil, disabled)
require.Len(t, p.enabledPublishCodecs, 1)
require.True(t, mime.IsMimeTypeStringRTX(p.enabledPublishCodecs[0].Mime))
alt := selectAlternativeVideoCodec(p.enabledPublishCodecs)
require.False(t, mime.IsMimeTypeStringRTX(alt), "fallback must not be rtx, got %q", alt)
require.Equal(t, mime.MimeTypeVP8.String(), alt)
})
}
func TestSupportsMoving(t *testing.T) {
t.Run("current protocol version", func(t *testing.T) {
p := newParticipantForTestWithOpts("test", &participantOpts{protocolVersion: types.CurrentProtocol})
require.NoError(t, p.SupportsMoving())
})
// a move rejected because of what the participant is cannot be retried into working, but it is
// still a caller error, so it has to surface as a 4xx rather than a 500
t.Run("client version that cannot be moved", func(t *testing.T) {
p := newParticipantForTestWithOpts("test", &participantOpts{protocolVersion: 6})
err := p.SupportsMoving()
require.ErrorIs(t, err, ErrMoveOldClientVersion)
var twirpErr twirp.Error
require.ErrorAs(t, err, &twirpErr)
require.Equal(t, twirp.FailedPrecondition, twirpErr.Code())
})
}
func TestTrackPublishing(t *testing.T) {
t.Run("should send the correct events", func(t *testing.T) {
p := newParticipantForTest("test")
track := &typesfakes.FakeMediaTrack{}
track.IDReturns("id")
published := false
updated := false
p.listener().(*typesfakes.FakeLocalParticipantListener).OnTrackUpdatedCalls(func(p types.Participant, track types.MediaTrack) {
updated = true
})
p.listener().(*typesfakes.FakeLocalParticipantListener).OnTrackPublishedCalls(func(p types.Participant, track types.MediaTrack) {
published = true
})
p.UpTrackManager.AddPublishedTrack(track)
p.handleTrackPublished(track, false, false)
require.True(t, published)
require.False(t, updated)
require.Len(t, p.UpTrackManager.publishedTracks, 1)
})
t.Run("sends back trackPublished event", func(t *testing.T) {
p := newParticipantForTest("test")
sink := p.params.Sink.(*routingfakes.FakeMessageSink)
p.AddTrack(&livekit.AddTrackRequest{
Cid: "cid",
Name: "webcam",
Type: livekit.TrackType_VIDEO,
Width: 1024,
Height: 768,
})
require.Equal(t, 1, sink.WriteMessageCallCount())
res := sink.WriteMessageArgsForCall(0).(*livekit.SignalResponse)
require.IsType(t, &livekit.SignalResponse_TrackPublished{}, res.Message)
published := res.Message.(*livekit.SignalResponse_TrackPublished).TrackPublished
require.Equal(t, "cid", published.Cid)
require.Equal(t, "webcam", published.Track.Name)
require.Equal(t, livekit.TrackType_VIDEO, published.Track.Type)
require.Equal(t, uint32(1024), published.Track.Width)
require.Equal(t, uint32(768), published.Track.Height)
})
t.Run("should not allow adding of duplicate tracks", func(t *testing.T) {
p := newParticipantForTest("test")
sink := p.params.Sink.(*routingfakes.FakeMessageSink)
p.AddTrack(&livekit.AddTrackRequest{
Cid: "cid",
Name: "webcam",
Type: livekit.TrackType_VIDEO,
})
p.AddTrack(&livekit.AddTrackRequest{
Cid: "cid",
Name: "duplicate",
Type: livekit.TrackType_AUDIO,
})
// error response on duplicate adds a message
require.Equal(t, 2, sink.WriteMessageCallCount())
})
t.Run("should queue adding of duplicate tracks if already published by client id in signalling", func(t *testing.T) {
p := newParticipantForTest("test")
sink := p.params.Sink.(*routingfakes.FakeMessageSink)
track := &typesfakes.FakeLocalMediaTrack{}
track.HasSignalCidCalls(func(s string) bool { return s == "cid" })
track.ToProtoReturns(&livekit.TrackInfo{})
// directly add to publishedTracks without lock - for testing purpose only
p.UpTrackManager.publishedTracks["cid"] = track
p.AddTrack(&livekit.AddTrackRequest{
Cid: "cid",
Name: "webcam",
Type: livekit.TrackType_VIDEO,
})
// `queued` `RequestResponse` should add a message
require.Equal(t, 1, sink.WriteMessageCallCount())
require.Equal(t, 1, len(p.pendingTracks["cid"].trackInfos))
// add again - it should be added to the queue
p.AddTrack(&livekit.AddTrackRequest{
Cid: "cid",
Name: "webcam",
Type: livekit.TrackType_VIDEO,
})
// `queued` `RequestResponse`s should have been sent for duplicate additions
require.Equal(t, 2, sink.WriteMessageCallCount())
require.Equal(t, 2, len(p.pendingTracks["cid"].trackInfos))
// check SID is the same
require.Equal(t, p.pendingTracks["cid"].trackInfos[0].Sid, p.pendingTracks["cid"].trackInfos[1].Sid)
})
t.Run("should queue adding of duplicate tracks if already published by client id in sdp", func(t *testing.T) {
p := newParticipantForTest("test")
sink := p.params.Sink.(*routingfakes.FakeMessageSink)
track := &typesfakes.FakeLocalMediaTrack{}
track.ToProtoReturns(&livekit.TrackInfo{})
track.HasSdpCidCalls(func(s string) bool { return s == "cid" })
// directly add to publishedTracks without lock - for testing purpose only
p.UpTrackManager.publishedTracks["cid"] = track
p.AddTrack(&livekit.AddTrackRequest{
Cid: "cid",
Name: "webcam",
Type: livekit.TrackType_VIDEO,
})
// `queued` `RequestResponse` should add a message
require.Equal(t, 1, sink.WriteMessageCallCount())
require.Equal(t, 1, len(p.pendingTracks["cid"].trackInfos))
// add again - it should be added to the queue
p.AddTrack(&livekit.AddTrackRequest{
Cid: "cid",
Name: "webcam",
Type: livekit.TrackType_VIDEO,
})
// `queued` `RequestResponse`s should have been sent for duplicate additions
require.Equal(t, 2, sink.WriteMessageCallCount())
require.Equal(t, 2, len(p.pendingTracks["cid"].trackInfos))
// check SID is the same
require.Equal(t, p.pendingTracks["cid"].trackInfos[0].Sid, p.pendingTracks["cid"].trackInfos[1].Sid)
})
t.Run("should release timed out pending tracks when at the pending track limit", func(t *testing.T) {
p := newParticipantForTestWithOpts("test", &participantOpts{protocolVersion: 7})
sink := p.params.Sink.(*routingfakes.FakeMessageSink)
// one slot holds two queued entries that share a track id, both before any media
queued := p.addPendingTrack(&livekit.AddTrackRequest{Cid: "cidq", Name: "q0", Type: livekit.TrackType_VIDEO})
require.NotNil(t, queued)
p.addPendingTrack(&livekit.AddTrackRequest{Cid: "cidq", Name: "q1", Type: livekit.TrackType_VIDEO}) // queued, returns nil
require.Len(t, p.pendingTracks["cidq"].trackInfos, 2)
var sids []livekit.TrackID
for i := 0; i < cMaxPendingTracks-1; i++ {
ti := p.addPendingTrack(&livekit.AddTrackRequest{
Cid: fmt.Sprintf("cid%d", i),
Name: fmt.Sprintf("track%d", i),
Type: livekit.TrackType_VIDEO,
})
require.NotNil(t, ti)
sids = append(sids, livekit.TrackID(ti.Sid))
}
require.Len(t, p.pendingTracks, cMaxPendingTracks)
// nothing timed out, so a new publish is rejected
require.Nil(t, p.addPendingTrack(&livekit.AddTrackRequest{Cid: "fresh", Name: "fresh", Type: livekit.TrackType_VIDEO}))
// one lone track times out, a new publish takes its slot
p.supervisor.SetPublicationTimedOutForTest(sids[0])
numMessages := sink.WriteMessageCallCount()
require.NotNil(t, p.addPendingTrack(&livekit.AddTrackRequest{Cid: "fresh", Name: "fresh", Type: livekit.TrackType_VIDEO}))
require.Nil(t, p.pendingTracks["cid0"])
require.NotNil(t, p.pendingTracks["fresh"])
require.Len(t, p.pendingTracks, cMaxPendingTracks)
require.Empty(t, p.supervisor.TimedOutPublications())
require.Equal(t, numMessages+1, sink.WriteMessageCallCount())
res := sink.WriteMessageArgsForCall(numMessages).(*livekit.SignalResponse)
require.IsType(t, &livekit.SignalResponse_TrackUnpublished{}, res.Message)
require.Equal(t, string(sids[0]), res.Message.(*livekit.SignalResponse_TrackUnpublished).TrackUnpublished.TrackSid)
// the queued track id times out, both entries are released together
p.supervisor.SetPublicationTimedOutForTest(livekit.TrackID(queued.Sid))
require.NotNil(t, p.addPendingTrack(&livekit.AddTrackRequest{Cid: "fresh2", Name: "fresh2", Type: livekit.TrackType_VIDEO}))
require.Nil(t, p.pendingTracks["cidq"])
require.NotNil(t, p.pendingTracks["fresh2"])
require.Len(t, p.pendingTracks, cMaxPendingTracks)
})
t.Run("should not match late media of a released pending track to another pending track", func(t *testing.T) {
p := newParticipantForTestWithOpts("test", &participantOpts{protocolVersion: 7})
released := p.addPendingTrack(&livekit.AddTrackRequest{Cid: "cidA", Name: "a", Type: livekit.TrackType_VIDEO})
require.NotNil(t, released)
// SDP client id that an offer would set, as Firefox does
p.pendingTracks["cidA"].trackInfos[0].Codecs[0].SdpCid = "sdpA"
p.pendingTracksLock.Lock()
p.releasePendingTrackLocked(livekit.TrackID(released.Sid))
p.pendingTracksLock.Unlock()
other := p.addPendingTrack(&livekit.AddTrackRequest{Cid: "cidB", Name: "b", Type: livekit.TrackType_VIDEO})
require.NotNil(t, other)
// media for the released track, by signal or SDP client id, does not fall back to the other track by type
for _, cid := range []string{"cidA", "sdpA"} {
_, ti, _, _, _ := p.getPendingTrack(cid, livekit.TrackType_VIDEO, true)
require.Nil(t, ti, cid)
}
// an unknown client id still falls back by type, as before
_, ti, _, _, _ := p.getPendingTrack("unknown", livekit.TrackType_VIDEO, true)
require.NotNil(t, ti)
require.Equal(t, other.Sid, ti.Sid)
// reusing the released client id publishes on it again
reused := p.addPendingTrack(&livekit.AddTrackRequest{Cid: "cidA", Name: "a2", Type: livekit.TrackType_VIDEO})
require.NotNil(t, reused)
_, ti, _, _, _ = p.getPendingTrack("cidA", livekit.TrackType_VIDEO, true)
require.NotNil(t, ti)
require.Equal(t, reused.Sid, ti.Sid)
// the old SDP id is cleared too, a reused transceiver keeps it
p.pendingTracksLock.Lock()
require.False(t, p.isReleasedTrackCidLocked("sdpA"))
p.pendingTracksLock.Unlock()
})
t.Run("should clear a released track when a new request reuses one of its codec client ids", func(t *testing.T) {
p := newParticipantForTest("test")
p.pendingTracksLock.Lock()
p.addReleasedTrackCidsLocked([]string{"cidA", "codecA", "sdpA"})
p.addReleasedTrackCidsLocked([]string{"cidB"})
p.clearReleasedTrackCidsLocked(&livekit.AddTrackRequest{
Cid: "cidC",
SimulcastCodecs: []*livekit.SimulcastCodec{{Codec: "vp8", Cid: "codecA"}},
})
for _, cid := range []string{"cidA", "codecA", "sdpA"} {
require.False(t, p.isReleasedTrackCidLocked(cid), cid)
}
require.True(t, p.isReleasedTrackCidLocked("cidB"))
p.pendingTracksLock.Unlock()
})
t.Run("should bound released track client ids", func(t *testing.T) {
p := newParticipantForTest("test")
p.pendingTracksLock.Lock()
for i := 0; i <= cMaxReleasedTracks; i++ {
p.addReleasedTrackCidsLocked([]string{fmt.Sprintf("cid%d", i), fmt.Sprintf("sdp%d", i)})
}
require.Len(t, p.releasedTrackCids, cMaxReleasedTracks)
// the oldest track ages out with all of its ids
require.False(t, p.isReleasedTrackCidLocked("cid0"))
require.False(t, p.isReleasedTrackCidLocked("sdp0"))
require.True(t, p.isReleasedTrackCidLocked(fmt.Sprintf("sdp%d", cMaxReleasedTracks)))
p.pendingTracksLock.Unlock()
})
t.Run("should not allow adding disallowed sources", func(t *testing.T) {
p := newParticipantForTest("test")
p.SetPermission(&livekit.ParticipantPermission{
CanPublish: true,
CanPublishSources: []livekit.TrackSource{
livekit.TrackSource_CAMERA,
},
})
sink := p.params.Sink.(*routingfakes.FakeMessageSink)
p.AddTrack(&livekit.AddTrackRequest{
Cid: "cid",
Name: "webcam",
Source: livekit.TrackSource_CAMERA,
Type: livekit.TrackType_VIDEO,
})
require.Equal(t, 1, sink.WriteMessageCallCount())
p.AddTrack(&livekit.AddTrackRequest{
Cid: "cid2",
Name: "rejected source",
Type: livekit.TrackType_AUDIO,
Source: livekit.TrackSource_MICROPHONE,
})
// an error response for disallowed source should send a `RequestResponse`.
require.Equal(t, 2, sink.WriteMessageCallCount())
})
}
func TestOutOfOrderUpdates(t *testing.T) {
p := newParticipantForTest("test")
p.updateState(livekit.ParticipantInfo_JOINED)
p.SetMetadata("initial metadata")
sink := p.GetResponseSink().(*routingfakes.FakeMessageSink)
pi1 := p.ToProto()
p.SetMetadata("second update")
pi2 := p.ToProto()
require.Greater(t, pi2.Version, pi1.Version)
// send the second update first
require.NoError(t, p.SendParticipantUpdate([]*livekit.ParticipantInfo{pi2}))
require.NoError(t, p.SendParticipantUpdate([]*livekit.ParticipantInfo{pi1}))
// only sent once, and it's the earlier message
require.Equal(t, 1, sink.WriteMessageCallCount())
sent := sink.WriteMessageArgsForCall(0).(*livekit.SignalResponse)
require.Equal(t, "second update", sent.GetUpdate().Participants[0].Metadata)
}
func TestRecentlyDisconnectedKeepsUpdateOrder(t *testing.T) {
p := newParticipantForTest("test")
// fill the update cache (128 entries), the last update is a disconnect
const cacheSize = 128
for i := range cacheSize {
state := livekit.ParticipantInfo_ACTIVE
if i == cacheSize-1 {
state = livekit.ParticipantInfo_DISCONNECTED
}
require.NoError(t, p.SendParticipantUpdate([]*livekit.ParticipantInfo{{
Sid: fmt.Sprintf("PA_%d", i),
Identity: fmt.Sprintf("p%d", i),
Version: 1,
State: state,
}}))
}
disconnected := p.GetRecentlyDisconnectedParticipants()
require.Len(t, disconnected, 1)
require.Equal(t, "PA_127", disconnected[0].Sid)
// an update for a new participant evicts the least recently updated entry,
// not the disconnect that was just reported
require.NoError(t, p.SendParticipantUpdate([]*livekit.ParticipantInfo{{
Sid: "PA_new",
Identity: "new",
Version: 1,
State: livekit.ParticipantInfo_ACTIVE,
}}))
disconnected = p.GetRecentlyDisconnectedParticipants()
require.Len(t, disconnected, 1)
require.Equal(t, "PA_127", disconnected[0].Sid)
}
// after disconnection, things should continue to function and not panic
func TestDisconnectTiming(t *testing.T) {
t.Run("Negotiate doesn't panic after channel closed", func(t *testing.T) {
p := newParticipantForTest("test")
msg := routing.NewMessageChannel(livekit.ConnectionID("test"), routing.DefaultMessageChannelSize)
p.params.Sink = msg
go func() {
for msg := range msg.ReadChan() {
t.Log("received message from chan", msg)
}
}()
track := &typesfakes.FakeMediaTrack{}
p.UpTrackManager.AddPublishedTrack(track)
p.handleTrackPublished(track, false, false)
// close channel and then try to Negotiate
msg.Close()
})
}
func TestCorrectJoinedAt(t *testing.T) {
p := newParticipantForTest("test")
info := p.ToProto()
require.NotZero(t, info.JoinedAt)
require.True(t, time.Now().Unix()-info.JoinedAt <= 1)
}
func TestMuteSetting(t *testing.T) {
t.Run("can set mute when track is pending", func(t *testing.T) {
p := newParticipantForTest("test")
ti := &livekit.TrackInfo{Sid: "testTrack"}
p.pendingTracks["cid"] = &pendingTrackInfo{trackInfos: []*livekit.TrackInfo{ti}}
p.SetTrackMuted(&livekit.MuteTrackRequest{
Sid: ti.Sid,
Muted: true,
}, false)
require.True(t, p.pendingTracks["cid"].trackInfos[0].Muted)
})
t.Run("can publish a muted track", func(t *testing.T) {
p := newParticipantForTest("test")
p.AddTrack(&livekit.AddTrackRequest{
Cid: "cid",
Type: livekit.TrackType_AUDIO,
Muted: true,
})
_, ti, _, _, _ := p.getPendingTrack("cid", livekit.TrackType_AUDIO, false)
require.NotNil(t, ti)
require.True(t, ti.Muted)
})
}
func TestSubscriberAsPrimary(t *testing.T) {
t.Run("protocol 4 uses subs as primary", func(t *testing.T) {
p := newParticipantForTestWithOpts("test", &participantOpts{
permissions: &livekit.ParticipantPermission{
CanSubscribe: true,
CanPublish: true,
},
})
require.True(t, p.SubscriberAsPrimary())
})
t.Run("protocol 2 uses pub as primary", func(t *testing.T) {
p := newParticipantForTestWithOpts("test", &participantOpts{
protocolVersion: 2,
permissions: &livekit.ParticipantPermission{
CanSubscribe: true,
CanPublish: true,
},
})
require.False(t, p.SubscriberAsPrimary())
})
t.Run("publisher only uses pub as primary", func(t *testing.T) {
p := newParticipantForTestWithOpts("test", &participantOpts{
permissions: &livekit.ParticipantPermission{
CanSubscribe: false,
CanPublish: true,
},
})
require.False(t, p.SubscriberAsPrimary())
// ensure that it doesn't change after perms
p.SetPermission(&livekit.ParticipantPermission{
CanSubscribe: true,
CanPublish: true,
})
require.False(t, p.SubscriberAsPrimary())
})
}
func TestDisableCodecs(t *testing.T) {
participant := newParticipantForTestWithOpts("123", &participantOpts{
publisher: false,
clientConf: &livekit.ClientConfiguration{
DisabledCodecs: &livekit.DisabledCodecs{
Codecs: []*livekit.Codec{
{Mime: "video/h264"},
},
},
},
})
participant.SetMigrateState(types.MigrateStateComplete)
pc, err := webrtc.NewPeerConnection(webrtc.Configuration{})
require.NoError(t, err)
transceiver, err := pc.AddTransceiverFromKind(webrtc.RTPCodecTypeVideo, webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionSendrecv})
require.NoError(t, err)
sdp, err := pc.CreateOffer(nil)
require.NoError(t, err)
pc.SetLocalDescription(sdp)
codecs := transceiver.Receiver().GetParameters().Codecs
var found264 bool
for _, c := range codecs {
if mime.IsMimeTypeStringH264(c.MimeType) {
found264 = true
}
}
require.True(t, found264)
offerId := uint32(42)
// negotiated codec should not contain h264
sink := &routingfakes.FakeMessageSink{}
participant.SwapResponseSink(sink, types.SignallingCloseReasonUnknown)
var answer webrtc.SessionDescription
var answerId uint32
var answerReceived atomic.Bool
var answerIdReceived atomic.Uint32
sink.WriteMessageCalls(func(msg proto.Message) error {
if res, ok := msg.(*livekit.SignalResponse); ok {
if res.GetAnswer() != nil {
answer, answerId, _ = signalling.FromProtoSessionDescription(res.GetAnswer())
answerReceived.Store(true)
answerIdReceived.Store(answerId)
}
}
return nil
})
participant.HandleOffer(&livekit.SessionDescription{
Type: webrtc.SDPTypeOffer.String(),
Sdp: sdp.SDP,
Id: offerId,
})
testutils.WithTimeout(t, func() string {
if answerReceived.Load() && answerIdReceived.Load() == offerId {
return ""
} else {
return "answer not received OR answer id mismatch"
}
})
require.NoError(t, pc.SetRemoteDescription(answer), answer.SDP, sdp.SDP)
codecs = transceiver.Receiver().GetParameters().Codecs
found264 = false
for _, c := range codecs {
if mime.IsMimeTypeStringH264(c.MimeType) {
found264 = true
}
}
require.False(t, found264)
}
func TestDisablePublishCodec(t *testing.T) {
participant := newParticipantForTestWithOpts("123", &participantOpts{
publisher: true,
clientConf: &livekit.ClientConfiguration{
DisabledCodecs: &livekit.DisabledCodecs{
Publish: []*livekit.Codec{
{Mime: "video/h264"},
},
},
},
})
for _, codec := range participant.enabledPublishCodecs {
require.False(t, mime.IsMimeTypeStringH264(codec.Mime))
}
sink := &routingfakes.FakeMessageSink{}
participant.SwapResponseSink(sink, types.SignallingCloseReasonUnknown)
var publishReceived atomic.Bool
sink.WriteMessageCalls(func(msg proto.Message) error {
if res, ok := msg.(*livekit.SignalResponse); ok {
if published := res.GetTrackPublished(); published != nil {
publishReceived.Store(true)
require.NotEmpty(t, published.Track.Codecs)
require.True(t, mime.IsMimeTypeStringVP8(published.Track.Codecs[0].MimeType))
}
}
return nil
})
// simulcast codec response should pick an alternative
participant.AddTrack(&livekit.AddTrackRequest{
Cid: "cid1",
Type: livekit.TrackType_VIDEO,
SimulcastCodecs: []*livekit.SimulcastCodec{{
Codec: "h264",
Cid: "cid1",
}},
})
require.Eventually(t, func() bool { return publishReceived.Load() }, 5*time.Second, 10*time.Millisecond)
// publishing a supported codec should not change
publishReceived.Store(false)
sink.WriteMessageCalls(func(msg proto.Message) error {
if res, ok := msg.(*livekit.SignalResponse); ok {
if published := res.GetTrackPublished(); published != nil {
publishReceived.Store(true)
require.NotEmpty(t, published.Track.Codecs)
require.True(t, mime.IsMimeTypeStringVP8(published.Track.Codecs[0].MimeType))
}
}
return nil
})
participant.AddTrack(&livekit.AddTrackRequest{
Cid: "cid2",
Type: livekit.TrackType_VIDEO,
SimulcastCodecs: []*livekit.SimulcastCodec{{
Codec: "vp8",
Cid: "cid2",
}},
})
require.Eventually(t, func() bool { return publishReceived.Load() }, 5*time.Second, 10*time.Millisecond)
}
func TestPreferMediaCodecForPublisher(t *testing.T) {
testCases := []struct {
name string
mediaKind string
trackBaseCid string
preferredCodec string
addTrack *livekit.AddTrackRequest
mimeTypeStringChecker func(string) bool
mimeTypeCodecStringChecker func(string) bool
transceiverMimeType mime.MimeType
}{
{
name: "video",
mediaKind: "video",
trackBaseCid: "preferH264Video",
preferredCodec: "h264",
addTrack: &livekit.AddTrackRequest{
Type: livekit.TrackType_VIDEO,
Name: "video",
Width: 1280,
Height: 720,
Source: livekit.TrackSource_CAMERA,
},
mimeTypeStringChecker: mime.IsMimeTypeStringH264,
mimeTypeCodecStringChecker: mime.IsMimeTypeCodecStringH264,
transceiverMimeType: mime.MimeTypeVP8,
},
{
name: "audio",
mediaKind: "audio",
trackBaseCid: "preferPCMAAudio",
preferredCodec: "pcma",
addTrack: &livekit.AddTrackRequest{
Type: livekit.TrackType_AUDIO,
Name: "audio",
Source: livekit.TrackSource_MICROPHONE,
},
mimeTypeStringChecker: mime.IsMimeTypeStringPCMA,
mimeTypeCodecStringChecker: mime.IsMimeTypeCodecStringPCMA,
transceiverMimeType: mime.MimeTypeOpus,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
participant := newParticipantForTestWithOpts("123", &participantOpts{
publisher: true,
})
participant.SetMigrateState(types.MigrateStateComplete)
pc, err := webrtc.NewPeerConnection(webrtc.Configuration{})
require.NoError(t, err)
defer pc.Close()
for i := 0; i < 2; i++ {
// publish preferred track without client using setCodecPreferences()
trackCid := fmt.Sprintf("%s-%d", tc.trackBaseCid, i)
req := proto.CloneOf(tc.addTrack)
req.SimulcastCodecs = []*livekit.SimulcastCodec{
{
Codec: tc.preferredCodec,
Cid: trackCid,
},
}
participant.AddTrack(req)
track, err := webrtc.NewTrackLocalStaticRTP(webrtc.RTPCodecCapability{MimeType: tc.transceiverMimeType.String()}, trackCid, trackCid)
require.NoError(t, err)
transceiver, err := pc.AddTransceiverFromTrack(track, webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionSendrecv})
require.NoError(t, err)
codecs := transceiver.Receiver().GetParameters().Codecs
if i > 0 {
// the negotiated codecs order could be updated by first negotiation,
// reorder to make tested preferred codec not preferred
for tc.mimeTypeStringChecker(codecs[0].MimeType) {
codecs = append(codecs[1:], codecs[0])
}
}
// preferred codec should not be preferred in `offer`
require.False(t, tc.mimeTypeStringChecker(codecs[0].MimeType), "codecs", codecs)
sdp, err := pc.CreateOffer(nil)
require.NoError(t, err)
require.NoError(t, pc.SetLocalDescription(sdp))
offerId := uint32(23)
sink := &routingfakes.FakeMessageSink{}
participant.SwapResponseSink(sink, types.SignallingCloseReasonUnknown)
var answer webrtc.SessionDescription
var answerId uint32
var answerReceived atomic.Bool
var answerIdReceived atomic.Uint32
sink.WriteMessageCalls(func(msg proto.Message) error {
if res, ok := msg.(*livekit.SignalResponse); ok {
if res.GetAnswer() != nil {
answer, answerId, _ = signalling.FromProtoSessionDescription(res.GetAnswer())
pc.SetRemoteDescription(answer)
answerReceived.Store(true)
answerIdReceived.Store(answerId)
}
}
return nil
})
participant.HandleOffer(&livekit.SessionDescription{
Type: webrtc.SDPTypeOffer.String(),
Sdp: sdp.SDP,
Id: offerId,
})
require.Eventually(t, func() bool { return answerReceived.Load() && answerIdReceived.Load() == offerId }, 5*time.Second, 10*time.Millisecond)
var havePreferred bool
parsed, err := answer.Unmarshal()
require.NoError(t, err)
var mediaSectionIndex int
for _, m := range parsed.MediaDescriptions {
if m.MediaName.Media == tc.mediaKind {
if mediaSectionIndex == i {
codecs, err := lksdp.CodecsFromMediaDescription(m)
require.NoError(t, err)
if tc.mimeTypeCodecStringChecker(codecs[0].Name) {
havePreferred = true
break
}
}
mediaSectionIndex++
}
}
require.Truef(t, havePreferred, "%s should be preferred for %s section %d, answer sdp: \n%s", tc.preferredCodec, tc.mediaKind, i, answer.SDP)
}
})
}
}
func TestOneShotSimulcastOfferAppliesRids(t *testing.T) {
participant := newParticipantForTestWithOpts("whip", &participantOpts{
publisher: true,
oneShot: true,
})
participant.SetMigrateState(types.MigrateStateComplete)
pc, err := webrtc.NewPeerConnection(webrtc.Configuration{})
require.NoError(t, err)
defer pc.Close()
codec := webrtc.RTPCodecCapability{MimeType: mime.MimeTypeVP8.String()}
medium, err := webrtc.NewTrackLocalStaticRTP(codec, "video", "stream", webrtc.WithRTPStreamID("h"))
require.NoError(t, err)
low, err := webrtc.NewTrackLocalStaticRTP(codec, "video", "stream", webrtc.WithRTPStreamID("q"))
require.NoError(t, err)
sender, err := pc.AddTrack(medium)
require.NoError(t, err)
require.NoError(t, sender.AddEncoding(low))
offer, err := pc.CreateOffer(nil)
require.NoError(t, err)
require.NoError(t, pc.SetLocalDescription(offer))
require.Contains(t, offer.SDP, "a=simulcast:send h;q")
require.NoError(t, participant.HandleOffer(&livekit.SessionDescription{
Type: webrtc.SDPTypeOffer.String(),
Sdp: offer.SDP,
}))
participant.pendingTracksLock.RLock()
pti := participant.pendingTracks["video"]
participant.pendingTracksLock.RUnlock()
require.NotNil(t, pti)
layers := pti.trackInfos[0].Codecs[0].Layers
require.Len(t, layers, 2)
require.Equal(t, livekit.VideoQuality_LOW, layers[0].Quality)
require.Equal(t, "q", layers[0].Rid)
require.EqualValues(t, 0, layers[0].SpatialLayer)
require.Equal(t, livekit.VideoQuality_MEDIUM, layers[1].Quality)
require.Equal(t, "h", layers[1].Rid)
require.EqualValues(t, 1, layers[1].SpatialLayer)
}
func TestOneShotSingleLayerOfferUnchanged(t *testing.T) {
participant := newParticipantForTestWithOpts("whip", &participantOpts{
publisher: true,
oneShot: true,
})
participant.SetMigrateState(types.MigrateStateComplete)
pc, err := webrtc.NewPeerConnection(webrtc.Configuration{})
require.NoError(t, err)
defer pc.Close()
track, err := webrtc.NewTrackLocalStaticRTP(webrtc.RTPCodecCapability{MimeType: mime.MimeTypeVP8.String()}, "video", "stream")
require.NoError(t, err)
_, err = pc.AddTransceiverFromTrack(track, webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionSendonly})
require.NoError(t, err)
offer, err := pc.CreateOffer(nil)
require.NoError(t, err)
require.NoError(t, pc.SetLocalDescription(offer))
require.NotContains(t, offer.SDP, "a=simulcast")
require.NotContains(t, offer.SDP, "a=rid")
require.NoError(t, participant.HandleOffer(&livekit.SessionDescription{
Type: webrtc.SDPTypeOffer.String(),
Sdp: offer.SDP,
}))
participant.pendingTracksLock.RLock()
pti := participant.pendingTracks["video"]
participant.pendingTracksLock.RUnlock()
require.NotNil(t, pti)
layers := pti.trackInfos[0].Codecs[0].Layers
require.Len(t, layers, 1)
require.Equal(t, "", layers[0].Rid)
require.EqualValues(t, 0, layers[0].SpatialLayer)
}
func TestPreferAudioCodecForRed(t *testing.T) {
participant := newParticipantForTestWithOpts("123", &participantOpts{
publisher: true,
})
participant.SetMigrateState(types.MigrateStateComplete)
me := webrtc.MediaEngine{}
opusCodecParameters := protoCodecs.OpusCodecParameters
opusCodecParameters.RTPCodecCapability.RTCPFeedback = []webrtc.RTCPFeedback{{Type: webrtc.TypeRTCPFBNACK}}
require.NoError(t, me.RegisterCodec(opusCodecParameters, webrtc.RTPCodecTypeAudio))
redCodecParameters := protoCodecs.RedCodecParameters
redCodecParameters.RTPCodecCapability.RTCPFeedback = []webrtc.RTCPFeedback{{Type: webrtc.TypeRTCPFBNACK}}
require.NoError(t, me.RegisterCodec(redCodecParameters, webrtc.RTPCodecTypeAudio))
api := webrtc.NewAPI(webrtc.WithMediaEngine(&me))
pc, err := api.NewPeerConnection(webrtc.Configuration{})
require.NoError(t, err)
defer pc.Close()
for idx, disableRed := range []bool{false, true, false, true} {
t.Run(fmt.Sprintf("disableRed=%v", disableRed), func(t *testing.T) {
trackCid := fmt.Sprintf("audiotrack%d", idx)
req := &livekit.AddTrackRequest{
Type: livekit.TrackType_AUDIO,
Cid: trackCid,
}
if idx < 2 {
req.DisableRed = disableRed
} else {
codec := "red"
if disableRed {
codec = "opus"
}
req.SimulcastCodecs = []*livekit.SimulcastCodec{
{
Codec: codec,
Cid: trackCid,
},
}
}
participant.AddTrack(req)
track, err := webrtc.NewTrackLocalStaticRTP(
webrtc.RTPCodecCapability{MimeType: "audio/opus"},
trackCid,
trackCid,
)
require.NoError(t, err)
transceiver, err := pc.AddTransceiverFromTrack(
track,
webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionSendrecv},
)
require.NoError(t, err)
codecs := transceiver.Sender().GetParameters().Codecs
for i, c := range codecs {
if c.MimeType == "audio/opus" {
if i != 0 {
codecs[0], codecs[i] = codecs[i], codecs[0]
}
break
}
}
transceiver.SetCodecPreferences(codecs)
sdp, err := pc.CreateOffer(nil)
require.NoError(t, err)
pc.SetLocalDescription(sdp)
// opus should be preferred
require.Equal(t, codecs[0].MimeType, "audio/opus", sdp)
offerId := uint32(0xffffff)
sink := &routingfakes.FakeMessageSink{}
participant.SwapResponseSink(sink, types.SignallingCloseReasonUnknown)
var answer webrtc.SessionDescription
var answerId uint32
var answerReceived atomic.Bool
var answerIdReceived atomic.Uint32
sink.WriteMessageCalls(func(msg proto.Message) error {
if res, ok := msg.(*livekit.SignalResponse); ok {
if res.GetAnswer() != nil {
answer, answerId, _ = signalling.FromProtoSessionDescription(res.GetAnswer())
pc.SetRemoteDescription(answer)
answerReceived.Store(true)
answerIdReceived.Store(answerId)
}
}
return nil
})
participant.HandleOffer(&livekit.SessionDescription{
Type: webrtc.SDPTypeOffer.String(),
Sdp: sdp.SDP,
Id: offerId,
})
require.Eventually(
t,
func() bool {
return answerReceived.Load() && answerIdReceived.Load() == offerId
},
5*time.Second,
10*time.Millisecond,
)
var redPreferred bool
parsed, err := answer.Unmarshal()
require.NoError(t, err)
var audioSectionIndex int
for _, m := range parsed.MediaDescriptions {
if m.MediaName.Media == "audio" {
if audioSectionIndex == idx {
codecs, err := lksdp.CodecsFromMediaDescription(m)
require.NoError(t, err)
// nack is always enabled. if red is preferred, server will not generate nack request
var nackEnabled bool
for _, c := range codecs {
if c.Name == "opus" {
for _, fb := range c.RTCPFeedback {
if strings.Contains(fb, "nack") {
nackEnabled = true
break
}
}
}
}
require.True(t, nackEnabled, "nack should be enabled for opus")
if mime.IsMimeTypeCodecStringRED(codecs[0].Name) {
redPreferred = true
break
}
}
audioSectionIndex++
}
}
require.Equalf(t, !disableRed, redPreferred, "offer : \n%s\nanswer sdp: \n%s", sdp, answer.SDP)
})
}
}
type participantOpts struct {
permissions *livekit.ParticipantPermission
protocolVersion types.ProtocolVersion
publisher bool
clientConf *livekit.ClientConfiguration
clientInfo *livekit.ClientInfo
migration bool
oneShot bool
}
func newParticipantForTestWithOpts(identity livekit.ParticipantIdentity, opts *participantOpts) *ParticipantImpl {
if opts == nil {
opts = &participantOpts{}
}
if opts.protocolVersion == 0 {
opts.protocolVersion = 6
}
conf, _ := config.NewConfig("", true, nil, nil)
// disable mux, it doesn't play too well with unit test
conf.RTC.TCPPort = 0
rtcConf, err := NewWebRTCConfig(conf)
if err != nil {
panic(err)
}
ff := buffer.NewFactoryOfBufferFactory(500, 200)
rtcConf.SetBufferFactory(ff.CreateBufferFactory())
grants := &auth.ClaimGrants{
Video: &auth.VideoGrant{},
}
if opts.permissions != nil {
grants.Video.SetCanPublish(opts.permissions.CanPublish)
grants.Video.SetCanPublishData(opts.permissions.CanPublishData)
grants.Video.SetCanSubscribe(opts.permissions.CanSubscribe)
}
enabledCodecs := make([]*livekit.Codec, 0, len(conf.Room.EnabledCodecs))
for _, c := range conf.Room.EnabledCodecs {
enabledCodecs = append(enabledCodecs, &livekit.Codec{
Mime: c.Mime,
FmtpLine: c.FmtpLine,
})
}
sid := livekit.ParticipantID(guid.New(utils.ParticipantPrefix))
p, _ := NewParticipant(ParticipantParams{
SID: sid,
Identity: identity,
Config: rtcConf,
Sink: &routingfakes.FakeMessageSink{},
ProtocolVersion: opts.protocolVersion,
SessionStartTime: time.Now(),
PLIThrottleConfig: conf.RTC.PLIThrottle,
Grants: grants,
PublishEnabledCodecs: enabledCodecs,
SubscribeEnabledCodecs: enabledCodecs,
ClientConf: opts.clientConf,
ClientInfo: ClientInfo{ClientInfo: opts.clientInfo},
Logger: LoggerWithParticipant(logger.GetLogger(), identity, sid, false),
Reporter: roomobs.NewNoopParticipantSessionReporter(),
TelemetryListener: &typesfakes.FakeParticipantTelemetryListener{},
VersionGenerator: utils.NewDefaultTimedVersionGenerator(),
ParticipantListener: &typesfakes.FakeLocalParticipantListener{},
ParticipantHelper: &typesfakes.FakeLocalParticipantHelper{},
Migration: opts.migration,
UseOneShotSignallingMode: opts.oneShot,
})
p.isPublisher.Store(opts.publisher)
p.updateState(livekit.ParticipantInfo_ACTIVE)
return p
}
func newParticipantForTest(identity livekit.ParticipantIdentity) *ParticipantImpl {
return newParticipantForTestWithOpts(identity, nil)
}
func TestUnsequencedReliableDataBufferedWhileJoining(t *testing.T) {
// unsequenced reliable data, i. e. room service SendData, arriving before the
// reliable data channel is writable should be held and replayed, not dropped
t.Run("buffers and replays", func(t *testing.T) {
p := newParticipantForTest("test")
require.False(t, p.reliableDataInfo.canWriteReliable)
require.NoError(t, p.SendDataMessage(livekit.DataPacket_RELIABLE, []byte("one"), "", 0))
require.NoError(t, p.SendDataMessage(livekit.DataPacket_RELIABLE, []byte("two"), "", 0))
require.Equal(t, [][]byte{[]byte("one"), []byte("two")}, p.reliableDataInfo.joiningUnsequencedMessages)
require.Equal(t, 6, p.reliableDataInfo.joiningUnsequencedBytes)
p.replayJoiningReliableMessages()
require.True(t, p.reliableDataInfo.canWriteReliable)
require.Empty(t, p.reliableDataInfo.joiningUnsequencedMessages)
require.Zero(t, p.reliableDataInfo.joiningUnsequencedBytes)
})
t.Run("does not buffer once writable", func(t *testing.T) {
p := newParticipantForTest("test")
p.replayJoiningReliableMessages()
// no data channel in test, so the write through fails rather than getting buffered
require.Error(t, p.SendDataMessage(livekit.DataPacket_RELIABLE, []byte("one"), "", 0))
require.Empty(t, p.reliableDataInfo.joiningUnsequencedMessages)
})
t.Run("bounded buffer", func(t *testing.T) {
p := newParticipantForTest("test")
data := make([]byte, cMaxJoiningUnsequencedReliableBytes)
require.NoError(t, p.SendDataMessage(livekit.DataPacket_RELIABLE, data, "", 0))
require.Error(t, p.SendDataMessage(livekit.DataPacket_RELIABLE, []byte("overflow"), "", 0))
require.Len(t, p.reliableDataInfo.joiningUnsequencedMessages, 1)
require.Equal(t, 1, p.reliableDataInfo.joiningUnsequencedDropped)
})
t.Run("lossy is not buffered", func(t *testing.T) {
p := newParticipantForTest("test")
require.Error(t, p.SendDataMessage(livekit.DataPacket_LOSSY, []byte("one"), "", 0))
require.Empty(t, p.reliableDataInfo.joiningUnsequencedMessages)
})
}
func TestMigratingInParticipantWaitsForReconnectResponse(t *testing.T) {
// a migrating in participant resumes on a ReconnectResponse, the client takes it only
// as the first message on the resumed signal connection, so everything else waits
newMigratingParticipant := func() (*ParticipantImpl, *routingfakes.FakeMessageSink) {
p := newParticipantForTestWithOpts("test", &participantOpts{
migration: true,
protocolVersion: 17,
clientInfo: &livekit.ClientInfo{Sdk: livekit.ClientInfo_JS, Version: "2.15.2"},
})
return p, p.params.Sink.(*routingfakes.FakeMessageSink)
}
t.Run("not ready until the response is sent", func(t *testing.T) {
p, sink := newMigratingParticipant()
require.False(t, p.IsReady())
require.NoError(t, p.HandleReconnectAndSendResponse(
livekit.ReconnectReason_RR_UNKNOWN,
&livekit.ReconnectResponse{LastMessageSeq: 21},
nil,
))
require.True(t, p.IsReady())
require.Equal(t, 1, sink.WriteMessageCallCount())
res := sink.WriteMessageArgsForCall(0).(*livekit.SignalResponse)
require.NotNil(t, res.GetReconnect())
require.EqualValues(t, 21, res.GetReconnect().LastMessageSeq)
})
t.Run("the response goes out first", func(t *testing.T) {
p, sink := newMigratingParticipant()
// a room update while waiting is dropped, it is re-sent after the migration
require.NoError(t, p.SendRoomUpdate(&livekit.Room{Name: "test"}))
// participant updates while waiting are queued
require.NoError(t, p.SendParticipantUpdate([]*livekit.ParticipantInfo{{
Sid: "PA_other",
Identity: "other",
Version: 1,
}}))
require.Zero(t, sink.WriteMessageCallCount())
require.NoError(t, p.HandleReconnectAndSendResponse(
livekit.ReconnectReason_RR_UNKNOWN,
&livekit.ReconnectResponse{},
nil,
))
require.Equal(t, 2, sink.WriteMessageCallCount())
first := sink.WriteMessageArgsForCall(0).(*livekit.SignalResponse)
require.NotNil(t, first.GetReconnect())
second := sink.WriteMessageArgsForCall(1).(*livekit.SignalResponse)
require.NotNil(t, second.GetUpdate())
require.Len(t, second.GetUpdate().Participants, 1)
require.EqualValues(t, "PA_other", second.GetUpdate().Participants[0].Sid)
})
t.Run("a client that cannot handle the response is ready right away", func(t *testing.T) {
p := newParticipantForTestWithOpts("test", &participantOpts{
migration: true,
protocolVersion: 17,
clientInfo: &livekit.ClientInfo{Sdk: livekit.ClientInfo_JS, Version: "1.6.2"},
})
sink := p.params.Sink.(*routingfakes.FakeMessageSink)
require.NoError(t, p.HandleReconnectAndSendResponse(
livekit.ReconnectReason_RR_UNKNOWN,
&livekit.ReconnectResponse{},
nil,
))
require.True(t, p.IsReady())
require.Zero(t, sink.WriteMessageCallCount())
require.NoError(t, p.SendRoomUpdate(&livekit.Room{Name: "test"}))
require.Equal(t, 1, sink.WriteMessageCallCount())
})
}
func TestResumedParticipantWaitsForReconnectResponse(t *testing.T) {
// a resumed connection has to open with the ReconnectResponse too, and the
// participant is ready throughout, so the signaller holds messages back
newResumedParticipant := func(version string) (*ParticipantImpl, *routingfakes.FakeMessageSink) {
p := newParticipantForTestWithOpts("test", &participantOpts{
protocolVersion: 17,
clientInfo: &livekit.ClientInfo{Sdk: livekit.ClientInfo_JS, Version: version},
})
require.True(t, p.IsReady())
sink := &routingfakes.FakeMessageSink{}
p.SwapResponseSink(sink, types.SignallingCloseReasonResume)
return p, sink
}
t.Run("the response goes out first", func(t *testing.T) {
p, sink := newResumedParticipant("2.15.2")
// a room update in this window is dropped, the resume path re-sends room state
require.NoError(t, p.SendRoomUpdate(&livekit.Room{Name: "test"}))
// participant updates are queued
require.NoError(t, p.SendParticipantUpdate([]*livekit.ParticipantInfo{{
Sid: "PA_other",
Identity: "other",
Version: 1,
}}))
require.Zero(t, sink.WriteMessageCallCount())
require.NoError(t, p.HandleReconnectAndSendResponse(
livekit.ReconnectReason_RR_SIGNAL_DISCONNECTED,
&livekit.ReconnectResponse{LastMessageSeq: 7},
nil,
))
require.Equal(t, 2, sink.WriteMessageCallCount())
first := sink.WriteMessageArgsForCall(0).(*livekit.SignalResponse)
require.NotNil(t, first.GetReconnect())
require.EqualValues(t, 7, first.GetReconnect().LastMessageSeq)
second := sink.WriteMessageArgsForCall(1).(*livekit.SignalResponse)
require.NotNil(t, second.GetUpdate())
require.EqualValues(t, "PA_other", second.GetUpdate().Participants[0].Sid)
})
t.Run("a connection that opens without a response still delivers the queue", func(t *testing.T) {
p, sink := newResumedParticipant("2.15.2")
require.NoError(t, p.SendParticipantUpdate([]*livekit.ParticipantInfo{{
Sid: "PA_other",
Identity: "other",
Version: 1,
}}))
require.Zero(t, sink.WriteMessageCallCount())
// no ReconnectResponse written, this is what the handshake window does on expiry
p.signaller.OpenHandshake()
require.Equal(t, 1, sink.WriteMessageCallCount())
res := sink.WriteMessageArgsForCall(0).(*livekit.SignalResponse)
require.NotNil(t, res.GetUpdate())
require.EqualValues(t, "PA_other", res.GetUpdate().Participants[0].Sid)
})
t.Run("a client that cannot handle the response is not held back", func(t *testing.T) {
p, sink := newResumedParticipant("1.6.2")
require.NoError(t, p.HandleReconnectAndSendResponse(
livekit.ReconnectReason_RR_SIGNAL_DISCONNECTED,
&livekit.ReconnectResponse{},
nil,
))
require.Zero(t, sink.WriteMessageCallCount())
require.NoError(t, p.SendRoomUpdate(&livekit.Room{Name: "test"}))
require.Equal(t, 1, sink.WriteMessageCallCount())
})
}
func TestLeaveJoinSession(t *testing.T) {
p := newParticipantForTest("leave-join-session")
p.params.LoggerResolver = zaputil.NoOpDeferrer{}
_, p.params.ReporterResolver = roomobs.DeferredParticipantReporter(roomobs.NewNoopProjectReporter())
tl := p.GetTelemetryListener().(*typesfakes.FakeParticipantTelemetryListener)
track := &typesfakes.FakeLocalMediaTrack{}
track.IDReturns("TR_test")
track.PublishedReturns(true)
p.UpTrackManager.AddPublishedTrack(track)
// leaving reports the track unpublished, then runs the leave under the left session's
// guard, and the next session gets a fresh one
prevGuard := p.TelemetryGuard()
require.NotNil(t, prevGuard)
left := false
p.LeaveSession(func() {
left = true
require.Same(t, prevGuard, p.TelemetryGuard())
require.Equal(t, 1, tl.OnTrackUnpublishedCallCount())
})
require.True(t, left)
require.NotSame(t, prevGuard, p.TelemetryGuard())
require.Equal(t, 1, tl.OnTrackUnpublishedCallCount())
_, _, _, wasPublished, shouldSend := tl.OnTrackUnpublishedArgsForCall(0)
require.True(t, wasPublished)
require.True(t, shouldSend)
// joining runs the join first, then reports the track published again
joined := false
p.JoinSession(func() {
joined = true
require.Zero(t, tl.OnTrackPublishedCallCount())
})
require.True(t, joined)
require.Equal(t, 1, tl.OnTrackPublishedCallCount())
_, _, _, shouldSend = tl.OnTrackPublishedArgsForCall(0)
require.True(t, shouldSend)
// nil callbacks are fine
p.LeaveSession(nil)
p.JoinSession(nil)
require.Equal(t, 2, tl.OnTrackUnpublishedCallCount())
require.Equal(t, 2, tl.OnTrackPublishedCallCount())
}