Files
livekit/pkg/rtc/subscriptionmanager_test.go
T
Raja SubramanianandClaude Opus 5.5 8eb162f104 rtc: do not advertise a subscribed track the subscriber cannot decode (#5117)
* rtc: do not advertise a subscribed track the subscriber cannot decode

In single peer connection mode a subscriber offers a recvonly section with
its own codecs. If the published track uses a codec the subscriber did not
offer, DownTrack.Bind finds no match but returns nil, because returning an
error aborts the whole transport. pion then keeps the sender, so the answer
still carries the track msid and ssrc. The subscriber sees a short-lived
TrackSubscribed before the bind failure removes the track again.

Park such a sender before building the answer: drop its track so the
section answers inactive, then report the codec failure out of band so the
subscription manager unsubscribes without reentering negotiation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* rtc: report codec mismatch via Handler, not the downtrack

Route the parked sender's failure through a new Handler method into the
participant, instead of reaching from transport into the downtrack. The
participant unsubscribes and reports the codec error. Keeps transport
free of the sfu package.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* rtc: use CodecsFromMediaDescription for the offered-codec check

Reuse the protocol sdp helper instead of hand-parsing rtpmap lines.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* rtc: reword codec-suppression comments

Describe it as a subscription, drop the stale prototype label.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* rtc: check every bindable codec before suppressing a sender

The check before the answer looked only at the primary codec. A subscriber
that offers a later upstream codec, for example Opus for a RED-first track,
lost a track that Bind can bind.

DownTrack.BindableCodecs returns the codecs Bind can pick, and both Bind and
the check use it. It also fixes the encrypted RED/Opus rule in Bind: the old
condition was never true, so an encrypted RED track could bind as Opus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* rtc: keep published encrypted codecs bindable, record codec rejections

The encrypted RED/Opus rule cut every RED/Opus pair to the primary codec.
That also dropped a second codec the publisher sends on its own stream.
WrappedReceiver now decides which codecs bind: for an encrypted source,
only its published codecs, never the RED or Opus alternative it adds.

A sender parked before the answer now ends its subscription through the
same path as a bind error, so it records OnTrackSubscribeFailed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-11-02 12:32:28 +05:30

1111 lines
37 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"
"sync"
"testing"
"time"
"github.com/pion/webrtc/v4"
"github.com/stretchr/testify/require"
"go.uber.org/atomic"
"github.com/livekit/livekit-server/pkg/rtc/types"
"github.com/livekit/livekit-server/pkg/rtc/types/typesfakes"
"github.com/livekit/livekit-server/pkg/utils"
"github.com/livekit/protocol/livekit"
"github.com/livekit/protocol/logger"
)
func init() {
reconcileInterval = 50 * time.Millisecond
notFoundTimeout = 200 * time.Millisecond
subscriptionTimeout = 200 * time.Millisecond
}
const (
subSettleTimeout = 600 * time.Millisecond
subCheckInterval = 10 * time.Millisecond
)
func TestSubscribe(t *testing.T) {
t.Run("happy path subscribe", func(t *testing.T) {
sm := newTestSubscriptionManager()
defer sm.Close(false)
resolver := newTestResolver(true, true, "pub", "pubID")
sm.params.TrackResolver = resolver.Resolve
subCount := atomic.Int32{}
failed := atomic.Bool{}
sm.params.OnTrackSubscribed = func(subTrack types.SubscribedTrack) {
subCount.Add(1)
}
sm.params.OnSubscriptionError = func(trackID livekit.TrackID, fatal bool, err error) {
failed.Store(true)
}
numParticipantSubscribed := atomic.Int32{}
numParticipantUnsubscribed := atomic.Int32{}
sm.OnSubscribeStatusChanged(func(pubID livekit.ParticipantID, subscribed bool) {
if subscribed {
numParticipantSubscribed.Add(1)
} else {
numParticipantUnsubscribed.Add(1)
}
})
sm.SubscribeToTrack("track", false)
s := sm.subscriptions["track"]
require.True(t, s.isDesired())
require.Eventually(t, func() bool {
return subCount.Load() == 1
}, subSettleTimeout, subCheckInterval, "track was not subscribed")
require.NotNil(t, s.getSubscribedTrack())
require.Len(t, sm.GetSubscribedTracks(), 1)
require.Eventually(t, func() bool {
return len(sm.GetSubscribedParticipants()) == 1
}, subSettleTimeout, subCheckInterval, "GetSubscribedParticipants should have returned one item")
require.Equal(t, "pubID", string(sm.GetSubscribedParticipants()[0]))
// ensure telemetry events are sent
tl := sm.params.Participant.GetTelemetryListener().(*typesfakes.FakeParticipantTelemetryListener)
require.Equal(t, 1, tl.OnTrackSubscribeRequestedCallCount())
// ensure bound
setTestSubscribedTrackBound(t, s.getSubscribedTrack())
require.Eventually(t, func() bool {
return !s.needsBind()
}, subSettleTimeout, subCheckInterval, "track was not bound")
// telemetry event should have been sent
require.Equal(t, 1, tl.OnTrackSubscribedCallCount())
time.Sleep(notFoundTimeout)
require.False(t, failed.Load())
resolver.SetPause(true)
// ensure its resilience after being closed
setTestSubscribedTrackClosed(t, s.getSubscribedTrack(), false)
require.Eventually(t, func() bool {
return s.needsSubscribe()
}, subSettleTimeout, subCheckInterval, "needs subscribe did not persist across track close")
resolver.SetPause(false)
require.Eventually(t, func() bool {
return s.isDesired() && !s.needsSubscribe()
}, subSettleTimeout, subCheckInterval, "track was not resubscribed")
// was subscribed twice, unsubscribed once (due to close)
require.Eventually(t, func() bool {
return numParticipantSubscribed.Load() == 2
}, subSettleTimeout, subCheckInterval, "participant subscribe status was not updated twice")
require.Eventually(t, func() bool {
return numParticipantUnsubscribed.Load() == 1
}, subSettleTimeout, subCheckInterval, "participant unsubscribe status was not updated")
})
t.Run("no track permission", func(t *testing.T) {
sm := newTestSubscriptionManager()
defer sm.Close(false)
resolver := newTestResolver(false, true, "pub", "pubID")
sm.params.TrackResolver = resolver.Resolve
failed := atomic.Bool{}
sm.params.OnSubscriptionError = func(trackID livekit.TrackID, fatal bool, err error) {
failed.Store(true)
}
sm.SubscribeToTrack("track", false)
s := sm.subscriptions["track"]
require.Eventually(t, func() bool {
return !s.getHasPermission()
}, subSettleTimeout, subCheckInterval, "should not have permission to subscribe")
time.Sleep(subscriptionTimeout)
// should not have called failed callbacks, isDesired remains unchanged
require.True(t, s.isDesired())
require.False(t, failed.Load())
require.True(t, s.needsSubscribe())
require.Len(t, sm.GetSubscribedTracks(), 0)
// trackSubscribed telemetry not sent
tl := sm.params.Participant.GetTelemetryListener().(*typesfakes.FakeParticipantTelemetryListener)
require.Equal(t, 1, tl.OnTrackSubscribeRequestedCallCount())
require.Equal(t, 0, tl.OnTrackSubscribedCallCount())
// give permissions now
resolver.lock.Lock()
resolver.hasPermission = true
resolver.lock.Unlock()
require.Eventually(t, func() bool {
return !s.needsSubscribe()
}, subSettleTimeout, subCheckInterval, "should be subscribed")
require.Len(t, sm.GetSubscribedTracks(), 1)
})
t.Run("publisher left", func(t *testing.T) {
sm := newTestSubscriptionManager()
defer sm.Close(false)
resolver := newTestResolver(true, true, "pub", "pubID")
sm.params.TrackResolver = resolver.Resolve
failed := atomic.Bool{}
sm.params.OnSubscriptionError = func(trackID livekit.TrackID, fatal bool, err error) {
failed.Store(true)
}
sm.SubscribeToTrack("track", false)
s := sm.subscriptions["track"]
require.Eventually(t, func() bool {
return !s.needsSubscribe()
}, subSettleTimeout, subCheckInterval, "should be subscribed")
resolver.lock.Lock()
resolver.hasTrack = false
resolver.lock.Unlock()
// publisher triggers close
setTestSubscribedTrackClosed(t, s.getSubscribedTrack(), false)
require.Eventually(t, func() bool {
return !s.isDesired()
}, subSettleTimeout, subCheckInterval, "isDesired not set to false")
})
}
func TestUnsubscribe(t *testing.T) {
sm := newTestSubscriptionManager()
defer sm.Close(false)
unsubCount := atomic.Int32{}
sm.params.OnTrackUnsubscribed = func(subTrack types.SubscribedTrack) {
unsubCount.Add(1)
}
resolver := newTestResolver(true, true, "pub", "pubID")
s := &mediaTrackSubscription{
trackSubscription: trackSubscription{
trackID: "track",
desired: true,
subscriberID: sm.params.Participant.ID(),
publisherID: "pubID",
publisherIdentity: "pub",
logger: logger.GetLogger(),
hasPermission: true,
},
bound: true,
}
// a bunch of unfortunate manual wiring
res := resolver.Resolve(nil, s.trackID)
res.TrackChangedNotifier.AddObserver(string(sm.params.Participant.ID()), func() {})
s.changedNotifier = res.TrackChangedNotifier
st, err := res.Track.AddSubscriber(sm.params.Participant)
require.NoError(t, err)
s.subscribedTrack = st
st.OnClose(func(isExpectedToResume bool) {
sm.handleSubscribedTrackClose(s, isExpectedToResume)
})
res.Track.(*typesfakes.FakeMediaTrack).RemoveSubscriberCalls(func(pID livekit.ParticipantID, isExpectedToResume bool) {
setTestSubscribedTrackClosed(t, st, isExpectedToResume)
})
sm.lock.Lock()
sm.subscriptions["track"] = s
sm.lock.Unlock()
require.False(t, s.needsSubscribe())
require.False(t, s.needsUnsubscribe())
// unsubscribe
sm.UnsubscribeFromTrack("track")
require.False(t, s.isDesired())
require.Eventually(t, func() bool {
if s.needsUnsubscribe() {
return false
}
if sm.pendingUnsubscribes.Load() != 0 {
return false
}
sm.lock.RLock()
subLen := len(sm.subscriptions)
sm.lock.RUnlock()
return subLen == 0
}, subSettleTimeout, subCheckInterval, "Track was not unsubscribed")
// no traces should be left
require.Len(t, sm.GetSubscribedTracks(), 0)
// the observer is dropped on a goroutine of its own
require.Eventually(t, func() bool {
return !res.TrackChangedNotifier.HasObservers()
}, subSettleTimeout, subCheckInterval, "observer was not removed")
tl := sm.params.Participant.GetTelemetryListener().(*typesfakes.FakeParticipantTelemetryListener)
require.Equal(t, 1, tl.OnTrackUnsubscribedCallCount())
}
func TestSubscribeStatusChanged(t *testing.T) {
sm := newTestSubscriptionManager()
defer sm.Close(false)
resolver := newTestResolver(true, true, "pub", "pubID")
sm.params.TrackResolver = resolver.Resolve
numParticipantSubscribed := atomic.Int32{}
numParticipantUnsubscribed := atomic.Int32{}
sm.OnSubscribeStatusChanged(func(pubID livekit.ParticipantID, subscribed bool) {
if subscribed {
numParticipantSubscribed.Add(1)
} else {
numParticipantUnsubscribed.Add(1)
}
})
sm.SubscribeToTrack("track1", false)
sm.SubscribeToTrack("track2", false)
s1 := sm.subscriptions["track1"]
s2 := sm.subscriptions["track2"]
require.Eventually(t, func() bool {
return !s1.needsSubscribe() && !s2.needsSubscribe()
}, subSettleTimeout, subCheckInterval, "track1 and track2 should be subscribed")
st1 := s1.getSubscribedTrack()
st1.OnClose(func(isExpectedToResume bool) {
sm.handleSubscribedTrackClose(s1, isExpectedToResume)
})
st2 := s2.getSubscribedTrack()
st2.OnClose(func(isExpectedToResume bool) {
sm.handleSubscribedTrackClose(s2, isExpectedToResume)
})
st1.MediaTrack().(*typesfakes.FakeMediaTrack).RemoveSubscriberCalls(func(pID livekit.ParticipantID, isExpectedToResume bool) {
setTestSubscribedTrackClosed(t, st1, isExpectedToResume)
})
st2.MediaTrack().(*typesfakes.FakeMediaTrack).RemoveSubscriberCalls(func(pID livekit.ParticipantID, isExpectedToResume bool) {
setTestSubscribedTrackClosed(t, st2, isExpectedToResume)
})
require.Eventually(t, func() bool {
return numParticipantSubscribed.Load() == 1
}, subSettleTimeout, subCheckInterval, "should be subscribed to publisher")
require.Equal(t, int32(0), numParticipantUnsubscribed.Load())
require.True(t, sm.IsSubscribedTo("pubID"))
// now unsubscribe track2, no event should be fired
sm.UnsubscribeFromTrack("track2")
require.Eventually(t, func() bool {
return !s2.needsUnsubscribe()
}, subSettleTimeout, subCheckInterval, "track2 should be unsubscribed")
require.Equal(t, int32(0), numParticipantUnsubscribed.Load())
// unsubscribe track1, expect event
sm.UnsubscribeFromTrack("track1")
require.Eventually(t, func() bool {
return !s1.needsUnsubscribe()
}, subSettleTimeout, subCheckInterval, "track1 should be unsubscribed")
require.Eventually(t, func() bool {
return numParticipantUnsubscribed.Load() == 1
}, subSettleTimeout, subCheckInterval, "should be subscribed to publisher")
require.False(t, sm.IsSubscribedTo("pubID"))
}
// clients may send update subscribed settings prior to subscription events coming through
// settings should be persisted and used when the subscription does take place.
func TestUpdateSettingsBeforeSubscription(t *testing.T) {
sm := newTestSubscriptionManager()
defer sm.Close(false)
resolver := newTestResolver(true, true, "pub", "pubID")
sm.params.TrackResolver = resolver.Resolve
settings := &livekit.UpdateTrackSettings{
Disabled: true,
Width: 100,
Height: 100,
}
sm.UpdateSubscribedTrackSettings("track", settings)
sm.SubscribeToTrack("track", false)
s := sm.subscriptions["track"]
require.Eventually(t, func() bool {
return !s.needsSubscribe()
}, subSettleTimeout, subCheckInterval, "Track should be subscribed")
st := s.getSubscribedTrack().(*typesfakes.FakeSubscribedTrack)
require.Eventually(t, func() bool {
return st.UpdateSubscriberSettingsCallCount() == 1
}, subSettleTimeout, subCheckInterval, "UpdateSubscriberSettings should be called once")
applied, _ := st.UpdateSubscriberSettingsArgsForCall(0)
require.Equal(t, settings.Disabled, applied.Disabled)
require.Equal(t, settings.Width, applied.Width)
require.Equal(t, settings.Height, applied.Height)
}
func TestConcurrentFirstSubscribe(t *testing.T) {
settings := &livekit.UpdateTrackSettings{Width: 100, Height: 100}
for range 200 {
sm := newTestSubscriptionManager()
var lock sync.Mutex
var subscribed bool
mt := &typesfakes.FakeMediaTrack{}
mt.IDReturns("track")
mt.AddSubscriberCalls(func(types.LocalParticipant) (types.SubscribedTrack, error) {
lock.Lock()
defer lock.Unlock()
if subscribed {
return nil, errAlreadySubscribed
}
subscribed = true
st := &typesfakes.FakeSubscribedTrack{}
st.IDReturns("track")
st.MediaTrackReturns(mt)
return st, nil
})
sm.params.TrackResolver = func(types.LocalParticipant, livekit.TrackID) types.MediaResolverResult {
return types.MediaResolverResult{
Track: mt,
HasPermission: true,
PublisherID: "pubID",
PublisherIdentity: "pub",
TrackChangedNotifier: utils.NewChangeNotifier(),
TrackRemovedNotifier: utils.NewChangeNotifier(),
}
}
// two first subscribes and a settings update race to create the subscription
var start, done sync.WaitGroup
start.Add(1)
for _, f := range []func(){
func() { sm.SubscribeToTrack("track", false) },
func() { sm.SubscribeToTrack("track", false) },
func() { sm.UpdateSubscribedTrackSettings("track", settings) },
} {
done.Add(1)
go func() {
defer done.Done()
start.Wait()
f()
}()
}
start.Done()
done.Wait()
sm.lock.RLock()
s := sm.subscriptions["track"]
sm.lock.RUnlock()
require.Eventually(t, func() bool {
return !s.needsSubscribe()
}, subSettleTimeout, subCheckInterval, "the subscription that is kept should own the down track")
st := s.getSubscribedTrack().(*typesfakes.FakeSubscribedTrack)
require.Eventually(t, func() bool {
n := st.UpdateSubscriberSettingsCallCount()
if n == 0 {
return false
}
applied, _ := st.UpdateSubscriberSettingsArgsForCall(n - 1)
return applied == settings
}, subSettleTimeout, subCheckInterval, "the down track should get the settings")
sm.Close(false)
}
}
func TestSettingsKeptForSubscribe(t *testing.T) {
t.Run("media", func(t *testing.T) {
sm := newTestSubscriptionManager()
defer sm.Close(false)
resolver := newTestResolver(true, true, "pub", "pubID")
sm.params.TrackResolver = resolver.Resolve
settings := &livekit.UpdateTrackSettings{Disabled: true}
sm.UpdateSubscribedTrackSettings("track", settings)
// a cleanup pass between the settings and the subscribe must not drop the settings
sm.reconcileSubscriptions()
sm.SubscribeToTrack("track", false)
sm.lock.RLock()
s := sm.subscriptions["track"]
sm.lock.RUnlock()
require.Eventually(t, func() bool {
return !s.needsSubscribe()
}, subSettleTimeout, subCheckInterval, "track should be subscribed")
st := s.getSubscribedTrack().(*typesfakes.FakeSubscribedTrack)
require.Eventually(t, func() bool {
n := st.UpdateSubscriberSettingsCallCount()
if n == 0 {
return false
}
applied, _ := st.UpdateSubscriberSettingsArgsForCall(n - 1)
return applied == settings
}, subSettleTimeout, subCheckInterval, "the down track should get the settings")
// settings for a track that is never subscribed are cleaned up after notFoundTimeout
sm.UpdateSubscribedTrackSettings("other", settings)
require.Eventually(t, func() bool {
sm.lock.RLock()
defer sm.lock.RUnlock()
_, ok := sm.subscriptions["other"]
return !ok
}, subSettleTimeout, subCheckInterval, "unused settings should be cleaned up")
})
t.Run("data", func(t *testing.T) {
sm := newTestSubscriptionManager()
defer sm.Close(false)
resolver := newTestDataTrackResolver(true, true, "pub", "pubID")
sm.params.DataTrackResolver = resolver.Resolve
fps := uint32(5)
options := &livekit.DataTrackSubscriptionOptions{TargetFps: &fps}
sm.UpdateDataTrackSubscriptionOptions("track", options)
// a cleanup pass between the options and the subscribe must not drop the options
sm.reconcileDataTrackSubscriptions()
sm.SubscribeToDataTrack("track")
sm.lock.RLock()
s := sm.dataTrackSubscriptions["track"]
sm.lock.RUnlock()
require.Eventually(t, func() bool {
return s.getDataDownTrack() != nil
}, subSettleTimeout, subCheckInterval, "data track should be subscribed")
ddt := s.getDataDownTrack().(*typesfakes.FakeDataDownTrack)
n := ddt.UpdateSubscriptionOptionsCallCount()
require.NotZero(t, n)
require.Equal(t, options, ddt.UpdateSubscriptionOptionsArgsForCall(n-1))
})
}
func TestSubscribeBurstOverflowsQueue(t *testing.T) {
// a dropped reconcile must not wait for the ticker
saved := reconcileInterval
reconcileInterval = time.Hour
defer func() { reconcileInterval = saved }()
t.Run("media", func(t *testing.T) {
sm := newTestSubscriptionManager()
defer sm.Close(false)
// the first subscribe holds the worker until all are queued, so the queue overflows
release := make(chan struct{})
var holdOnce sync.Once
sm.params.TrackResolver = func(_ types.LocalParticipant, trackID livekit.TrackID) types.MediaResolverResult {
mt := &typesfakes.FakeMediaTrack{}
mt.IDReturns(trackID)
mt.AddSubscriberCalls(func(types.LocalParticipant) (types.SubscribedTrack, error) {
holdOnce.Do(func() { <-release })
st := &typesfakes.FakeSubscribedTrack{}
st.IDReturns(trackID)
st.MediaTrackReturns(mt)
return st, nil
})
return types.MediaResolverResult{
Track: mt,
HasPermission: true,
PublisherID: "pubID",
PublisherIdentity: "pub",
TrackChangedNotifier: utils.NewChangeNotifier(),
TrackRemovedNotifier: utils.NewChangeNotifier(),
}
}
numTracks := cap(sm.reconcileCh) + 10
for i := range numTracks {
sm.SubscribeToTrack(livekit.TrackID(fmt.Sprintf("TR_%d", i)), false)
}
close(release)
require.Eventually(t, func() bool {
sm.lock.RLock()
defer sm.lock.RUnlock()
for _, s := range sm.subscriptions {
if s.needsSubscribe() {
return false
}
}
return len(sm.subscriptions) == numTracks
}, subSettleTimeout, subCheckInterval, "every track should be subscribed")
})
t.Run("data", func(t *testing.T) {
sm := newTestSubscriptionManager()
defer sm.Close(false)
resolver := newTestDataTrackResolver(true, true, "pub", "pubID")
sm.params.DataTrackResolver = resolver.Resolve
release := make(chan struct{})
var holdOnce sync.Once
resolver.dataTrack.AddSubscriberCalls(func(types.LocalParticipant) (types.DataDownTrack, error) {
holdOnce.Do(func() { <-release })
ddt := &typesfakes.FakeDataDownTrack{}
ddt.PublishDataTrackReturns(resolver.dataTrack)
return ddt, nil
})
numTracks := cap(sm.reconcileDataTrackCh) + 10
for i := range numTracks {
sm.SubscribeToDataTrack(livekit.TrackID(fmt.Sprintf("DTR_%d", i)))
}
close(release)
require.Eventually(t, func() bool {
sm.lock.RLock()
defer sm.lock.RUnlock()
for _, s := range sm.dataTrackSubscriptions {
if s.needsSubscribe() {
return false
}
}
return len(sm.dataTrackSubscriptions) == numTracks
}, subSettleTimeout, subCheckInterval, "every data track should be subscribed")
})
}
func TestSubscriptionLimits(t *testing.T) {
sm := newTestSubscriptionManagerWithParams(testSubscriptionParams{
SubscriptionLimitAudio: 1,
SubscriptionLimitVideo: 1,
})
defer sm.Close(false)
resolver := newTestResolver(true, true, "pub", "pubID")
sm.params.TrackResolver = resolver.Resolve
subCount := atomic.Int32{}
failed := atomic.Bool{}
sm.params.OnTrackSubscribed = func(subTrack types.SubscribedTrack) {
subCount.Add(1)
}
sm.params.OnSubscriptionError = func(trackID livekit.TrackID, fatal bool, err error) {
failed.Store(true)
}
numParticipantSubscribed := atomic.Int32{}
numParticipantUnsubscribed := atomic.Int32{}
sm.OnSubscribeStatusChanged(func(pubID livekit.ParticipantID, subscribed bool) {
if subscribed {
numParticipantSubscribed.Add(1)
} else {
numParticipantUnsubscribed.Add(1)
}
})
sm.SubscribeToTrack("track", false)
s := sm.subscriptions["track"]
require.True(t, s.isDesired())
require.Eventually(t, func() bool {
return subCount.Load() == 1
}, subSettleTimeout, subCheckInterval, "track was not subscribed")
require.NotNil(t, s.getSubscribedTrack())
require.Len(t, sm.GetSubscribedTracks(), 1)
require.Eventually(t, func() bool {
return len(sm.GetSubscribedParticipants()) == 1
}, subSettleTimeout, subCheckInterval, "GetSubscribedParticipants should have returned one item")
require.Equal(t, "pubID", string(sm.GetSubscribedParticipants()[0]))
// ensure telemetry events are sent
tl := sm.params.Participant.GetTelemetryListener().(*typesfakes.FakeParticipantTelemetryListener)
require.Equal(t, 1, tl.OnTrackSubscribeRequestedCallCount())
// ensure bound
setTestSubscribedTrackBound(t, s.getSubscribedTrack())
require.Eventually(t, func() bool {
return !s.needsBind()
}, subSettleTimeout, subCheckInterval, "track was not bound")
// telemetry event should have been sent
require.Equal(t, 1, tl.OnTrackSubscribedCallCount())
// reach subscription limit, subscribe pending
sm.SubscribeToTrack("track2", false)
s2 := sm.subscriptions["track2"]
time.Sleep(subscriptionTimeout * 2)
require.True(t, s2.needsSubscribe())
require.Equal(t, 2, tl.OnTrackSubscribeRequestedCallCount())
require.Equal(t, 1, tl.OnTrackSubscribeFailedCallCount())
require.Len(t, sm.GetSubscribedTracks(), 1)
// unsubscribe track1, then track2 should be subscribed
sm.UnsubscribeFromTrack("track")
require.False(t, s.isDesired())
require.True(t, s.needsUnsubscribe())
// wait for unsubscribe to take effect
time.Sleep(reconcileInterval)
setTestSubscribedTrackClosed(t, s.getSubscribedTrack(), false)
require.Nil(t, s.getSubscribedTrack())
time.Sleep(reconcileInterval)
require.True(t, s2.isDesired())
require.False(t, s2.needsSubscribe())
require.EqualValues(t, 2, subCount.Load())
require.NotNil(t, s2.getSubscribedTrack())
require.Equal(t, 2, tl.OnTrackSubscribeRequestedCallCount())
require.Len(t, sm.GetSubscribedTracks(), 1)
// ensure bound
setTestSubscribedTrackBound(t, s2.getSubscribedTrack())
require.Eventually(t, func() bool {
return !s2.needsBind()
}, subSettleTimeout, subCheckInterval, "track was not bound")
// subscribe to track1 again, which should pending
sm.SubscribeToTrack("track", false)
s = sm.subscriptions["track"]
require.True(t, s.isDesired())
time.Sleep(subscriptionTimeout * 2)
require.True(t, s.needsSubscribe())
require.Equal(t, 3, tl.OnTrackSubscribeRequestedCallCount())
require.Equal(t, 2, tl.OnTrackSubscribeFailedCallCount())
require.Len(t, sm.GetSubscribedTracks(), 1)
}
func TestFailTrackBind(t *testing.T) {
sm := newTestSubscriptionManager()
defer sm.Close(false)
resolver := newTestResolver(true, true, "pub", "pubID")
sm.params.TrackResolver = resolver.Resolve
var subErr atomic.Error
sm.params.OnSubscriptionError = func(trackID livekit.TrackID, fatal bool, err error) {
subErr.Store(err)
}
sm.SubscribeToTrack("track", false)
s := sm.subscriptions["track"]
require.Eventually(t, func() bool {
return s.getSubscribedTrack() != nil
}, subSettleTimeout, subCheckInterval, "track was not subscribed")
sm.FailTrackBind("track", webrtc.ErrUnsupportedCodec)
tl := sm.params.Participant.GetTelemetryListener().(*typesfakes.FakeParticipantTelemetryListener)
require.Equal(t, 1, tl.OnTrackSubscribeFailedCallCount())
_, trackID, err, isUserError := tl.OnTrackSubscribeFailedArgsForCall(0)
require.Equal(t, livekit.TrackID("track"), trackID)
require.ErrorIs(t, err, webrtc.ErrUnsupportedCodec)
require.True(t, isUserError)
require.ErrorIs(t, subErr.Load(), webrtc.ErrUnsupportedCodec)
require.False(t, s.isDesired())
// an unknown track is ignored
sm.FailTrackBind("unknown", webrtc.ErrUnsupportedCodec)
require.Equal(t, 1, tl.OnTrackSubscribeFailedCallCount())
}
func TestSubscribeDataTrack(t *testing.T) {
t.Run("no track permission", func(t *testing.T) {
sm := newTestSubscriptionManager()
defer sm.Close(false)
resolver := newTestDataTrackResolver(false, true, "pub", "pubID")
sm.params.DataTrackResolver = resolver.Resolve
sm.SubscribeToDataTrack("track")
sm.lock.RLock()
s := sm.dataTrackSubscriptions["track"]
sm.lock.RUnlock()
require.Eventually(t, func() bool {
return !s.getHasPermission()
}, subSettleTimeout, subCheckInterval, "should not have permission to subscribe")
time.Sleep(subscriptionTimeout)
// isDesired remains unchanged, no subscriber added to the data track
require.True(t, s.isDesired())
require.True(t, s.needsSubscribe())
require.Equal(t, 0, resolver.dataTrack.AddSubscriberCallCount())
// subscriber should have been notified that permission was denied
p := sm.params.Participant.(*typesfakes.FakeLocalParticipant)
require.Equal(t, 1, p.SendSubscriptionPermissionUpdateCallCount())
_, _, allowed := p.SendSubscriptionPermissionUpdateArgsForCall(0)
require.False(t, allowed)
// give permissions now
resolver.SetHasPermission(true)
require.Eventually(t, func() bool {
return !s.needsSubscribe()
}, subSettleTimeout, subCheckInterval, "should be subscribed")
require.NotNil(t, s.getDataDownTrack())
require.Equal(t, 1, resolver.dataTrack.AddSubscriberCallCount())
})
t.Run("permission revoked while subscribed", func(t *testing.T) {
sm := newTestSubscriptionManager()
defer sm.Close(false)
resolver := newTestDataTrackResolver(true, true, "pub", "pubID")
sm.params.DataTrackResolver = resolver.Resolve
sm.SubscribeToDataTrack("track")
sm.lock.RLock()
s := sm.dataTrackSubscriptions["track"]
sm.lock.RUnlock()
require.Eventually(t, func() bool {
return !s.needsSubscribe()
}, subSettleTimeout, subCheckInterval, "should be subscribed")
// revoke permission and close the down track like the publisher would
// (DataTrack.RevokeDisallowedSubscribers -> RemoveSubscriber -> DataDownTrack.Close)
resolver.SetHasPermission(false)
ddt := s.getDataDownTrack()
require.NotNil(t, ddt)
setTestDataDownTrackClosed(t, ddt)
// subscription stays desired but is denied on resubscribe attempts
require.Nil(t, s.getDataDownTrack())
require.True(t, s.isDesired())
require.Eventually(t, func() bool {
return !s.getHasPermission()
}, subSettleTimeout, subCheckInterval, "should not have permission to resubscribe")
require.True(t, s.needsSubscribe())
require.Equal(t, 1, resolver.dataTrack.AddSubscriberCallCount())
// give permission back, should resubscribe
resolver.SetHasPermission(true)
require.Eventually(t, func() bool {
return !s.needsSubscribe() && s.getDataDownTrack() != nil
}, subSettleTimeout, subCheckInterval, "should be resubscribed")
require.Equal(t, 2, resolver.dataTrack.AddSubscriberCallCount())
})
t.Run("subscribe again during unsubscribe", func(t *testing.T) {
sm := newTestSubscriptionManager()
defer sm.Close(false)
resolver := newTestDataTrackResolver(true, true, "pub", "pubID")
sm.params.DataTrackResolver = resolver.Resolve
sm.SubscribeToDataTrack("track")
sm.lock.RLock()
s := sm.dataTrackSubscriptions["track"]
sm.lock.RUnlock()
require.Eventually(t, func() bool {
return !s.needsSubscribe()
}, subSettleTimeout, subCheckInterval, "should be subscribed")
// the client subscribes again while the removal runs,
// then the down track closes, as DataTrack.RemoveSubscriber does
ddt := s.getDataDownTrack().(*typesfakes.FakeDataDownTrack)
resolver.dataTrack.RemoveSubscriberCalls(func(livekit.ParticipantID) {
sm.SubscribeToDataTrack("track")
ddt.OnCloseArgsForCall(0)()
})
sm.UnsubscribeFromDataTrack("track")
require.Eventually(t, func() bool {
return resolver.dataTrack.AddSubscriberCallCount() == 2 && s.getDataDownTrack() != nil
}, subSettleTimeout, subCheckInterval, "should be subscribed again")
sm.lock.RLock()
require.Same(t, s, sm.dataTrackSubscriptions["track"])
sm.lock.RUnlock()
})
t.Run("unsubscribe before data track resolves", func(t *testing.T) {
sm := newTestSubscriptionManager()
defer sm.Close(false)
// no track available, subscribe attempts fail with ErrTrackNotFound
resolver := newTestDataTrackResolver(true, false, "pub", "pubID")
sm.params.DataTrackResolver = resolver.Resolve
sm.SubscribeToDataTrack("track")
sm.lock.RLock()
s := sm.dataTrackSubscriptions["track"]
sm.lock.RUnlock()
require.NotNil(t, s)
// let the worker attempt (and fail) the subscribe
require.Eventually(t, func() bool {
return s.getNumAttempts() > 0
}, subSettleTimeout, subCheckInterval, "no subscribe attempt was made")
sm.UnsubscribeFromDataTrack("track")
require.Eventually(t, func() bool {
sm.lock.RLock()
_, ok := sm.dataTrackSubscriptions["track"]
sm.lock.RUnlock()
return !ok
}, subSettleTimeout, subCheckInterval, "data track subscription was not cleaned up")
})
}
func TestSubscribePacerTakesVideoTokensOnly(t *testing.T) {
for _, tc := range []struct {
name string
kind livekit.TrackType
isSync bool
setupFails bool
tokenIsUsed bool
}{
{"video", livekit.TrackType_VIDEO, false, false, true},
{"audio", livekit.TrackType_AUDIO, false, false, false},
{"video one-shot", livekit.TrackType_VIDEO, true, false, false},
// a failed setup spends its token, like a real pacer that gates before setup
{"video setup fails", livekit.TrackType_VIDEO, false, true, true},
} {
t.Run(tc.name, func(t *testing.T) {
// a rate this low holds one token and takes 1000 s to refill it, so after one
// take the next subscribe waits however slow the test runs
pacer := NewSubscribePacer([]float64{0.001})
sm := newTestSubscriptionManager()
defer sm.Close(false)
sm.params.SubscribePacer = pacer
attempted := atomic.Bool{}
resolver := newTestResolver(true, true, "pub", "pubID")
sm.params.TrackResolver = func(sub types.LocalParticipant, trackID livekit.TrackID) types.MediaResolverResult {
res := resolver.Resolve(sub, trackID)
mt := res.Track.(*typesfakes.FakeMediaTrack)
mt.KindReturns(tc.kind)
if tc.setupFails {
mt.AddSubscriberCalls(func(types.LocalParticipant) (types.SubscribedTrack, error) {
attempted.Store(true)
return nil, ErrNoReceiver
})
}
return res
}
sm.params.OnTrackSubscribed = func(subTrack types.SubscribedTrack) {
attempted.Store(true)
}
sm.SubscribeToTrack("track", tc.isSync)
require.Eventually(t, attempted.Load, subSettleTimeout, subCheckInterval, "subscribe was not attempted")
require.Equal(t, tc.tokenIsUsed, pacer.Delays(time.Now())["0.001"] > 0)
})
}
}
// A canSubscribe revoke that lands while a subscribe is in flight cannot see the subscription
// yet. The participant here allows the subscribe at its start and is revoked by the time the
// subscription is recorded, so the subscribe must undo itself.
func TestSubscribeRevokedInFlight(t *testing.T) {
revokeAfterFirstCheck := func(sm *SubscriptionManager) {
p := sm.params.Participant.(*typesfakes.FakeLocalParticipant)
p.CanSubscribeReturnsOnCall(0, true)
p.CanSubscribeReturns(false)
}
t.Run("media", func(t *testing.T) {
sm := newTestSubscriptionManager()
defer sm.Close(false)
revokeAfterFirstCheck(sm)
resolver := newTestResolver(true, true, "pub", "pubID")
var lock sync.Mutex
var tracks []*typesfakes.FakeMediaTrack
sm.params.TrackResolver = func(sub types.LocalParticipant, trackID livekit.TrackID) types.MediaResolverResult {
res := resolver.Resolve(sub, trackID)
lock.Lock()
tracks = append(tracks, res.Track.(*typesfakes.FakeMediaTrack))
lock.Unlock()
return res
}
subscribed := atomic.Bool{}
sm.params.OnTrackSubscribed = func(subTrack types.SubscribedTrack) {
subscribed.Store(true)
}
sm.SubscribeToTrack("track", false)
require.Eventually(t, func() bool {
lock.Lock()
defer lock.Unlock()
return len(tracks) != 0 && tracks[0].RemoveSubscriberCallCount() == 1
}, subSettleTimeout, subCheckInterval, "the subscriber should be removed")
lock.Lock()
subID, isExpectedToResume := tracks[0].RemoveSubscriberArgsForCall(0)
lock.Unlock()
require.Equal(t, sm.params.Participant.ID(), subID)
require.False(t, isExpectedToResume)
require.Empty(t, sm.GetSubscribedParticipants())
require.False(t, subscribed.Load())
})
t.Run("data", func(t *testing.T) {
sm := newTestSubscriptionManager()
defer sm.Close(false)
revokeAfterFirstCheck(sm)
resolver := newTestDataTrackResolver(true, true, "pub", "pubID")
sm.params.DataTrackResolver = resolver.Resolve
sm.SubscribeToDataTrack("track")
require.Eventually(t, func() bool {
return resolver.dataTrack.RemoveSubscriberCallCount() == 1
}, subSettleTimeout, subCheckInterval, "the subscriber should be removed")
require.Equal(t, sm.params.Participant.ID(), resolver.dataTrack.RemoveSubscriberArgsForCall(0))
require.Empty(t, sm.GetSubscribedParticipants())
})
}
type testSubscriptionParams struct {
SubscriptionLimitAudio int32
SubscriptionLimitVideo int32
}
func newTestSubscriptionManager() *SubscriptionManager {
return newTestSubscriptionManagerWithParams(testSubscriptionParams{})
}
func newTestSubscriptionManagerWithParams(params testSubscriptionParams) *SubscriptionManager {
p := &typesfakes.FakeLocalParticipant{}
p.CanSubscribeReturns(true)
p.IDReturns("subID")
p.IdentityReturns("sub")
p.KindReturns(livekit.ParticipantInfo_STANDARD)
tl := &typesfakes.FakeParticipantTelemetryListener{}
p.GetTelemetryListenerReturns(tl)
return NewSubscriptionManager(SubscriptionManagerParams{
Participant: p,
Logger: logger.GetLogger(),
OnTrackSubscribed: func(subTrack types.SubscribedTrack) {},
OnTrackUnsubscribed: func(subTrack types.SubscribedTrack) {},
OnSubscriptionError: func(trackID livekit.TrackID, fatal bool, err error) {},
TrackResolver: func(sub types.LocalParticipant, trackID livekit.TrackID) types.MediaResolverResult {
return types.MediaResolverResult{}
},
SubscriptionLimitAudio: params.SubscriptionLimitAudio,
SubscriptionLimitVideo: params.SubscriptionLimitVideo,
})
}
type testResolver struct {
lock sync.Mutex
hasPermission bool
hasTrack bool
pubIdentity livekit.ParticipantIdentity
pubID livekit.ParticipantID
paused bool
}
func newTestResolver(hasPermission bool, hasTrack bool, pubIdentity livekit.ParticipantIdentity, pubID livekit.ParticipantID) *testResolver {
return &testResolver{
hasPermission: hasPermission,
hasTrack: hasTrack,
pubIdentity: pubIdentity,
pubID: pubID,
}
}
func (t *testResolver) SetPause(paused bool) {
t.lock.Lock()
defer t.lock.Unlock()
t.paused = paused
}
func (t *testResolver) Resolve(_subscriber types.LocalParticipant, trackID livekit.TrackID) types.MediaResolverResult {
t.lock.Lock()
defer t.lock.Unlock()
res := types.MediaResolverResult{
TrackChangedNotifier: utils.NewChangeNotifier(),
TrackRemovedNotifier: utils.NewChangeNotifier(),
HasPermission: t.hasPermission,
PublisherID: t.pubID,
PublisherIdentity: t.pubIdentity,
}
if t.hasTrack && !t.paused {
mt := &typesfakes.FakeMediaTrack{}
st := &typesfakes.FakeSubscribedTrack{}
st.IDReturns(trackID)
st.PublisherIDReturns(t.pubID)
st.PublisherIdentityReturns(t.pubIdentity)
mt.AddSubscriberCalls(func(sub types.LocalParticipant) (types.SubscribedTrack, error) {
st.SubscriberReturns(sub)
return st, nil
})
st.MediaTrackReturns(mt)
res.Track = mt
}
return res
}
type testDataTrackResolver struct {
lock sync.Mutex
hasPermission bool
hasTrack bool
pubIdentity livekit.ParticipantIdentity
pubID livekit.ParticipantID
dataTrack *typesfakes.FakeDataTrack
}
func newTestDataTrackResolver(hasPermission bool, hasTrack bool, pubIdentity livekit.ParticipantIdentity, pubID livekit.ParticipantID) *testDataTrackResolver {
r := &testDataTrackResolver{
hasPermission: hasPermission,
hasTrack: hasTrack,
pubIdentity: pubIdentity,
pubID: pubID,
dataTrack: &typesfakes.FakeDataTrack{},
}
r.dataTrack.PublisherIDReturns(pubID)
r.dataTrack.PublisherIdentityReturns(pubIdentity)
r.dataTrack.AddSubscriberCalls(func(sub types.LocalParticipant) (types.DataDownTrack, error) {
ddt := &typesfakes.FakeDataDownTrack{}
ddt.PublishDataTrackReturns(r.dataTrack)
return ddt, nil
})
return r
}
func (t *testDataTrackResolver) SetHasPermission(hasPermission bool) {
t.lock.Lock()
defer t.lock.Unlock()
t.hasPermission = hasPermission
}
func (t *testDataTrackResolver) Resolve(_subscriber types.LocalParticipant, trackID livekit.TrackID) types.DataResolverResult {
t.lock.Lock()
defer t.lock.Unlock()
res := types.DataResolverResult{
TrackChangedNotifier: utils.NewChangeNotifier(),
TrackRemovedNotifier: utils.NewChangeNotifier(),
HasPermission: t.hasPermission,
PublisherID: t.pubID,
PublisherIdentity: t.pubIdentity,
}
if t.hasTrack {
t.dataTrack.IDReturns(trackID)
res.DataTrack = t.dataTrack
}
return res
}
func setTestSubscribedTrackBound(t *testing.T, st types.SubscribedTrack) {
fst, ok := st.(*typesfakes.FakeSubscribedTrack)
require.True(t, ok)
for i := 0; i < fst.AddOnBindCallCount(); i++ {
fst.AddOnBindArgsForCall(i)(nil)
}
}
func setTestSubscribedTrackClosed(t *testing.T, st types.SubscribedTrack, isExpectedToResume bool) {
fst, ok := st.(*typesfakes.FakeSubscribedTrack)
require.True(t, ok)
fst.OnCloseArgsForCall(0)(isExpectedToResume)
}
func setTestDataDownTrackClosed(t *testing.T, ddt types.DataDownTrack) {
fddt, ok := ddt.(*typesfakes.FakeDataDownTrack)
require.True(t, ok)
fddt.OnCloseArgsForCall(0)()
}