mirror of
https://github.com/livekit/livekit.git
synced 2026-10-16 11:00:31 +00:00
* rtc: reclaim abandoned pending tracks A pending track is freed only when its first media arrives or the participant leaves. A client that calls AddTrack and then gives up before any media flows leaves the slot held for the whole session. After 20 such slots, every later AddTrack is rejected. Reclaim these slots when the cap is hit, if no media arrived and the slot is older than the publish timeout. Keep the supervisor monitor in step for queued tracks that share a track id. Add a publish_timeout config value, default 30s. Fixes: #4970 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * rtc: fix publish_timeout comment to not name an internal constant Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * rtc: release supervisor-timed-out pending tracks at the limit Replace the createdAt sweep with the supervisor's publish timeout. The supervisor counts time only while the publisher is connected and the track is unmuted, and never reports a live track, so a successor queued behind a live track is not released. Restart the publish timer when a queued publish reaches the front of the queue, so it does not inherit the clock of the track ahead of it. Write the publication error under the write lock, since it is now read from another goroutine. Drop the publish_timeout config value. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * rtc: skip stale supervisor timeouts and keep live monitors TimedOutPublications re-checks the monitor, because the cached error is cleared only on the next tick. A track that published since is not reported, so its queued successor is not released. RemovePublication keeps a monitor that still holds a published track. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * rtc: drop late media for released pending tracks Media or an offer with no matching pending track falls back to the first pending track of the same type. After a release, late media for the released track could take another pending track's identity. Record the released track's signal and SDP client ids, the 32 most recent, and drop media or offers for them. An AddTrack that reuses the client id clears it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * rtc: clear all ids of a released track when it is re-added A re-add cleared only the signal client id, so a Firefox republish that keeps the old SDP id had its offer and media dropped. Keep the ids of each released track together, and clear the whole track when a new AddTrack reuses its signal or simulcast codec client id. The limit now counts released tracks, and a track ages out with all of its ids. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1286 lines
44 KiB
Go
1286 lines
44 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 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
|
|
}
|
|
|
|
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,
|
|
})
|
|
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())
|
|
}
|